@cosmicdrift/kumiko-framework 0.225.0 → 0.227.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/package.json +3 -3
- package/src/bun-db/query.ts +37 -1
- package/src/db/__tests__/migrate-generator.test.ts +173 -1
- package/src/db/event-store-executor-read.ts +115 -54
- package/src/db/migrate-generator.ts +111 -8
- package/src/db/render-ddl.ts +1 -1
- package/src/engine/__tests__/boot-validator-query-output-schema.test.ts +435 -0
- package/src/engine/__tests__/build-app-schema.test.ts +215 -22
- package/src/engine/__tests__/multiselect-filter.integration.test.ts +141 -0
- package/src/engine/__tests__/screen.test.ts +68 -2
- package/src/engine/boot-validator/index.ts +4 -0
- package/src/engine/boot-validator/projection-list-screens.ts +2 -2
- package/src/engine/boot-validator/query-output-columns.ts +236 -0
- package/src/engine/boot-validator/screens.ts +31 -5
- package/src/engine/boot-validator/zod-shape.ts +51 -0
- package/src/engine/build-app-schema.ts +50 -13
- package/src/engine/feature-entity-handlers.ts +3 -1
- package/src/i18n/required-surface-keys.ts +4 -1
|
@@ -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
|
+
}
|
|
@@ -663,11 +663,37 @@ export function validateScreens(
|
|
|
663
663
|
}
|
|
664
664
|
for (const section of screen.layout.sections) {
|
|
665
665
|
if (isExtensionEditSection(section)) {
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
)
|
|
666
|
+
// projectionDetail is read-only (no composed form submit) — an
|
|
667
|
+
// extension that persists through the host's Save button has
|
|
668
|
+
// nothing to save against. Extensions that persist themselves
|
|
669
|
+
// (e.g. notes-history NotesSection, via their own dispatcher
|
|
670
|
+
// writes) are fine (solon#264).
|
|
671
|
+
if (section.contributesToFormSubmit === true) {
|
|
672
|
+
throw new Error(
|
|
673
|
+
`[Feature ${feature.name}] Screen "${screenId}" (projectionDetail) extension section ` +
|
|
674
|
+
`"${section.title}" sets contributesToFormSubmit: true, but projectionDetail has no ` +
|
|
675
|
+
`form submit to contribute to (read-only screen). Omit contributesToFormSubmit and ` +
|
|
676
|
+
`persist through the extension's own dispatcher writes instead.`,
|
|
677
|
+
);
|
|
678
|
+
}
|
|
679
|
+
if (section.component?.react === undefined && section.component?.native === undefined) {
|
|
680
|
+
throw new Error(
|
|
681
|
+
`[Feature ${feature.name}] Screen "${screenId}" (projectionDetail) extension section ` +
|
|
682
|
+
`"${section.title}" has no component — declare a react/native component marker.`,
|
|
683
|
+
);
|
|
684
|
+
}
|
|
685
|
+
// The host-derived entity name on projectionDetail is an internal
|
|
686
|
+
// placeholder (no real entity backs this screen) — a self-
|
|
687
|
+
// persisting extension needs the real domain entity name declared
|
|
688
|
+
// explicitly, or it silently reads/writes against the wrong type.
|
|
689
|
+
if (section.entityName === undefined || section.entityName.trim().length === 0) {
|
|
690
|
+
throw new Error(
|
|
691
|
+
`[Feature ${feature.name}] Screen "${screenId}" (projectionDetail) extension section ` +
|
|
692
|
+
`"${section.title}" has no entityName — projectionDetail has no real entity, so the ` +
|
|
693
|
+
`extension must declare entityName explicitly (the domain entity it persists against).`,
|
|
694
|
+
);
|
|
695
|
+
}
|
|
696
|
+
continue;
|
|
671
697
|
}
|
|
672
698
|
if (section.kind === "relatedList") {
|
|
673
699
|
if (!section.query || typeof section.query !== "string") {
|
|
@@ -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
|
-
//
|
|
401
|
-
//
|
|
402
|
-
//
|
|
403
|
-
// `as unknown as EntityDefinition`
|
|
404
|
-
//
|
|
405
|
-
// JSON.stringify
|
|
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
|
|
408
|
-
//
|
|
409
|
-
//
|
|
410
|
-
// Record
|
|
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 (
|
|
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
|
|
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
|
-
|
|
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 };
|
|
@@ -226,7 +226,10 @@ export function requiredKeysFromScreen(
|
|
|
226
226
|
case "projectionDetail": {
|
|
227
227
|
const detail = screen as ProjectionDetailScreenDefinition;
|
|
228
228
|
for (const section of detail.layout.sections) {
|
|
229
|
-
if (isExtensionEditSection(section))
|
|
229
|
+
if (isExtensionEditSection(section)) {
|
|
230
|
+
pushKey(out, section.title);
|
|
231
|
+
continue;
|
|
232
|
+
}
|
|
230
233
|
if (section.kind === "relatedList") {
|
|
231
234
|
pushKey(out, section.title);
|
|
232
235
|
for (const col of section.columns) {
|