@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.
- 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__/boot-validator.test.ts +92 -2
- 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__/projection-detail-tabs.test.ts +158 -0
- 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 +98 -9
- 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
|
@@ -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
|
-
//
|
|
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
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
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
|
-
//
|
|
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 };
|