// Erzeugt aus einer Server-Registry das client-safe AppSchema das die // Browser-Renderer-Pipeline konsumiert. Genauer Zweck: dev-server kann // die ganze hand-geschriebene `clientSchema`-Spiegelung abschaffen, der // Server schickt einfach das aufgelöste AppSchema beim Boot mit. // // JSON-Safety: Wir projezieren explizit auf eine Whitelist statt // JSON.stringify-roundtripping. Functions würden silent gedroppt und // Zod-Schemas (v4 hat .toJSON, aber emittiert ein _zod-Envelope) würden // als komische Ghost-Properties auftauchen. Das hier ist ein bewusster // Vertrag: was die Browser-Renderer-Pipeline liest, taucht hier auf. // Neue Browser-needed-Fields müssen explizit erweitert werden. // // Aktuell projeziert (Stand 2026-04-25): // Entity: { table?, fields: { type, required?, sortable?, default? } } // Screen: verbatim (ScreenDefinition ist von Haus aus JSON-safe — // custom-screens haben keine functions, layout/columns/etc. // sind plain literals) // Nav: verbatim (NavDefinition ist nur strings + literals) // Workspace: verbatim definition + getWorkspaceNavs() von der Registry // Translations: verbatim feature.translations (raw r.translations keys, // unfiltered — see FeatureSchema.translations doc) // // Feature-Toggles: BISHER NICHT GEFILTERT. Wenn ein Feature über die // feature-toggles-bundled-feature global deaktiviert ist, erscheint es // trotzdem im AppSchema. Reason: die Toggle-Auflösung lebt im pipeline- // dispatcher, nicht in der Registry, und wir haben hier keinen TenantDb- // Kontext um sie zu lesen. TODO wenn das ein realer Use-Case wird: // `effectiveFeatures` Argument annehmen und über alle iterations filtern. import { ZodObject, type ZodType } from "zod"; import type { AppSchema, EntityDefinition, FeatureSchema, ProjectionListScreenDefinition, ScreenDefinition, WorkspaceSchema, } from "../ui-types"; import { buildConfigFeatureSchema, type ConfigFeatureSchema, SETTINGS_HUB_FEATURE, } from "./build-config-feature-schema"; import type { Registry } from "./types/feature"; import type { ClientDerivedFieldDef, DerivedFieldDef, FieldDefinition } from "./types/fields"; export type BuildAppSchemaOptions = { /** Dev-server authoring hints (Settings-Hub placement). Default off — only * `createKumikoServer` opts in; prod boot + unit tests stay silent. */ readonly authoringWarnings?: boolean; /** Forwarded onto every FeatureSchema.searchAdapterMissing. Set by the boot * entrypoint (createKumikoServer, runProdApp) from its own * context.searchAdapter presence check — buildAppSchema itself has no * context, only the registry. Omit/false when a SearchAdapter is wired. */ readonly searchAdapterMissing?: boolean; }; export function buildAppSchema(registry: Registry, options: BuildAppSchemaOptions = {}): AppSchema { const features: FeatureSchema[] = []; for (const [featureName, feature] of registry.features) { const navs = Object.values(feature.navs); // The nav entry alone doesn't say which kind a collection lists, so the // client can't derive its tree provider from `navs` — project the // collections separately, with the nav QN already qualified. const contentCollections = Object.values(feature.contentCollections ?? {}).map( (collection) => ({ ...collection, navQn: `${featureName}:nav:${collection.id}`, }), ); const featureSchema: FeatureSchema = { featureName, entities: projectEntities(feature.entities ?? {}), screens: projectScreens(feature.screens, registry), ...(navs.length > 0 && { navs }), ...(contentCollections.length > 0 && { contentCollections }), // #1059: verbatim r.translations({keys}) — see FeatureSchema.translations // doc for why this must NOT go through registry.getAllTranslations() // (double-prefixes features that already qualify their own keys). ...(Object.keys(feature.translations ?? {}).length > 0 && { translations: feature.translations, }), ...(options.searchAdapterMissing === true && { searchAdapterMissing: true }), }; features.push(featureSchema); } // Workspaces: getAllWorkspaces() liefert mit QUALIFIZIERTEN ids (das // schreibt die Registry beim Store-Overwrite ein). Die Browser- // Renderer erwartet aber kurze ids (matcht gegen URL-Segment, gegen // navigate({ workspaceId })). Wir gehen direkt durch `feature.workspaces` // — dort sind die ids noch in der Autor-Form (short) — und ziehen die // pre-resolved navMembers aus der Registry. let workspaces: WorkspaceSchema[] = []; for (const [featureName, feature] of registry.features) { for (const [shortId, definition] of Object.entries(feature.workspaces)) { const qualified = `${featureName}:workspace:${shortId}`; workspaces.push({ definition: { ...definition, id: shortId }, navMembers: registry.getWorkspaceNavs(qualified), }); } } // Self-Populating Settings-Hub: aus den deklarierten Config-Keys mit `mask` // werden Screens/Navs (+ eine Workspace) abgeleitet und hier eingehängt — // kein manuelles r.screen/r.nav am App-Author. const appHadWorkspaces = workspaces.length > 0; const generated = buildConfigFeatureSchema(registry); if (generated.screens.length > 0) { mergeSettingsHubIntoConfigFeature(features, generated); // Flip-Schutz: nur für Apps die schon Workspaces nutzen. Bei einer // workspace-losen App bleibt app.workspaces undefined → der Renderer zeigt // alle Navs ungefiltert, die Hub-Navs inklusive. if (appHadWorkspaces) { const placed = placeSettingsHub(workspaces, generated); workspaces = placed.workspaces; if (placed.standalone !== undefined) workspaces.push(placed.standalone); if (options.authoringWarnings === true) { warnUnplacedAudiences(placed.unplaced); warnDanglingAudienceRefs(placed.danglingRefs); } } } const schema = { features, ...(workspaces.length > 0 && { workspaces }), }; if (typeof process !== "undefined" && process.env.NODE_ENV !== "production") { // A stringify-roundtrip comparison can never fire here: JSON.stringify // drops functions/undefined identically on both sides. Walk the value // instead so a leaked function renderer is actually caught. const offender = findNonJsonSafePath(schema, "schema"); if (offender !== null) { // biome-ignore lint/suspicious/noConsole: dev-only assertion console.error( `[kumiko] buildAppSchema: Output ist nicht JSON-safe — nicht-serialisierbarer Wert bei "${offender}" (Funktions-Renderer oder Klassen-Instanz im Schema?).`, schema, ); } } return schema; } // Hängt die generierten Hub-Screens/Navs an die config-FeatureSchema (qualified // dann als config:screen:* / config:nav:*). Existiert sie noch nicht (config // bundled-feature nicht gemountet), wird sie angelegt. find-or-create statt // fixem Push verhindert eine zweite FeatureSchema mit demselben featureName. function mergeSettingsHubIntoConfigFeature( features: FeatureSchema[], generated: ConfigFeatureSchema, ): void { const existing = features.find((f) => f.featureName === SETTINGS_HUB_FEATURE); if (existing === undefined) { features.push({ featureName: SETTINGS_HUB_FEATURE, entities: {}, screens: generated.screens, navs: generated.navs, }); } else { features[features.indexOf(existing)] = { ...existing, screens: [...existing.screens, ...generated.screens], navs: [...(existing.navs ?? []), ...generated.navs], }; } } // Audience children referenced via navMembers get attached inline (slice-filter would otherwise hide non-member children); the standalone switcher keeps only unplaced audiences so nothing duplicates or silently vanishes. function placeSettingsHub( appWorkspaces: readonly WorkspaceSchema[], generated: ConfigFeatureSchema, ): { workspaces: WorkspaceSchema[]; standalone: WorkspaceSchema | undefined; unplaced: string[]; danglingRefs: string[]; } { const prefix = `${SETTINGS_HUB_FEATURE}:nav:`; const audienceShortIds = new Set(); const childParent = new Map(); const childrenByAudience = new Map(); for (const nav of generated.navs) { if (nav.parent === undefined) { audienceShortIds.add(nav.id); } else { childParent.set(nav.id, nav.parent); const list = childrenByAudience.get(nav.parent) ?? []; list.push(nav.id); childrenByAudience.set(nav.parent, list); } } const placedAudiences = new Set(); // Config-hub navs a workspace references but that were never generated (no // audience and no known child) — e.g. `config:nav:audience-user` without any // registered user-scope config keys. Otherwise the reference vanishes // silently (silent-skip). const danglingRefs = new Set(); const workspaces = appWorkspaces.map((ws) => { const additions: string[] = []; for (const member of ws.navMembers) { if (!member.startsWith(prefix)) continue; const shortId = member.slice(prefix.length); if (!audienceShortIds.has(shortId)) { if (!childParent.has(shortId)) danglingRefs.add(shortId); continue; } placedAudiences.add(shortId); for (const child of childrenByAudience.get(shortId) ?? []) { const childQn = `${prefix}${child}`; if (!ws.navMembers.includes(childQn) && !additions.includes(childQn)) { additions.push(childQn); } } } return additions.length > 0 ? { ...ws, navMembers: [...ws.navMembers, ...additions] } : ws; }); const audienceOf = (shortId: string): string => audienceShortIds.has(shortId) ? shortId : (childParent.get(shortId) ?? shortId); let standalone: WorkspaceSchema | undefined; if (generated.workspace !== undefined && placedAudiences.size < audienceShortIds.size) { const remaining = generated.workspace.navMembers.filter((member) => { const shortId = member.startsWith(prefix) ? member.slice(prefix.length) : member; return !placedAudiences.has(audienceOf(shortId)); }); if (remaining.length > 0) standalone = { ...generated.workspace, navMembers: remaining }; } const unplaced = placedAudiences.size > 0 ? [...audienceShortIds].filter((id) => !placedAudiences.has(id)) : []; return { workspaces, standalone, unplaced, danglingRefs: [...danglingRefs] }; } function warnUnplacedAudiences(unplaced: readonly string[]): void { // skip: every audience placed — nothing to warn about if (unplaced.length === 0) return; // biome-ignore lint/suspicious/noConsole: dev-only authoring hint console.warn( `[kumiko] Settings-Hub: ${unplaced.join(", ")} nicht in einer App-Workspace platziert — ` + `erscheint im Standalone-"Einstellungen"-Tab. Referenziere ` + `${unplaced.map((id) => `${SETTINGS_HUB_FEATURE}:nav:${id}`).join(", ")} ` + `in einer r.workspace.nav, um die Gruppe inline zu zeigen.`, ); } function warnDanglingAudienceRefs(dangling: readonly string[]): void { // skip: no dangling refs — nothing to warn about if (dangling.length === 0) return; // biome-ignore lint/suspicious/noConsole: dev-only authoring hint console.warn( `[kumiko] Settings-Hub: ${dangling .map((id) => `${SETTINGS_HUB_FEATURE}:nav:${id}`) .join(", ")} in einer Workspace referenziert, aber nie generiert — ` + `keine Config-Keys für diesen Scope registriert. Tippfehler oder ` + `vorzeitige Referenz? Der Eintrag rendert sonst unsichtbar.`, ); } // PlatformComponent slots ({ react, native }) legitimately hold component // functions — JSON.stringify drops them at inject-time and the client // re-resolves by name, so the walker treats them as opaque. function isPlatformComponentShape(value: object): boolean { const keys = Object.keys(value); return keys.length > 0 && keys.every((k) => k === "react" || k === "native"); } // Returns the path of the first value JSON.stringify would drop or distort // (function, undefined, symbol, bigint, non-finite number, class instance) — // or null when the value is JSON-safe apart from PlatformComponent slots. export function findNonJsonSafePath(value: unknown, path: string): string | null { if (value === null || typeof value === "string" || typeof value === "boolean") return null; if (typeof value === "number") return Number.isFinite(value) ? null : path; if (Array.isArray(value)) { for (let i = 0; i < value.length; i++) { const hit = findNonJsonSafePath(value[i], `${path}[${i}]`); if (hit !== null) return hit; } return null; } if (typeof value === "object") { const proto = Object.getPrototypeOf(value); if (proto !== Object.prototype && proto !== null) return path; if (isPlatformComponentShape(value)) return null; for (const [key, entry] of Object.entries(value)) { const hit = findNonJsonSafePath(entry, `${path}.${key}`); if (hit !== null) return hit; } return null; } // function, symbol, bigint, undefined return path; } // projectionList screens don't declare searchable/sortable/paginated as // authoring intent — they're derived here from the bound query handler's // Zod schema, the source of truth for what parameters it actually accepts // (fw#2165). A hand-written `searchable: true` still wins over the derived // default (screen.searchable ?? derived); the boot-validator (3a) rejects // one that contradicts the schema. function projectScreens( screens: Readonly>, registry: Registry, ): ScreenDefinition[] { return Object.values(screens).map((screen) => screen.type === "projectionList" ? projectProjectionListScreen(screen, registry) : screen, ); } function projectProjectionListScreen( screen: ProjectionListScreenDefinition, registry: Registry, ): ProjectionListScreenDefinition { const schema = registry.getQueryHandler(screen.query)?.schema; const capabilities = deriveProjectionListCapabilities(schema); return { ...screen, searchable: screen.searchable ?? capabilities.searchable, sortable: capabilities.sortable, paginated: capabilities.paginated, }; } // Zod v4: a ZodObject's param names live on `.shape`. A schema that isn't a // ZodObject (e.g. a z.union across payload shapes) yields no capability // instead of throwing — same as a missing/unresolved query handler. function deriveProjectionListCapabilities(schema: ZodType | undefined): { searchable: boolean; sortable: boolean; paginated: boolean; } { const shape = schema instanceof ZodObject ? schema.shape : undefined; return { searchable: shape !== undefined && "search" in shape, sortable: shape !== undefined && "sort" in shape, paginated: shape !== undefined && ("cursor" in shape || "offset" in shape), }; } function projectEntities( entities: Readonly>, ): Readonly> { const out: Record = {}; for (const [name, entity] of Object.entries(entities)) { out[name] = projectEntity(entity); } return out; } // EntityDefinition ist eine Discriminated-Union an Field-Types — wir // kennen alle JSON-safe Properties pro Field-Type. Statt jede Variante // einzeln auszuhandeln, walken wir die Field-Map und filtern auf die // Whitelist. Was nicht durchkommt: Server-only-runtime wie ZodValidate- // Functions, Computed-Functions, Default-Functions. function projectEntity(entity: EntityDefinition): EntityDefinition { const fieldsOut: Record = {}; for (const [fieldName, fieldDef] of Object.entries(entity.fields)) { fieldsOut[fieldName] = projectField(fieldDef); } // derivedFields MÜSSEN mit ins Client-Schema (nur die Metadaten, nicht die // derive-fn): computeListViewModel löst eine entityList-Spalte über // `entity.derivedFields[field].valueType` auf — fehlt der Eintrag, wirft es // "references unknown field". Der executor hat den Wert server-seitig schon // an die Row gehängt; der Client braucht nur den valueType für den Renderer. const derivedOut: Record = {}; for (const [name, derivedDef] of Object.entries(entity.derivedFields ?? {})) { derivedOut[name] = projectDerivedField(derivedDef); } // EntityDefinition akzeptiert idType/access/searchWeight als optional — // wir lassen die weg weil der Browser-Renderer sie nicht liest. `table` // schicken wir mit, falls Apps `entity.table` direkt referenzieren. // Kein Cast nötig: alle weggelassenen Felder sind `?`-optional. return { fields: fieldsOut, // @cast-boundary schema-walk: EntityDefinition.derivedFields is typed for // the SERVER (derive required); this is the one place a client schema // narrows it to ClientDerivedFieldDef (valueType only) — the cast lives // at the actual server/client type boundary instead of inside // projectDerivedField, which now honestly returns the narrower type. ...(Object.keys(derivedOut).length > 0 && { derivedFields: derivedOut as unknown as Record, }), ...(typeof entity.table === "string" && { table: entity.table }), }; } // Nur valueType durch — die derive-fn ist Server-only und NICHT JSON-safe // (würde sonst die Output-Walk-Guard triggern). Gibt ehrlich // ClientDerivedFieldDef zurück statt eine DerivedFieldDef vorzutäuschen, // die keine derive-fn hat. function projectDerivedField(derivedDef: DerivedFieldDef): ClientDerivedFieldDef { return { valueType: derivedDef.valueType }; } // Whitelist pro Field. `default` darf nur durch wenn Literal (string/ // number/boolean/null) — auch wenn die FieldDefinition-Types „default" // nur als Literal typisieren, hat das Sample-Pattern // `as unknown as EntityDefinition` Authorinnen schon Function-Defaults // reinschmuggeln lassen. Diese Defense-in-Depth fängt sie ab BEVOR // JSON.stringify sie in der Browser-Injection-Pipeline droppt. // // Cast am Exit `as FieldDefinition`: type-system-wise erfüllt unsere // Out-Map die Discriminated-Union nur mit unverengtem `type`-String — // der Cast bridged die Variant-Inferenz, die TS aus einem Generic // Record nicht zurückrechnet. function projectField(fieldDef: FieldDefinition): FieldDefinition { const def = fieldDef as Record; // @cast-boundary schema-walk const out: Record = {}; if (typeof def["type"] === "string") out["type"] = def["type"]; if (typeof def["required"] === "boolean") out["required"] = def["required"]; if (typeof def["sortable"] === "boolean") out["sortable"] = def["sortable"]; // filterable steuert die Faceted-Filter-Dropdowns im Renderer (select/ // boolean) — muss daher ins Client-Schema. if (typeof def["filterable"] === "boolean") out["filterable"] = def["filterable"]; if (typeof def["searchable"] === "boolean") out["searchable"] = def["searchable"]; if (isLiteral(def["default"])) out["default"] = def["default"]; // Select: options-Liste ist plain JSON, durchschicken. if (Array.isArray(def["options"])) out["options"] = def["options"]; // Reference: entity-Target + labelField + multiple müssen zum Renderer. // Der ReferenceInput baut die Options-Query aus refEntity/refFeature und // resolved das Label über labelField — ohne diese Properties fällt das // Dropdown leer aus (QN wird `:query::list` → 404). if (typeof def["entity"] === "string") out["entity"] = def["entity"]; if (typeof def["labelField"] === "string") out["labelField"] = def["labelField"]; if (typeof def["multiple"] === "boolean") out["multiple"] = def["multiple"]; return out as FieldDefinition; // @cast-boundary schema-walk } function isLiteral(value: unknown): boolean { if (value === null) return true; const t = typeof value; return t === "string" || t === "number" || t === "boolean"; }