@cosmicdrift/kumiko-framework 0.35.0 → 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.35.0",
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(
@@ -227,7 +227,12 @@ export type {
227
227
  ScreenSlots,
228
228
  ToolbarAction,
229
229
  } from "./screen";
230
- export { isExtensionEditSection, normalizeEditField, normalizeListColumn } from "./screen";
230
+ export {
231
+ isExtensionEditSection,
232
+ isFormatSpec,
233
+ normalizeEditField,
234
+ normalizeListColumn,
235
+ } from "./screen";
231
236
  export type { TargetRef } from "./target-ref";
232
237
  export type {
233
238
  Subscribe,
@@ -31,8 +31,15 @@ export type PlatformComponent = {
31
31
  // renderer-web handles all built-in keys; unknown app-specific keys fall back
32
32
  // to String(value).
33
33
  export interface FieldFormatRegistry {
34
- timestamp: Record<never, never>;
35
- date: Record<never, never>;
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
+ };
36
43
  boolean: { readonly trueLabel?: string; readonly falseLabel?: string };
37
44
  currency: { readonly symbol?: string };
38
45
  priority: { readonly emptyLabel?: string; readonly prefix?: string };
@@ -478,11 +485,35 @@ export type ScreenDefinition =
478
485
  | ConfigEditScreenDefinition
479
486
  | CustomScreenDefinition;
480
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
+
481
500
  // Collapse the string-shorthand into the object form. Both the boot-validator
482
501
  // and (later) ui-core's view-model builder iterate over fields/columns — the
483
502
  // helper keeps that loop from growing two branches everywhere.
484
503
  export function normalizeListColumn(c: ListColumnSpec): Exclude<ListColumnSpec, string> {
485
- 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;
486
517
  }
487
518
 
488
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";