@cosmicdrift/kumiko-framework 0.224.2 → 0.226.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,236 @@
1
+ import type { ZodType } from "zod";
2
+ import { isPagedQueryHandler } from "../define-handler";
3
+ import { normalizeListColumn } from "../screen-helpers";
4
+ import type {
5
+ DashboardScreenDefinition,
6
+ DashboardStatPanel,
7
+ EditLayout,
8
+ FeatureDefinition,
9
+ ListColumnSpec,
10
+ ProjectionDetailScreenDefinition,
11
+ ProjectionListScreenDefinition,
12
+ QueryHandlerDef,
13
+ ScreenDefinition,
14
+ } from "../types";
15
+ import { buildQueryHandlerMap } from "./projection-list-screens";
16
+ import { getZodObjectShape, getZodRowShape } from "./zod-shape";
17
+
18
+ // fw#2493: query handlers can now declare `outputSchema` (the Zod shape of
19
+ // their actual return value). This validator is entirely opt-in and
20
+ // additive — a handler without `outputSchema`, or one whose declared shape
21
+ // this can't introspect (e.g. a z.union), leaves every check below a no-op,
22
+ // same "capability absent, no throw" policy as the input-schema checks in
23
+ // projection-list-screens.ts. Must run after validateQueryRefs (fw#2178):
24
+ // an unresolvable query QN is that validator's job to report — by the time
25
+ // this runs every `query` string here already resolves.
26
+
27
+ type ShapeLookup = Record<string, ZodType>;
28
+
29
+ function checkFieldExists(
30
+ shape: ShapeLookup | undefined,
31
+ field: string,
32
+ buildMessage: () => string,
33
+ ): void {
34
+ // skip: capability absent (no introspectable shape) or field already present — nothing to validate
35
+ if (shape === undefined || field in shape) return;
36
+ throw new Error(buildMessage());
37
+ }
38
+
39
+ // Mirrors entity-list-screens.ts's "unlabeled unknown column" convention: a
40
+ // column with its own `label` is a virtual/computed cell drawn by a
41
+ // renderer, not a row field — exempt from the shape check.
42
+ function checkColumnField(
43
+ rowShape: ShapeLookup | undefined,
44
+ column: ListColumnSpec,
45
+ buildMessage: (field: string) => string,
46
+ ): void {
47
+ const normalized = normalizeListColumn(column);
48
+ // skip: labeled column is a virtual/computed cell drawn by a renderer, not a row field
49
+ if (normalized.label !== undefined) return;
50
+ checkFieldExists(rowShape, normalized.field, () => buildMessage(normalized.field));
51
+ }
52
+
53
+ // A paged handler (definePagedQueryHandler) whose outputSchema describes the
54
+ // row shape directly instead of the `{ rows, nextCursor, total? }` envelope
55
+ // would otherwise fail silently: getZodRowShape finds no "rows" key and every
56
+ // column check above just no-ops, so the author believes columns are checked
57
+ // when none are. Catches only the realistic mistake (envelope has no "rows"
58
+ // field at all) — an introspectable schema that isn't even a ZodObject is
59
+ // still "capability absent" per the module policy above.
60
+ function checkPagedHandlerOutputSchemaShape(
61
+ queryHandlers: ReadonlyMap<string, QueryHandlerDef>,
62
+ ): void {
63
+ for (const [qn, handler] of queryHandlers) {
64
+ if (!isPagedQueryHandler(handler) || handler.outputSchema === undefined) continue;
65
+ const shape = getZodObjectShape(handler.outputSchema);
66
+ if (shape === undefined || "rows" in shape) continue;
67
+ throw new Error(
68
+ `Query handler "${qn}" is a paged handler (definePagedQueryHandler) but its outputSchema does not describe the paged envelope { rows: [...], nextCursor, total? } — it has no "rows" field. Did you pass the row schema instead of wrapping it in the envelope?`,
69
+ );
70
+ }
71
+ }
72
+
73
+ function checkProjectionListOutputColumns(
74
+ queryHandlers: ReadonlyMap<string, QueryHandlerDef>,
75
+ featureName: string,
76
+ screenId: string,
77
+ screen: ProjectionListScreenDefinition,
78
+ ): void {
79
+ const rowShape = getZodRowShape(queryHandlers.get(screen.query)?.outputSchema);
80
+ for (const column of screen.columns) {
81
+ checkColumnField(
82
+ rowShape,
83
+ column,
84
+ (field) =>
85
+ `[Feature ${featureName}] Screen "${screenId}" (projectionList) column "${field}" is not present in query "${screen.query}"'s outputSchema — check for a typo, or add a "label" to mark it a virtual/computed column.`,
86
+ );
87
+ }
88
+ }
89
+
90
+ function checkEditLayoutOutputColumns(
91
+ queryHandlers: ReadonlyMap<string, QueryHandlerDef>,
92
+ featureName: string,
93
+ screenId: string,
94
+ screenType: string,
95
+ layout: EditLayout,
96
+ ): void {
97
+ for (const section of layout.sections) {
98
+ if (section.kind !== "relatedList") continue;
99
+ const rowShape = getZodRowShape(queryHandlers.get(section.query)?.outputSchema);
100
+ for (const column of section.columns) {
101
+ checkColumnField(
102
+ rowShape,
103
+ column,
104
+ (field) =>
105
+ `[Feature ${featureName}] Screen "${screenId}" (${screenType}) relatedList section "${section.title}" column "${field}" is not present in query "${section.query}"'s outputSchema — check for a typo, or add a "label" to mark it a virtual/computed column.`,
106
+ );
107
+ }
108
+ }
109
+ }
110
+
111
+ function checkProjectionDetailOutputFields(
112
+ queryHandlers: ReadonlyMap<string, QueryHandlerDef>,
113
+ featureName: string,
114
+ screenId: string,
115
+ screen: ProjectionDetailScreenDefinition,
116
+ ): void {
117
+ const recordShape = getZodObjectShape(queryHandlers.get(screen.query)?.outputSchema);
118
+ const prefix = `[Feature ${featureName}] Screen "${screenId}" (projectionDetail)`;
119
+ const checkHeaderField = (part: "title" | "subtitle" | "status", field: string): void => {
120
+ checkFieldExists(
121
+ recordShape,
122
+ field,
123
+ () =>
124
+ `${prefix} header.${part} references field "${field}" which is not present in query "${screen.query}"'s outputSchema.`,
125
+ );
126
+ };
127
+ if (screen.header !== undefined) {
128
+ checkHeaderField("title", screen.header.title);
129
+ if (screen.header.subtitle !== undefined) checkHeaderField("subtitle", screen.header.subtitle);
130
+ if (screen.header.status !== undefined) checkHeaderField("status", screen.header.status);
131
+ }
132
+ for (const metric of screen.metrics ?? []) {
133
+ checkFieldExists(
134
+ recordShape,
135
+ metric,
136
+ () =>
137
+ `${prefix} metrics references field "${metric}" which is not present in query "${screen.query}"'s outputSchema.`,
138
+ );
139
+ }
140
+ }
141
+
142
+ // Stat panels' query contract is a flat record (not the paged `{ rows }`
143
+ // envelope) — see DashboardStatPanel's doc in screen.ts — so this checks
144
+ // the handler's outputSchema shape directly, not its row shape.
145
+ function checkDashboardStatPanelFields(
146
+ queryHandlers: ReadonlyMap<string, QueryHandlerDef>,
147
+ featureName: string,
148
+ screenId: string,
149
+ panel: DashboardStatPanel,
150
+ ): void {
151
+ const recordShape = getZodObjectShape(queryHandlers.get(panel.query)?.outputSchema);
152
+ const prefix = `[Feature ${featureName}] Screen "${screenId}" (dashboard) panel "${panel.id}"`;
153
+ const checkPanelField = (part: string, field: string | undefined): void => {
154
+ // skip: this stat-panel field slot is optional and wasn't declared — nothing to validate
155
+ if (field === undefined) return;
156
+ checkFieldExists(
157
+ recordShape,
158
+ field,
159
+ () =>
160
+ `${prefix} ${part} references field "${field}" which is not present in query "${panel.query}"'s outputSchema.`,
161
+ );
162
+ };
163
+ checkPanelField("valueField", panel.valueField);
164
+ checkPanelField("subField", panel.subField);
165
+ checkPanelField("toneField", panel.toneField);
166
+ checkPanelField("deltaField", panel.deltaField);
167
+ checkPanelField("deltaDirectionField", panel.deltaDirectionField);
168
+ checkPanelField("deltaToneField", panel.deltaToneField);
169
+ }
170
+
171
+ function checkDashboardOutputFields(
172
+ queryHandlers: ReadonlyMap<string, QueryHandlerDef>,
173
+ featureName: string,
174
+ screenId: string,
175
+ screen: DashboardScreenDefinition,
176
+ ): void {
177
+ for (const panel of screen.panels) {
178
+ if (panel.kind === "stat") {
179
+ checkDashboardStatPanelFields(queryHandlers, featureName, screenId, panel);
180
+ } else if (panel.kind === "stat-group") {
181
+ for (const stat of panel.stats) {
182
+ checkDashboardStatPanelFields(queryHandlers, featureName, screenId, stat);
183
+ }
184
+ } else if (panel.kind === "list") {
185
+ const rowShape = getZodRowShape(queryHandlers.get(panel.query)?.outputSchema);
186
+ for (const column of panel.columns) {
187
+ checkColumnField(
188
+ rowShape,
189
+ column,
190
+ (field) =>
191
+ `[Feature ${featureName}] Screen "${screenId}" (dashboard) panel "${panel.id}" column "${field}" is not present in query "${panel.query}"'s outputSchema — check for a typo, or add a "label" to mark it a virtual/computed column.`,
192
+ );
193
+ }
194
+ }
195
+ // chart/feed/progress-list/custom panels have a fixed query-result
196
+ // contract with no author-declared field names to check.
197
+ }
198
+ }
199
+
200
+ function checkScreenOutputColumns(
201
+ queryHandlers: ReadonlyMap<string, QueryHandlerDef>,
202
+ featureName: string,
203
+ screenId: string,
204
+ screen: ScreenDefinition,
205
+ ): void {
206
+ if (screen.type === "projectionList") {
207
+ checkProjectionListOutputColumns(queryHandlers, featureName, screenId, screen);
208
+ } else if (screen.type === "projectionDetail") {
209
+ checkProjectionDetailOutputFields(queryHandlers, featureName, screenId, screen);
210
+ checkEditLayoutOutputColumns(
211
+ queryHandlers,
212
+ featureName,
213
+ screenId,
214
+ "projectionDetail",
215
+ screen.layout,
216
+ );
217
+ } else if (
218
+ screen.type === "entityEdit" ||
219
+ screen.type === "actionForm" ||
220
+ screen.type === "configEdit"
221
+ ) {
222
+ checkEditLayoutOutputColumns(queryHandlers, featureName, screenId, screen.type, screen.layout);
223
+ } else if (screen.type === "dashboard") {
224
+ checkDashboardOutputFields(queryHandlers, featureName, screenId, screen);
225
+ }
226
+ }
227
+
228
+ export function validateQueryOutputColumns(features: readonly FeatureDefinition[]): void {
229
+ const queryHandlers = buildQueryHandlerMap(features);
230
+ checkPagedHandlerOutputSchemaShape(queryHandlers);
231
+ for (const feature of features) {
232
+ for (const [screenId, screen] of Object.entries(feature.screens)) {
233
+ checkScreenOutputColumns(queryHandlers, feature.name, screenId, screen);
234
+ }
235
+ }
236
+ }
@@ -6,7 +6,7 @@
6
6
 
7
7
  import { NO_WIDGET_FIELD_TYPES } from "@cosmicdrift/kumiko-types/fields";
8
8
  import { rowMetaFieldNames } from "../../db/table-builder";
9
- import { isValidQn, qualifyEntityName } from "../qualified-name";
9
+ import { isKebabSegment, isValidQn, qualifyEntityName } from "../qualified-name";
10
10
  import { getAllowedFilterOps, isFieldFilterable } from "../screen-filter-ops";
11
11
  import { isExtensionEditSection, normalizeEditField, normalizeListColumn } from "../screen-helpers";
12
12
  import type { EntityDefinition, FeatureDefinition } from "../types";
@@ -83,6 +83,14 @@ function validateNoWidgetRequiredField(
83
83
  // entityList and projectionList (framework#1708) — projectionList has no
84
84
  // `screen.entity`, so there's no same-entity row["id"] auto-fill case: any
85
85
  // entityEdit target without an explicit entityId reaches create there.
86
+ //
87
+ // dashboard targets (framework#1708 follow-up, commit 4e0d6cb26) also read
88
+ // URL search params — but only for the single `filter.id` a dashboard
89
+ // declares (useFilterParams in dashboard-body.tsx seeds its value from
90
+ // `nav.searchParams[filter.id]`). A dashboard with no `filter` has nowhere
91
+ // for the value to land, and a params extractor whose keys don't include
92
+ // the filter's id would silently miss it — both are boot errors instead of
93
+ // a silently-empty dashboard.
86
94
  function validateRowActionNavigateParams(
87
95
  featureName: string,
88
96
  screenId: string,
@@ -93,16 +101,38 @@ function validateRowActionNavigateParams(
93
101
  ): void {
94
102
  // skip: not a navigate-with-params action — nothing to validate here.
95
103
  if (action.kind !== "navigate" || action.params === undefined) return;
96
- // entityList/projectionList targets also read URL search params (Tier
104
+ // skip: unresolvable/custom target already reported (or exempt) elsewhere.
105
+ if (target === undefined || target.screen.type === "custom") return;
106
+ // skip: entityList/projectionList targets also read URL search params (Tier
97
107
  // 2.7c filter-prefill, see use-list-url-state.ts: `<screenId>.q/.sort/
98
108
  // .dir/.page/.f.<field>`), not just actionForm/entityEdit-create.
99
- const exemptTargetType =
100
- target === undefined ||
101
- target.screen.type === "custom" ||
102
- target.screen.type === "entityList" ||
103
- target.screen.type === "projectionList";
104
- // skip: unresolvable/custom/list target already reported (or exempt) elsewhere.
105
- if (exemptTargetType) return;
109
+ if (target.screen.type === "entityList" || target.screen.type === "projectionList") return;
110
+
111
+ if (target.screen.type === "dashboard") {
112
+ const targetDescriptor =
113
+ action.screen !== undefined ? `"${action.screen}"` : `entity "${action.entity}"`;
114
+ const filter = target.screen.filter;
115
+ if (filter === undefined) {
116
+ throw new Error(
117
+ `[Feature ${featureName}] Screen "${screenId}" (${screenType}) rowAction "${action.id}" sets ` +
118
+ `params on navigate-target ${targetDescriptor} (dashboard) — target dashboard declares no ` +
119
+ `filter — params would be a no-op. Remove the params extractor, or add a \`filter\` to the ` +
120
+ `target dashboard so it has somewhere to read the value from.`,
121
+ );
122
+ }
123
+ const extractedKeys =
124
+ "pick" in action.params ? action.params.pick : Object.keys(action.params.map);
125
+ if (!extractedKeys.includes(filter.id)) {
126
+ throw new Error(
127
+ `[Feature ${featureName}] Screen "${screenId}" (${screenType}) rowAction "${action.id}" sets ` +
128
+ `params [${extractedKeys.join(", ")}] on navigate-target ${targetDescriptor} (dashboard) whose ` +
129
+ `filter id is "${filter.id}" — none of the extracted keys match, so the filter would stay ` +
130
+ `unset. Fix the params extractor to produce a "${filter.id}" key, or remove it.`,
131
+ );
132
+ }
133
+ // skip: filter present and the extractor's keys cover it — valid dashboard deep-link.
134
+ return;
135
+ }
106
136
 
107
137
  const isEntityEditUpdate =
108
138
  target.screen.type === "entityEdit" &&
@@ -140,6 +170,17 @@ function validateWizardLayout(
140
170
  layout: EditLayout,
141
171
  featureMap: ReadonlyMap<string, FeatureDefinition>,
142
172
  ): void {
173
+ // Tabs truncate the layout to one section, but scopeFieldNames derives
174
+ // required-field validation from the `fields` prop, not from layout — a
175
+ // hidden tab could hide a required field and silently block submit.
176
+ // projectionDetail has no submit, so tabs are safe there (see its own
177
+ // branch in validateScreens) but not here.
178
+ if (layout.mode === "tabs") {
179
+ throw new Error(
180
+ `[Feature ${featureName}] Screen "${screenId}" (${screenType}) sets mode: "tabs" — tabs are only ` +
181
+ `supported on projectionDetail. Use mode: "wizard" or "single" instead.`,
182
+ );
183
+ }
143
184
  // "form-draft" is hardcoded because the framework layer must not depend on
144
185
  // @cosmicdrift/kumiko-bundled-features — same precedence as the
145
186
  // "user-data-rights" check in gdpr-storage.ts.
@@ -572,6 +613,54 @@ export function validateScreens(
572
613
  `declare at least one section.`,
573
614
  );
574
615
  }
616
+ if (screen.layout.mode === "tabs") {
617
+ if (screen.layout.sections.length < 2) {
618
+ throw new Error(
619
+ `[Feature ${feature.name}] Screen "${screenId}" (projectionDetail) has mode: "tabs" but only ` +
620
+ `${screen.layout.sections.length} section(s) — tabs need at least 2 sections.`,
621
+ );
622
+ }
623
+ const tabIds = new Set<string>();
624
+ screen.layout.sections.forEach((section, index) => {
625
+ if (section.title === undefined || section.title.trim().length === 0) {
626
+ throw new Error(
627
+ `[Feature ${feature.name}] Screen "${screenId}" (projectionDetail) has mode: "tabs" but ` +
628
+ `sections[${index}] has no title — every tab needs a title.`,
629
+ );
630
+ }
631
+ if (section.id === undefined || section.id.trim().length === 0) {
632
+ throw new Error(
633
+ `[Feature ${feature.name}] Screen "${screenId}" (projectionDetail) has mode: "tabs" but ` +
634
+ `sections[${index}] ("${section.title}") has no id — every tab needs a stable id for ` +
635
+ `the ?tab= param.`,
636
+ );
637
+ }
638
+ if (!isKebabSegment(section.id)) {
639
+ throw new Error(
640
+ `[Feature ${feature.name}] Screen "${screenId}" (projectionDetail) sections[${index}] ` +
641
+ `("${section.title}") has id "${section.id}" — must be kebab-case.`,
642
+ );
643
+ }
644
+ if (tabIds.has(section.id)) {
645
+ throw new Error(
646
+ `[Feature ${feature.name}] Screen "${screenId}" (projectionDetail) has duplicate tab id ` +
647
+ `"${section.id}" (sections[${index}]).`,
648
+ );
649
+ }
650
+ tabIds.add(section.id);
651
+ });
652
+ }
653
+ if (screen.metrics !== undefined) {
654
+ for (const metric of screen.metrics) {
655
+ if (screen.fieldLabels?.[metric] === undefined) {
656
+ throw new Error(
657
+ `[Feature ${feature.name}] Screen "${screenId}" (projectionDetail) metric "${metric}" has ` +
658
+ `no entry in fieldLabels — every metrics field needs a label, there is no fallback to ` +
659
+ `the raw column name.`,
660
+ );
661
+ }
662
+ }
663
+ }
575
664
  for (const section of screen.layout.sections) {
576
665
  if (isExtensionEditSection(section)) {
577
666
  throw new Error(
@@ -0,0 +1,51 @@
1
+ import { ZodArray, ZodDefault, ZodNullable, ZodObject, ZodOptional, type ZodType } from "zod";
2
+
3
+ // Drills through wrapper types (.nullable(), .optional(), .default()) a
4
+ // handler's schema may use around its actual object/array shape — e.g. a
5
+ // detail query returning `Row | null` when the record doesn't exist.
6
+ // Bounded to avoid looping on a pathological schema.
7
+ function unwrapZodType(schema: ZodType): ZodType {
8
+ let current: ZodType = schema;
9
+ for (let i = 0; i < 8; i++) {
10
+ if (
11
+ current instanceof ZodOptional ||
12
+ current instanceof ZodNullable ||
13
+ current instanceof ZodDefault
14
+ ) {
15
+ // @cast-boundary schema-walk — Zod v4's .unwrap() types its result as
16
+ // the core $ZodType, not the z.ZodType wrapper; same runtime instance
17
+ // (see env/_zod-introspect.ts for the same drill on ZodDefault/Optional).
18
+ current = current.unwrap() as ZodType;
19
+ continue;
20
+ }
21
+ break;
22
+ }
23
+ return current;
24
+ }
25
+
26
+ // Non-ZodObject schemas (e.g. a z.union across payload shapes) and an
27
+ // absent schema both fall through to "shape unknown" — callers treat that
28
+ // as "capability absent" and skip the check rather than throwing, the same
29
+ // policy projection-list-screens.ts already uses for input-schema checks.
30
+ export function getZodObjectShape(
31
+ schema: ZodType | undefined,
32
+ ): Record<string, ZodType> | undefined {
33
+ if (schema === undefined) return undefined;
34
+ const unwrapped = unwrapZodType(schema);
35
+ return unwrapped instanceof ZodObject ? unwrapped.shape : undefined;
36
+ }
37
+
38
+ // The shape of one row for a query whose result follows the paged-list
39
+ // contract `{ rows: T[], nextCursor, total? }` (projectionList/relatedList/
40
+ // dashboard-list `query`) — unwraps `rows` (a ZodArray) to its element
41
+ // schema, then that element's own shape.
42
+ export function getZodRowShape(schema: ZodType | undefined): Record<string, ZodType> | undefined {
43
+ const objectShape = getZodObjectShape(schema);
44
+ const rowsField = objectShape?.["rows"];
45
+ if (rowsField === undefined) return undefined;
46
+ const rowsArray = unwrapZodType(rowsField);
47
+ // @cast-boundary schema-walk — same core-vs-wrapper gap as .unwrap() above.
48
+ return rowsArray instanceof ZodArray
49
+ ? getZodObjectShape(rowsArray.element as ZodType)
50
+ : undefined;
51
+ }
@@ -397,17 +397,21 @@ function projectDerivedField(derivedDef: DerivedFieldDef): ClientDerivedFieldDef
397
397
  return { valueType: derivedDef.valueType };
398
398
  }
399
399
 
400
- // Whitelist pro Field. `default` darf nur durch wenn Literal (string/
401
- // number/boolean/null) — auch wenn die FieldDefinition-Types „default"
402
- // nur als Literal typisieren, hat das Sample-Pattern
403
- // `as unknown as EntityDefinition` Authorinnen schon Function-Defaults
404
- // reinschmuggeln lassen. Diese Defense-in-Depth fängt sie ab BEVOR
405
- // JSON.stringify sie in der Browser-Injection-Pipeline droppt.
400
+ // Per-field whitelist. Every forwarded value must be JSON-safe (literal, or
401
+ // an array/plain-object built only from JSON-safe values) — even though the
402
+ // FieldDefinition types only type `default` as a literal, the sample-authoring
403
+ // pattern `as unknown as EntityDefinition` has already let function-valued
404
+ // defaults slip through. This whitelist is defense-in-depth that catches them
405
+ // BEFORE JSON.stringify silently drops them in the browser-injection pipeline.
406
+ // The same check gates the structured properties below (`multiline`,
407
+ // `derived`, `totals`, `totalsMatch`) — a top-level `Array.isArray`/plain-
408
+ // object check alone would let a function survive one level deep.
406
409
  //
407
- // Cast am Exit `as FieldDefinition`: type-system-wise erfüllt unsere
408
- // Out-Map die Discriminated-Union nur mit unverengtem `type`-String
409
- // der Cast bridged die Variant-Inferenz, die TS aus einem Generic
410
- // Record nicht zurückrechnet.
410
+ // Cast at the exit `as FieldDefinition`: type-system-wise our out-map only
411
+ // satisfies the discriminated union with an unnarrowed `type` string the
412
+ // cast bridges the variant inference TS can't recompute from a generic
413
+ // Record.
414
+ // kumiko-lint-ignore complexity-budget flat per-property whitelist walk, one independent `if` per FieldDefinition property — same shape as schema-builder.ts's field→zod switch
411
415
  function projectField(fieldDef: FieldDefinition): FieldDefinition {
412
416
  const def = fieldDef as Record<string, unknown>; // @cast-boundary schema-walk
413
417
  const out: Record<string, unknown> = {};
@@ -418,7 +422,7 @@ function projectField(fieldDef: FieldDefinition): FieldDefinition {
418
422
  // boolean) — muss daher ins Client-Schema.
419
423
  if (typeof def["filterable"] === "boolean") out["filterable"] = def["filterable"];
420
424
  if (typeof def["searchable"] === "boolean") out["searchable"] = def["searchable"];
421
- if (isLiteral(def["default"])) out["default"] = def["default"];
425
+ if (isJsonSafeValue(def["default"])) out["default"] = def["default"];
422
426
  // Select: options-Liste ist plain JSON, durchschicken.
423
427
  if (Array.isArray(def["options"])) out["options"] = def["options"];
424
428
  // Reference: entity-Target + labelField + multiple müssen zum Renderer.
@@ -434,11 +438,44 @@ function projectField(fieldDef: FieldDefinition): FieldDefinition {
434
438
  if (typeof def["display"] === "string") out["display"] = def["display"];
435
439
  if (typeof def["columns"] === "number") out["columns"] = def["columns"];
436
440
  if (typeof def["maxRows"] === "number") out["maxRows"] = def["maxRows"];
441
+ // text/longText: textarea row count — DefaultInput renders a single-line
442
+ // input otherwise (fw#2497).
443
+ if (
444
+ typeof def["multiline"] === "boolean" ||
445
+ (isPlainObject(def["multiline"]) && isJsonSafeValue(def["multiline"]))
446
+ )
447
+ out["multiline"] = def["multiline"];
448
+ // number: Zod write-boundary bounds. date/timestamp/locatedTimestamp:
449
+ // ISO-string picker bounds — same keys, different literal type (fw#2497).
450
+ if (typeof def["min"] === "number" || typeof def["min"] === "string") out["min"] = def["min"];
451
+ if (typeof def["max"] === "number" || typeof def["max"] === "string") out["max"] = def["max"];
452
+ // date/timestamp/locatedTimestamp: display/parsing locale override (fw#2497).
453
+ if (typeof def["locale"] === "string") out["locale"] = def["locale"];
454
+ // image: which camera a mobile capture opens (fw#2497).
455
+ if (typeof def["capture"] === "string") out["capture"] = def["capture"];
456
+ // embedded lists: row-count bounds, computed cells, totals row, and the
457
+ // sibling-money-field totals check (fw#2497).
458
+ if (typeof def["minItems"] === "number") out["minItems"] = def["minItems"];
459
+ if (typeof def["maxItems"] === "number") out["maxItems"] = def["maxItems"];
460
+ if (isPlainObject(def["derived"]) && isJsonSafeValue(def["derived"]))
461
+ out["derived"] = def["derived"];
462
+ if (Array.isArray(def["totals"]) && isJsonSafeValue(def["totals"])) out["totals"] = def["totals"];
463
+ if (isPlainObject(def["totalsMatch"]) && isJsonSafeValue(def["totalsMatch"]))
464
+ out["totalsMatch"] = def["totalsMatch"];
437
465
  return out as FieldDefinition; // @cast-boundary schema-walk
438
466
  }
439
467
 
440
- function isLiteral(value: unknown): boolean {
468
+ function isPlainObject(value: unknown): value is Record<string, unknown> {
469
+ if (typeof value !== "object" || value === null || Array.isArray(value)) return false;
470
+ const proto = Object.getPrototypeOf(value);
471
+ return proto === Object.prototype || proto === null;
472
+ }
473
+
474
+ function isJsonSafeValue(value: unknown): boolean {
441
475
  if (value === null) return true;
442
476
  const t = typeof value;
443
- return t === "string" || t === "number" || t === "boolean";
477
+ if (t === "string" || t === "number" || t === "boolean") return true;
478
+ if (Array.isArray(value)) return value.every(isJsonSafeValue);
479
+ if (isPlainObject(value)) return Object.values(value).every(isJsonSafeValue);
480
+ return false;
444
481
  }
@@ -144,7 +144,7 @@ export function buildEntityHandlerMethods<TName extends string>(
144
144
  nameOrDef: string | QueryHandlerDefinition<TName, TSchema>,
145
145
  schema?: TSchema,
146
146
  handler?: QueryHandlerFn<z.infer<TSchema>>,
147
- options?: { access?: AccessRule; rateLimit?: RateLimitOption },
147
+ options?: { access?: AccessRule; rateLimit?: RateLimitOption; outputSchema?: ZodType },
148
148
  ): HandlerRef {
149
149
  if (typeof nameOrDef === "object") {
150
150
  const def = nameOrDef;
@@ -155,6 +155,7 @@ export function buildEntityHandlerMethods<TName extends string>(
155
155
  handler: def.handler as QueryHandlerFn, // @cast-boundary engine-bridge
156
156
  ...(def.access && { access: def.access }),
157
157
  ...(def.rateLimit && { rateLimit: def.rateLimit }),
158
+ ...(def.outputSchema && { outputSchema: def.outputSchema }),
158
159
  // Carry the definePagedQueryHandler brand through — this rebuild
159
160
  // drops any field not explicitly listed.
160
161
  ...(isPagedQueryHandler(def) && { [PAGED_QUERY_HANDLER_BRAND]: true }),
@@ -170,6 +171,7 @@ export function buildEntityHandlerMethods<TName extends string>(
170
171
  handler: handler as QueryHandlerFn, // @cast-boundary engine-bridge
171
172
  ...(options?.access && { access: options.access }),
172
173
  ...(options?.rateLimit && { rateLimit: options.rateLimit }),
174
+ ...(options?.outputSchema && { outputSchema: options.outputSchema }),
173
175
  };
174
176
  tryMapEntity(state, name, nameOrDef);
175
177
  return { name: nameOrDef };