@cosmicdrift/kumiko-framework 0.225.0 → 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
+ }
@@ -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 };