@cosmicdrift/kumiko-framework 0.34.2 → 0.36.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-framework",
3
- "version": "0.34.2",
3
+ "version": "0.36.0",
4
4
  "description": "Framework core — engine, pipeline, API, DB, and every other bit that makes Kumiko go.",
5
5
  "license": "BUSL-1.1",
6
6
  "author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
@@ -181,7 +181,7 @@
181
181
  "zod": "^4.4.3"
182
182
  },
183
183
  "devDependencies": {
184
- "@cosmicdrift/kumiko-dispatcher-live": "0.21.0",
184
+ "@cosmicdrift/kumiko-dispatcher-live": "0.35.0",
185
185
  "@types/uuid": "^11.0.0",
186
186
  "bun-types": "^1.3.13",
187
187
  "pino-pretty": "^13.1.3"
@@ -151,4 +151,86 @@ describe("buildAppSchema", () => {
151
151
  // Feature-namen identisch nach Roundtrip
152
152
  expect(parsed.features[0].featureName).toBe("ent");
153
153
  });
154
+
155
+ test("FormatSpec-Renderer + FieldCondition-RowActions überleben JSON-Roundtrip unverändert", () => {
156
+ // Pinnt: FormatSpec ({ format: "timestamp" } etc.) ist JSON-sicher
157
+ // und FieldCondition ({ field, eq/ne } | boolean) bleibt nach
158
+ // JSON.parse(JSON.stringify(app)) deep-equal zum Original.
159
+ const entity = {
160
+ table: "events",
161
+ fields: {
162
+ id: { type: "text" },
163
+ startedAt: { type: "timestamp" },
164
+ status: { type: "text" },
165
+ priority: { type: "number" },
166
+ },
167
+ } as unknown as EntityDefinition;
168
+
169
+ const f = defineFeature("ev", (r) => {
170
+ r.entity("event", entity);
171
+ r.screen({
172
+ id: "list",
173
+ type: "entityList",
174
+ entity: "event",
175
+ columns: [
176
+ "id",
177
+ { field: "startedAt", renderer: { format: "timestamp" as const } },
178
+ { field: "priority", renderer: { format: "priority" as const, prefix: "P" } },
179
+ { field: "status" },
180
+ ],
181
+ rowActions: [
182
+ {
183
+ kind: "navigate",
184
+ id: "open",
185
+ label: "Öffnen",
186
+ screen: "detail",
187
+ visible: { field: "status", ne: "archived" },
188
+ },
189
+ {
190
+ kind: "navigate",
191
+ id: "archive",
192
+ label: "Archivieren",
193
+ screen: "archive",
194
+ visible: { field: "status", eq: "open" },
195
+ },
196
+ {
197
+ kind: "navigate",
198
+ id: "always",
199
+ label: "Immer",
200
+ screen: "view",
201
+ visible: true,
202
+ },
203
+ ],
204
+ });
205
+ });
206
+
207
+ const app = buildAppSchema(createRegistry([f]));
208
+ const roundTripped = JSON.parse(JSON.stringify(app));
209
+
210
+ // Vollständige deep-equality — kein Silent-Drop durch JSON.stringify
211
+ expect(roundTripped).toEqual(app);
212
+
213
+ // Explizit: FormatSpec-Felder landen unverändert an
214
+ const screen = roundTripped.features[0]?.screens[0];
215
+ const cols = screen?.columns as Array<{ field?: string; renderer?: unknown }>;
216
+ expect(cols?.find((c) => c.field === "startedAt")?.renderer).toEqual({
217
+ format: "timestamp",
218
+ });
219
+ expect(cols?.find((c) => c.field === "priority")?.renderer).toEqual({
220
+ format: "priority",
221
+ prefix: "P",
222
+ });
223
+
224
+ // Explizit: FieldCondition-Varianten (eq, ne, boolean) landen unverändert an
225
+ const actions = screen?.rowActions as Array<{ id: string; visible?: unknown }>;
226
+ expect(actions?.find((a) => a.id === "open")?.visible).toEqual({
227
+ field: "status",
228
+ ne: "archived",
229
+ });
230
+ expect(actions?.find((a) => a.id === "archive")?.visible).toEqual({
231
+ field: "status",
232
+ eq: "open",
233
+ });
234
+ expect(actions?.find((a) => a.id === "always")?.visible).toBe(true);
235
+ });
154
236
  });
@@ -59,10 +59,31 @@ export function buildAppSchema(registry: Registry): AppSchema {
59
59
  }
60
60
  }
61
61
 
62
- return {
62
+ const schema = {
63
63
  features,
64
64
  ...(workspaces.length > 0 && { workspaces }),
65
65
  };
66
+
67
+ if (typeof process !== "undefined" && process.env.NODE_ENV !== "production") {
68
+ try {
69
+ const roundTripped = JSON.parse(JSON.stringify(schema));
70
+ if (JSON.stringify(roundTripped) !== JSON.stringify(schema)) {
71
+ // biome-ignore lint/suspicious/noConsole: dev-only assertion
72
+ console.error(
73
+ "[kumiko] buildAppSchema: Output ist nicht JSON-safe — ein Funktions-Renderer oder nicht-serialisierbarer Wert ist in das Schema gerutscht. Details im Diff:",
74
+ schema,
75
+ );
76
+ }
77
+ } catch {
78
+ // biome-ignore lint/suspicious/noConsole: dev-only assertion
79
+ console.error(
80
+ "[kumiko] buildAppSchema: JSON.stringify fehlgeschlagen — Schema enthält nicht-serialisierbare Werte.",
81
+ schema,
82
+ );
83
+ }
84
+ }
85
+
86
+ return schema;
66
87
  }
67
88
 
68
89
  function projectEntities(
@@ -213,7 +213,9 @@ export type {
213
213
  EntityEditScreenDefinition,
214
214
  EntityListScreenDefinition,
215
215
  FieldCondition,
216
+ FieldFormatRegistry,
216
217
  FieldRenderer,
218
+ FormatSpec,
217
219
  ListColumnSpec,
218
220
  PlatformComponent,
219
221
  RowAction,
@@ -225,7 +227,12 @@ export type {
225
227
  ScreenSlots,
226
228
  ToolbarAction,
227
229
  } from "./screen";
228
- export { isExtensionEditSection, normalizeEditField, normalizeListColumn } from "./screen";
230
+ export {
231
+ isExtensionEditSection,
232
+ isFormatSpec,
233
+ normalizeEditField,
234
+ normalizeListColumn,
235
+ } from "./screen";
229
236
  export type { TargetRef } from "./target-ref";
230
237
  export type {
231
238
  Subscribe,
@@ -24,18 +24,38 @@ export type PlatformComponent = {
24
24
  readonly native?: unknown;
25
25
  };
26
26
 
27
+ // Built-in value formatters. Apps extend via module augmentation:
28
+ // declare module "@cosmicdrift/kumiko-framework" {
29
+ // interface FieldFormatRegistry { myFormat: { myOption?: string } }
30
+ // }
31
+ // renderer-web handles all built-in keys; unknown app-specific keys fall back
32
+ // to String(value).
33
+ export interface FieldFormatRegistry {
34
+ timestamp: {
35
+ readonly locale?: string;
36
+ readonly dateStyle?: "full" | "long" | "medium" | "short";
37
+ readonly timeStyle?: "full" | "long" | "medium" | "short";
38
+ };
39
+ date: {
40
+ readonly locale?: string;
41
+ readonly dateStyle?: "full" | "long" | "medium" | "short";
42
+ };
43
+ boolean: { readonly trueLabel?: string; readonly falseLabel?: string };
44
+ currency: { readonly symbol?: string };
45
+ priority: { readonly emptyLabel?: string; readonly prefix?: string };
46
+ }
47
+
48
+ // Discriminated union derived from the registry — one variant per key.
49
+ // JSON-safe: no function members, survives buildAppSchema → window.__KUMIKO_SCHEMA__.
50
+ export type FormatSpec = {
51
+ [K in keyof FieldFormatRegistry]: { readonly format: K } & FieldFormatRegistry[K];
52
+ }[keyof FieldFormatRegistry];
53
+
27
54
  // Level-2 field renderer (ui-architecture.md §Renderer Customization):
28
55
  // - PlatformComponent → platform-specific component from the same feature
29
- // - string → cross-feature QN reference (resolved by the renderer)
30
- // - function inline value formatter (e.g. `v => `${v} €``)
31
- // Function-Form bekommt optional die ganze Row als 2. Argument —
32
- // nützlich für context-aware Renderer (Tier 2.7e-Eagerload nutzt das
33
- // um aus row._refs den resolved Display-Wert zu lesen). Renderer die
34
- // nur den value brauchen ignorieren das Argument einfach.
35
- export type FieldRenderer =
36
- | PlatformComponent
37
- | string
38
- | ((value: unknown, row?: Readonly<Record<string, unknown>>) => string);
56
+ // - string → cross-feature QN reference (resolved at mount-time)
57
+ // - FormatSpec declarative value formatter, JSON-safe ({ format: "timestamp" } etc.)
58
+ export type FieldRenderer = PlatformComponent | string | FormatSpec;
39
59
 
40
60
  // Declarative field-state condition. Evaluated by the renderer against the
41
61
  // current row/form values. Three forms:
@@ -465,11 +485,35 @@ export type ScreenDefinition =
465
485
  | ConfigEditScreenDefinition
466
486
  | CustomScreenDefinition;
467
487
 
488
+ // Type guard — narrows FieldRenderer to FormatSpec. Useful for renderer
489
+ // authors who branch on the three FieldRenderer variants without manual
490
+ // "format" in renderer checks.
491
+ export function isFormatSpec(r: unknown): r is FormatSpec {
492
+ return (
493
+ typeof r === "object" &&
494
+ r !== null &&
495
+ "format" in r &&
496
+ typeof (r as Record<string, unknown>)["format"] === "string"
497
+ );
498
+ }
499
+
468
500
  // Collapse the string-shorthand into the object form. Both the boot-validator
469
501
  // and (later) ui-core's view-model builder iterate over fields/columns — the
470
502
  // helper keeps that loop from growing two branches everywhere.
471
503
  export function normalizeListColumn(c: ListColumnSpec): Exclude<ListColumnSpec, string> {
472
- return typeof c === "string" ? { field: c } : c;
504
+ const col = typeof c === "string" ? { field: c } : c;
505
+ if (
506
+ typeof process !== "undefined" &&
507
+ process.env.NODE_ENV !== "production" &&
508
+ col.renderer !== undefined &&
509
+ typeof col.renderer === "function"
510
+ ) {
511
+ // biome-ignore lint/suspicious/noConsole: dev-only warning
512
+ console.warn(
513
+ `[kumiko] normalizeListColumn: Feld "${col.field}" hat einen Funktions-Renderer — dieser wird von JSON.stringify verworfen. Bitte auf FormatSpec ({ format: "..." }) migrieren.`,
514
+ );
515
+ }
516
+ return col;
473
517
  }
474
518
 
475
519
  export function normalizeEditField(f: EditFieldSpec): Exclude<EditFieldSpec, string> {
@@ -69,6 +69,7 @@ export type {
69
69
  } from "../engine/types/screen";
70
70
  export {
71
71
  isExtensionEditSection,
72
+ isFormatSpec,
72
73
  normalizeEditField,
73
74
  normalizeListColumn,
74
75
  } from "../engine/types/screen";