@cosmicdrift/kumiko-framework 0.133.0 → 0.135.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.
Files changed (37) hide show
  1. package/package.json +2 -2
  2. package/src/engine/__tests__/boot-validator-dashboard.test.ts +136 -2
  3. package/src/engine/__tests__/boot-validator-located-timestamps.test.ts +3 -19
  4. package/src/engine/__tests__/factories-time.test.ts +2 -66
  5. package/src/engine/__tests__/{visual-tree-patterns.test.ts → tree-actions-patterns.test.ts} +4 -84
  6. package/src/engine/boot-validator/entity-handler.ts +5 -4
  7. package/src/engine/boot-validator/screens-nav.ts +112 -16
  8. package/src/engine/define-feature.ts +3 -17
  9. package/src/engine/factories.ts +2 -49
  10. package/src/engine/feature-ast/extractors/index.ts +1 -4
  11. package/src/engine/feature-ast/extractors/round6.ts +2 -36
  12. package/src/engine/feature-ast/parse.ts +1 -4
  13. package/src/engine/feature-ast/patch.ts +2 -5
  14. package/src/engine/feature-ast/patterns.ts +0 -14
  15. package/src/engine/feature-ast/render.ts +1 -9
  16. package/src/engine/index.ts +0 -1
  17. package/src/engine/pattern-library/__tests__/library.test.ts +0 -3
  18. package/src/engine/pattern-library/library.ts +0 -19
  19. package/src/engine/registry.ts +6 -16
  20. package/src/engine/types/feature.ts +3 -33
  21. package/src/engine/types/fields.ts +5 -4
  22. package/src/engine/types/index.ts +5 -0
  23. package/src/engine/types/screen.ts +64 -1
  24. package/src/engine/types/workspace.ts +0 -7
  25. package/src/errors/classes.ts +1 -1
  26. package/src/errors/field-issue.ts +0 -3
  27. package/src/errors/index.ts +0 -1
  28. package/src/i18n/required-surface-keys.ts +29 -10
  29. package/src/observability/__tests__/recording-tracer.test.ts +6 -4
  30. package/src/observability/noop-provider.ts +0 -9
  31. package/src/observability/recording-tracer.ts +0 -12
  32. package/src/observability/types/span.ts +0 -6
  33. package/src/time/tz-context.ts +1 -1
  34. package/src/ui-types/index.ts +5 -0
  35. package/src/bun-db/__tests__/bun-test-stack.ts +0 -6
  36. package/src/db/row-helpers.ts +0 -4
  37. package/src/engine/feature-ast/__tests__/visual-tree-parse.test.ts +0 -184
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-framework",
3
- "version": "0.133.0",
3
+ "version": "0.135.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>",
@@ -189,7 +189,7 @@
189
189
  "zod": "^4.4.3"
190
190
  },
191
191
  "devDependencies": {
192
- "@cosmicdrift/kumiko-dispatcher-live": "0.133.0",
192
+ "@cosmicdrift/kumiko-dispatcher-live": "0.135.0",
193
193
  "bun-types": "^1.3.13",
194
194
  "pino-pretty": "^13.1.3"
195
195
  },
@@ -12,15 +12,25 @@ const STAT_PANEL = {
12
12
  valueField: "count",
13
13
  } as const;
14
14
 
15
- function dashboardFeature(panels: DashboardScreenDefinition["panels"]) {
15
+ function dashboardFeature(
16
+ panels: DashboardScreenDefinition["panels"],
17
+ filter?: DashboardScreenDefinition["filter"],
18
+ ) {
16
19
  return defineFeature("demo", (r) => {
17
- r.screen({ id: "overview", type: "dashboard", panels });
20
+ r.screen({
21
+ id: "overview",
22
+ type: "dashboard",
23
+ panels,
24
+ ...(filter !== undefined && { filter }),
25
+ });
18
26
  r.translations({
19
27
  keys: {
20
28
  "screen:overview.title": { de: "Übersicht", en: "Overview" },
21
29
  "demo:dashboard:panel:open-incidents": { de: "Offene Vorfälle", en: "Open incidents" },
22
30
  "demo:dashboard:panel:latest": { de: "Neueste", en: "Latest" },
23
31
  "demo:dashboard:col:name": { de: "Name", en: "Name" },
32
+ "demo:dashboard:group:net-worth": { de: "Net Worth", en: "Net Worth" },
33
+ "demo:dashboard:filter:region": { de: "Region", en: "Region" },
24
34
  },
25
35
  });
26
36
  });
@@ -90,4 +100,128 @@ describe("validateBoot — dashboard screens", () => {
90
100
  expect(keys).toContain("demo:dashboard:panel:latest");
91
101
  expect(keys).toContain("demo:dashboard:col:name");
92
102
  });
103
+
104
+ test("accepts a stat-group, feed, progress-list and custom panel", () => {
105
+ const feature = dashboardFeature(
106
+ [
107
+ {
108
+ kind: "stat-group",
109
+ id: "net-worth",
110
+ label: "demo:dashboard:group:net-worth",
111
+ stats: [STAT_PANEL],
112
+ },
113
+ {
114
+ kind: "feed",
115
+ id: "upcoming",
116
+ label: "demo:dashboard:panel:latest",
117
+ query: "demo:query:incident:latest",
118
+ },
119
+ {
120
+ kind: "progress-list",
121
+ id: "progress",
122
+ label: "demo:dashboard:panel:latest",
123
+ query: "demo:query:incident:latest",
124
+ },
125
+ {
126
+ kind: "custom",
127
+ id: "custom-panel",
128
+ component: { react: { __component: "demo-custom" } },
129
+ },
130
+ ],
131
+ {
132
+ id: "region",
133
+ label: "demo:dashboard:filter:region",
134
+ kind: "select",
135
+ options: [{ value: "eu", label: "demo:dashboard:filter:region" }],
136
+ },
137
+ );
138
+ expect(() => validateBoot([feature])).not.toThrow();
139
+ });
140
+
141
+ test("rejects a stat-group with an empty stats list", () => {
142
+ const feature = dashboardFeature([
143
+ { kind: "stat-group", id: "net-worth", label: "demo:dashboard:group:net-worth", stats: [] },
144
+ ]);
145
+ expect(() => validateBoot([feature])).toThrow(/empty stats list/);
146
+ });
147
+
148
+ test("rejects a duplicate id nested inside a stat-group", () => {
149
+ const feature = dashboardFeature([
150
+ STAT_PANEL,
151
+ {
152
+ kind: "stat-group",
153
+ id: "net-worth",
154
+ label: "demo:dashboard:group:net-worth",
155
+ stats: [STAT_PANEL],
156
+ },
157
+ ]);
158
+ expect(() => validateBoot([feature])).toThrow(/duplicate panel id/);
159
+ });
160
+
161
+ test("rejects a custom panel without a react/native component", () => {
162
+ const feature = dashboardFeature([{ kind: "custom", id: "custom-panel", component: {} }]);
163
+ expect(() => validateBoot([feature])).toThrow(/has no component/);
164
+ });
165
+
166
+ test("rejects a filter that sets neither options nor optionsQuery", () => {
167
+ const feature = dashboardFeature([STAT_PANEL], {
168
+ id: "region",
169
+ label: "demo:dashboard:filter:region",
170
+ kind: "select",
171
+ });
172
+ expect(() => validateBoot([feature])).toThrow(/exactly one of/);
173
+ });
174
+
175
+ test("rejects a filter that sets both options and optionsQuery", () => {
176
+ const feature = dashboardFeature([STAT_PANEL], {
177
+ id: "region",
178
+ label: "demo:dashboard:filter:region",
179
+ kind: "select",
180
+ options: [{ value: "eu", label: "demo:dashboard:filter:region" }],
181
+ optionsQuery: "demo:query:folder:list",
182
+ });
183
+ expect(() => validateBoot([feature])).toThrow(/exactly one of/);
184
+ });
185
+
186
+ test("rejects a filter with an empty options list", () => {
187
+ const feature = dashboardFeature([STAT_PANEL], {
188
+ id: "region",
189
+ label: "demo:dashboard:filter:region",
190
+ kind: "select",
191
+ options: [],
192
+ });
193
+ expect(() => validateBoot([feature])).toThrow(/filter.options is empty/);
194
+ });
195
+
196
+ test("requiredKeysFromScreen sammelt stat-group-Kinder- und Filter-Labels, aber keine custom-Panel-Keys", () => {
197
+ const screen: DashboardScreenDefinition = {
198
+ id: "overview",
199
+ type: "dashboard",
200
+ filter: {
201
+ id: "region",
202
+ label: "demo:dashboard:filter:region",
203
+ kind: "select",
204
+ options: [{ value: "eu", label: "demo:dashboard:col:name" }],
205
+ },
206
+ panels: [
207
+ {
208
+ kind: "stat-group",
209
+ id: "net-worth",
210
+ label: "demo:dashboard:group:net-worth",
211
+ stats: [STAT_PANEL],
212
+ },
213
+ {
214
+ kind: "custom",
215
+ id: "custom-panel",
216
+ component: { react: { __component: "demo-custom" } },
217
+ },
218
+ ],
219
+ };
220
+ const keys = requiredKeysFromScreen("demo", screen);
221
+ expect(keys).toContain("demo:dashboard:group:net-worth");
222
+ expect(keys).toContain("demo:dashboard:panel:open-incidents");
223
+ expect(keys).toContain("demo:dashboard:filter:region");
224
+ expect(keys).toContain("demo:dashboard:col:name");
225
+ expect(keys).not.toContain("custom-panel");
226
+ });
93
227
  });
@@ -7,25 +7,9 @@
7
7
  import { describe, expect, test } from "bun:test";
8
8
  import { validateBoot } from "../boot-validator";
9
9
  import { defineFeature } from "../define-feature";
10
- import { createEntity, createTimestampField, createTzField, locatedTimestamp } from "../factories";
10
+ import { createEntity, createTimestampField, createTzField } from "../factories";
11
11
 
12
12
  describe("validateBoot — locatedBy markers", () => {
13
- test("locatedTimestamp(name) Helper-Pair passiert validiert (positive case)", () => {
14
- const feature = defineFeature("test", (r) => {
15
- r.entity(
16
- "order",
17
- createEntity({
18
- fields: {
19
- ...locatedTimestamp("pickup"),
20
- ...locatedTimestamp("delivery"),
21
- },
22
- }),
23
- );
24
- });
25
-
26
- expect(() => validateBoot([feature])).not.toThrow();
27
- });
28
-
29
13
  test("manuelle Konstruktion mit korrektem Pair passiert (positive case)", () => {
30
14
  const feature = defineFeature("test", (r) => {
31
15
  r.entity(
@@ -75,7 +59,7 @@ describe("validateBoot — locatedBy markers", () => {
75
59
  expect(() => validateBoot([feature])).toThrow(/expected "tz"/);
76
60
  });
77
61
 
78
- test("Fehlermeldung verweist auf locatedTimestamp-Helper als Fix", () => {
62
+ test("Fehlermeldung verweist auf createLocatedTimestampField als Fix", () => {
79
63
  const feature = defineFeature("test", (r) => {
80
64
  r.entity(
81
65
  "order",
@@ -87,7 +71,7 @@ describe("validateBoot — locatedBy markers", () => {
87
71
  );
88
72
  });
89
73
 
90
- expect(() => validateBoot([feature])).toThrow(/locatedTimestamp\("pickup"\)/);
74
+ expect(() => validateBoot([feature])).toThrow(/createLocatedTimestampField/);
91
75
  });
92
76
 
93
77
  test("Timestamp ohne locatedBy ist OK (reiner UTC-Instant)", () => {
@@ -1,17 +1,12 @@
1
1
  // Unit-Tests für die neuen Time-Field-Factories
2
- // (createTimestampField, createTzField, locatedTimestamp).
2
+ // (createTimestampField, createTzField, createLocatedTimestampField).
3
3
  //
4
4
  // Test-Fokus: korrektes Field-Shape + locatedBy-Marker-Verdrahtung. Die
5
5
  // echte TZ-Konvertierung (Wall-Clock ↔ UTC) testen wir später beim
6
6
  // DB-Wrapper-Schritt.
7
7
 
8
8
  import { describe, expect, test } from "bun:test";
9
- import {
10
- createLocatedTimestampField,
11
- createTimestampField,
12
- createTzField,
13
- locatedTimestamp,
14
- } from "../factories";
9
+ import { createLocatedTimestampField, createTimestampField, createTzField } from "../factories";
15
10
 
16
11
  describe("createTimestampField", () => {
17
12
  test("default-Form ist nicht-required UTC-Instant ohne locatedBy", () => {
@@ -57,65 +52,6 @@ describe("createTzField", () => {
57
52
  });
58
53
  });
59
54
 
60
- describe("locatedTimestamp(name) Helper", () => {
61
- test("erzeugt korrektes Pair aus <name>At + <name>Tz mit locatedBy-Verdrahtung", () => {
62
- const fields = locatedTimestamp("pickup");
63
- expect(fields).toEqual({
64
- pickupAt: { type: "timestamp", locatedBy: "pickupTz" },
65
- pickupTz: { type: "tz" },
66
- });
67
- });
68
-
69
- test("required-Override propagiert auf BEIDE Felder", () => {
70
- const fields = locatedTimestamp("delivery", { required: true });
71
- expect(fields).toEqual({
72
- deliveryAt: { type: "timestamp", locatedBy: "deliveryTz", required: true },
73
- deliveryTz: { type: "tz", required: true },
74
- });
75
- });
76
-
77
- test("access-Override propagiert auf BEIDE Felder (Field-Level Read-Access)", () => {
78
- const fields = locatedTimestamp("internal", {
79
- access: { read: ["Dispatcher"] },
80
- });
81
- expect(fields).toEqual({
82
- internalAt: {
83
- type: "timestamp",
84
- locatedBy: "internalTz",
85
- access: { read: ["Dispatcher"] },
86
- },
87
- internalTz: { type: "tz", access: { read: ["Dispatcher"] } },
88
- });
89
- });
90
-
91
- test("locatedBy-Marker zeigt immer auf das EIGENE Tz-Feld (nicht auf einen anderen Namen)", () => {
92
- // Das ist der Kern des Patterns — wenn die zwei Felder nicht
93
- // konsistent verdrahtet sind, fliegt der Boot-Validator (kommt in
94
- // späterer Iteration). Hier prüfen wir die Helper-Garantie.
95
- for (const name of ["a", "x_y", "long_field_name"]) {
96
- const fields = locatedTimestamp(name);
97
- const at = fields[`${name}At`];
98
- if (at?.type !== "timestamp") throw new Error("at field missing");
99
- expect(at.locatedBy).toBe(`${name}Tz`);
100
- }
101
- });
102
-
103
- test("Spread in createEntity-fields Kompositions-tauglich", () => {
104
- // Realer Use-Case: pickup + delivery in einer Entity, plus normale Felder.
105
- const entityFields = {
106
- ...locatedTimestamp("pickup"),
107
- ...locatedTimestamp("delivery"),
108
- // Kein Konflikt zwischen den beiden Pairs.
109
- };
110
- expect(Object.keys(entityFields).sort()).toEqual([
111
- "deliveryAt",
112
- "deliveryTz",
113
- "pickupAt",
114
- "pickupTz",
115
- ]);
116
- });
117
- });
118
-
119
55
  describe("createLocatedTimestampField (Phase A — atomarer Field-Type)", () => {
120
56
  test("default-Form ist nicht-required mit type 'locatedTimestamp'", () => {
121
57
  expect(createLocatedTimestampField()).toEqual({
@@ -1,15 +1,5 @@
1
1
  import { describe, expect, test } from "bun:test";
2
2
  import { buildTarget, createRegistry, defineFeature } from "../index";
3
- import type { TreeChildrenSubscribe, TreeNode } from "../types/tree-node";
4
-
5
- // Stub-Provider für Tests. Form: (ctx) => (emit) => unsubscribe.
6
- // Emittet einmal initial, kein Cleanup nötig (no-op unsubscribe).
7
- function makeStubProvider(nodes: readonly TreeNode[]): TreeChildrenSubscribe {
8
- return () => (emit) => {
9
- emit(nodes);
10
- return () => {};
11
- };
12
- }
13
3
 
14
4
  describe("r.treeActions — registrar slot", () => {
15
5
  test("feature without r.treeActions leaves the slot undefined", () => {
@@ -63,80 +53,12 @@ describe("r.treeActions — registrar slot", () => {
63
53
  });
64
54
  });
65
55
 
66
- describe("r.tree — registrar slot", () => {
67
- test("feature without r.tree leaves the slot undefined", () => {
68
- const feature = defineFeature("empty", () => {});
69
- expect(feature.treeProvider).toBeUndefined();
70
- });
71
-
72
- test("single r.tree call stores the provider function on the FeatureDefinition", () => {
73
- const provider = makeStubProvider([{ label: "Marketing" }]);
74
- const feature = defineFeature("text-content", (r) => {
75
- r.tree(provider);
76
- });
77
- expect(feature.treeProvider).toBe(provider);
78
- });
79
-
80
- test("second r.tree call throws — only-once-guard", () => {
81
- expect(() =>
82
- defineFeature("dupe", (r) => {
83
- r.tree(makeStubProvider([]));
84
- r.tree(makeStubProvider([]));
85
- }),
86
- ).toThrow(/r\.tree\(\) already called/);
87
- });
88
-
89
- test("treeActions and tree are independent slots — can declare one without the other", () => {
90
- const onlyActions = defineFeature("a", (r) => {
91
- r.treeActions({ list: {} });
92
- });
93
- expect(onlyActions.treeActions).toBeDefined();
94
- expect(onlyActions.treeProvider).toBeUndefined();
95
-
96
- const onlyProvider = defineFeature("b", (r) => {
97
- r.tree(makeStubProvider([]));
98
- });
99
- expect(onlyProvider.treeActions).toBeUndefined();
100
- expect(onlyProvider.treeProvider).toBeDefined();
101
- });
102
- });
103
-
104
- describe("Registry.getTreeProviders + getTreeActions", () => {
105
- test("empty registry returns empty providers map and undefined actions", () => {
56
+ describe("Registry.getTreeActions", () => {
57
+ test("empty registry returns undefined actions", () => {
106
58
  const reg = createRegistry([]);
107
- expect(reg.getTreeProviders().size).toBe(0);
108
59
  expect(reg.getTreeActions("nonexistent")).toBeUndefined();
109
60
  });
110
61
 
111
- test("aggregates providers from multiple features keyed by feature name", () => {
112
- const providerA = makeStubProvider([{ label: "A-root" }]);
113
- const providerB = makeStubProvider([{ label: "B-root" }]);
114
- const featureA = defineFeature("text-content", (r) => {
115
- r.tree(providerA);
116
- });
117
- const featureB = defineFeature("legal-pages", (r) => {
118
- r.tree(providerB);
119
- });
120
- const reg = createRegistry([featureA, featureB]);
121
-
122
- const providers = reg.getTreeProviders();
123
- expect(providers.size).toBe(2);
124
- expect(providers.get("text-content")).toBe(providerA);
125
- expect(providers.get("legal-pages")).toBe(providerB);
126
- });
127
-
128
- test("features without r.tree are absent from the providers map (Zero-Whitelist-Filter)", () => {
129
- const featureWithProvider = defineFeature("text-content", (r) => {
130
- r.tree(makeStubProvider([]));
131
- });
132
- const featureWithoutProvider = defineFeature("schema-editor", () => {});
133
- const reg = createRegistry([featureWithProvider, featureWithoutProvider]);
134
-
135
- const providers = reg.getTreeProviders();
136
- expect(providers.has("text-content")).toBe(true);
137
- expect(providers.has("schema-editor")).toBe(false);
138
- });
139
-
140
62
  test("getTreeActions returns the erased map for a feature that declared r.treeActions", () => {
141
63
  const feature = defineFeature("text-content", (r) => {
142
64
  r.treeActions({
@@ -153,9 +75,7 @@ describe("Registry.getTreeProviders + getTreeActions", () => {
153
75
  });
154
76
 
155
77
  test("getTreeActions returns undefined for a feature without r.treeActions", () => {
156
- const feature = defineFeature("no-actions", (r) => {
157
- r.tree(makeStubProvider([]));
158
- });
78
+ const feature = defineFeature("no-actions", () => {});
159
79
  const reg = createRegistry([feature]);
160
80
 
161
81
  expect(reg.getTreeActions("no-actions")).toBeUndefined();
@@ -169,7 +89,7 @@ describe("Registry.getTreeProviders + getTreeActions", () => {
169
89
  // returnt einen typed Handle, der via setup-export durch FeatureDefinition
170
90
  // fließt und compile-time-typisiert vom Schicht-1-buildTarget konsumiert
171
91
  // wird. Ohne diese Tests würde ein Type-Drift in einer der zwei Schichten
172
- // erst in V.1.1 sichtbar — siehe advisor-Verdict + Memory `[EventDef-
92
+ // erst spät sichtbar — siehe advisor-Verdict + Memory `[EventDef-
173
93
  // Exports-Pattern]`.
174
94
  // =============================================================================
175
95
 
@@ -178,8 +178,9 @@ export function validateMultiStreamProjections(feature: FeatureDefinition): void
178
178
  //
179
179
  // Die häufigste Quelle von Konflikten ist Hand-Konstruktion:
180
180
  // { foo: { type: "timestamp", locatedBy: "fooTz" } }
181
- // ohne das `fooTz`-Feld zu deklarieren. Der `locatedTimestamp(name)` Helper
182
- // macht das Pair atomar — wer ihn nutzt, fliegt nicht durch diesen Validator.
181
+ // ohne das `fooTz`-Feld zu deklarieren. `createLocatedTimestampField()`
182
+ // erzeugt stattdessen EIN Feld vom Typ "locatedTimestamp" — wer den nutzt,
183
+ // fliegt nicht durch diesen Validator.
183
184
  export function validateLocatedTimestamps(feature: FeatureDefinition): void {
184
185
  for (const [entityName, entity] of Object.entries(feature.entities ?? {})) {
185
186
  const fields = entity.fields;
@@ -190,8 +191,8 @@ export function validateLocatedTimestamps(feature: FeatureDefinition): void {
190
191
  throw new Error(
191
192
  `Feature "${feature.name}", entity "${entityName}": field "${fieldName}" has ` +
192
193
  `locatedBy: "${field.locatedBy}" but no field with that name exists in the entity. ` +
193
- `Either declare the tz-field, or use the locatedTimestamp("${fieldName.replace(/At$/, "")}") helper ` +
194
- `to create the pair atomically.`,
194
+ `Either declare the tz-field, or use createLocatedTimestampField() ` +
195
+ `to create a single located-timestamp field instead.`,
195
196
  );
196
197
  }
197
198
  if (referenced.type !== "tz") {
@@ -4,7 +4,11 @@ import { qualifyEntityName } from "../qualified-name";
4
4
  import { getAllowedFilterOps, isFieldFilterable } from "../screen-filter-ops";
5
5
  import type { FeatureDefinition, NavDefinition, WorkspaceDefinition } from "../types";
6
6
  import type {
7
+ DashboardCustomPanel,
8
+ DashboardFilterDefinition,
9
+ DashboardPanelDefinition,
7
10
  DashboardScreenDefinition,
11
+ DashboardStatGroupPanel,
8
12
  FieldCondition,
9
13
  RowAction,
10
14
  RowFieldExtractor,
@@ -655,36 +659,128 @@ function validateDashboardScreen(
655
659
  );
656
660
  }
657
661
  const panelIds = new Set<string>();
662
+ const addPanelId = (id: string, context: string): void => {
663
+ if (panelIds.has(id)) {
664
+ throw new Error(
665
+ `[Feature ${featureName}] Screen "${screenId}" (dashboard) has duplicate panel id "${id}" (${context}).`,
666
+ );
667
+ }
668
+ panelIds.add(id);
669
+ };
670
+
658
671
  for (const panel of screen.panels) {
659
- if (panelIds.has(panel.id)) {
672
+ addPanelId(panel.id, panel.kind);
673
+ if (panel.kind === "stat-group") {
674
+ validateDashboardStatGroupPanel(featureName, screenId, panel, addPanelId);
675
+ } else if (panel.kind === "custom") {
676
+ validateDashboardCustomPanel(featureName, screenId, panel);
677
+ } else {
678
+ validateDashboardQueryPanel(featureName, screenId, panel);
679
+ }
680
+ }
681
+
682
+ if (screen.filter !== undefined) {
683
+ validateDashboardFilterDefinition(featureName, screenId, screen.filter);
684
+ }
685
+ }
686
+
687
+ function validateDashboardStatGroupPanel(
688
+ featureName: string,
689
+ screenId: string,
690
+ panel: DashboardStatGroupPanel,
691
+ addPanelId: (id: string, context: string) => void,
692
+ ): void {
693
+ if (panel.stats.length === 0) {
694
+ throw new Error(
695
+ `[Feature ${featureName}] Screen "${screenId}" (dashboard) stat-group "${panel.id}" has an empty stats list.`,
696
+ );
697
+ }
698
+ for (const stat of panel.stats) {
699
+ addPanelId(stat.id, "stat-group child");
700
+ if (!stat.query || typeof stat.query !== "string") {
660
701
  throw new Error(
661
- `[Feature ${featureName}] Screen "${screenId}" (dashboard) has duplicate panel id "${panel.id}".`,
702
+ `[Feature ${featureName}] Screen "${screenId}" (dashboard) stat-group "${panel.id}" child "${stat.id}" has empty or non-string query.`,
662
703
  );
663
704
  }
664
- panelIds.add(panel.id);
665
- if (!panel.query || typeof panel.query !== "string") {
705
+ if (stat.valueField.length === 0) {
666
706
  throw new Error(
667
- `[Feature ${featureName}] Screen "${screenId}" (dashboard) panel "${panel.id}" has empty or non-string query.`,
707
+ `[Feature ${featureName}] Screen "${screenId}" (dashboard) stat-group "${panel.id}" child "${stat.id}" has empty valueField.`,
668
708
  );
669
709
  }
670
- if (panel.kind === "stat" && panel.valueField.length === 0) {
710
+ }
711
+ }
712
+
713
+ function validateDashboardCustomPanel(
714
+ featureName: string,
715
+ screenId: string,
716
+ panel: DashboardCustomPanel,
717
+ ): void {
718
+ if (panel.component.react === undefined && panel.component.native === undefined) {
719
+ throw new Error(
720
+ `[Feature ${featureName}] Screen "${screenId}" (dashboard) custom-panel "${panel.id}" has no component — ` +
721
+ `declare a react/native component marker.`,
722
+ );
723
+ }
724
+ }
725
+
726
+ function validateDashboardQueryPanel(
727
+ featureName: string,
728
+ screenId: string,
729
+ panel: Exclude<DashboardPanelDefinition, DashboardStatGroupPanel | DashboardCustomPanel>,
730
+ ): void {
731
+ if (!panel.query || typeof panel.query !== "string") {
732
+ throw new Error(
733
+ `[Feature ${featureName}] Screen "${screenId}" (dashboard) panel "${panel.id}" has empty or non-string query.`,
734
+ );
735
+ }
736
+ if (panel.kind === "stat" && panel.valueField.length === 0) {
737
+ throw new Error(
738
+ `[Feature ${featureName}] Screen "${screenId}" (dashboard) stat-panel "${panel.id}" has empty valueField.`,
739
+ );
740
+ }
741
+ if (panel.kind === "list") {
742
+ if (panel.columns.length === 0) {
671
743
  throw new Error(
672
- `[Feature ${featureName}] Screen "${screenId}" (dashboard) stat-panel "${panel.id}" has empty valueField.`,
744
+ `[Feature ${featureName}] Screen "${screenId}" (dashboard) list-panel "${panel.id}" has an empty columns list.`,
673
745
  );
674
746
  }
675
- if (panel.kind === "list") {
676
- if (panel.columns.length === 0) {
677
- throw new Error(
678
- `[Feature ${featureName}] Screen "${screenId}" (dashboard) list-panel "${panel.id}" has an empty columns list.`,
679
- );
680
- }
681
- for (const col of panel.columns) {
682
- validateColumnRendererForm(featureName, screenId, normalizeListColumn(col));
683
- }
747
+ for (const col of panel.columns) {
748
+ validateColumnRendererForm(featureName, screenId, normalizeListColumn(col));
684
749
  }
685
750
  }
686
751
  }
687
752
 
753
+ function validateDashboardFilterDefinition(
754
+ featureName: string,
755
+ screenId: string,
756
+ filter: DashboardFilterDefinition,
757
+ ): void {
758
+ if (filter.id.length === 0) {
759
+ throw new Error(
760
+ `[Feature ${featureName}] Screen "${screenId}" (dashboard) filter has an empty id.`,
761
+ );
762
+ }
763
+ if (filter.label.length === 0) {
764
+ throw new Error(
765
+ `[Feature ${featureName}] Screen "${screenId}" (dashboard) filter has an empty label.`,
766
+ );
767
+ }
768
+ const hasOptions = filter.options !== undefined;
769
+ const hasOptionsQuery = filter.optionsQuery !== undefined;
770
+ if (hasOptions === hasOptionsQuery) {
771
+ throw new Error(
772
+ `[Feature ${featureName}] Screen "${screenId}" (dashboard) filter must set exactly one of ` +
773
+ `options/optionsQuery.`,
774
+ );
775
+ }
776
+ if (hasOptions && (filter.options?.length ?? 0) === 0) {
777
+ throw new Error(
778
+ `[Feature ${featureName}] Screen "${screenId}" (dashboard) filter.options is empty — ` +
779
+ `declare at least one option or use optionsQuery instead.`,
780
+ );
781
+ }
782
+ }
783
+
688
784
  // Form-check für ListColumn-Renderer in der PlatformComponent-Form
689
785
  // (`{ react: { __component: "Name" } }`). Der Server kennt die client-
690
786
  // seitige columnRenderers-Map nicht — also nur prüfen ob die Struktur
@@ -64,7 +64,6 @@ import type {
64
64
  TranslationsDef,
65
65
  TreeActionDef,
66
66
  TreeActionsHandle,
67
- TreeChildrenSubscribe,
68
67
  UiHints,
69
68
  UnmanagedTableEntry,
70
69
  UnmanagedTableOptions,
@@ -163,14 +162,13 @@ export function defineFeature<const TName extends string, TExports = undefined>(
163
162
  let toggleableDefault: boolean | undefined;
164
163
  let description: string | undefined;
165
164
  let uiHints: UiHints | undefined;
166
- // Visual-Tree-Slots — at-most-one per feature, only-once-guard im
167
- // registrar (siehe r.treeActions / r.tree). Undefined wenn das Feature
168
- // keinen Visual-Tree-Beitrag liefert (Zero-Whitelist-Filter).
165
+ // Tree-Action-Slot — at-most-one per feature, only-once-guard im
166
+ // registrar (siehe r.treeActions). Undefined wenn das Feature keine
167
+ // Tree-Actions liefert (Zero-Whitelist-Filter).
169
168
  // Name-Collision-Sicherheit: object-literal-method-Names im registrar
170
169
  // sind keine bindings im closure-scope, daher kollidiert die `treeActions`
171
170
  // closure-let-var nicht mit der `treeActions(...)` registrar-Methode.
172
171
  let treeActions: Readonly<Record<string, TreeActionDef>> | undefined;
173
- let treeProvider: TreeChildrenSubscribe | undefined;
174
172
  // Optional Zod-schema for env-vars this feature reads at runtime,
175
173
  // declared via r.envSchema(). At-most-one per feature.
176
174
  let envSchema: z.ZodObject<z.ZodRawShape> | undefined;
@@ -969,17 +967,6 @@ export function defineFeature<const TName extends string, TExports = undefined>(
969
967
  treeActions: actions,
970
968
  });
971
969
  },
972
-
973
- tree(provider: TreeChildrenSubscribe): void {
974
- // Only-once-guard analog zu r.treeActions.
975
- if (treeProvider !== undefined) {
976
- throw new Error(
977
- `[Feature ${name}] r.tree() already called. ` +
978
- `Each feature may declare a single tree-provider.`,
979
- );
980
- }
981
- treeProvider = provider;
982
- },
983
970
  };
984
971
 
985
972
  const exports = setup(registrar) as TExports; // @cast-boundary engine-bridge
@@ -1049,7 +1036,6 @@ export function defineFeature<const TName extends string, TExports = undefined>(
1049
1036
  rawTables,
1050
1037
  unmanagedTables,
1051
1038
  ...(treeActions !== undefined && { treeActions }),
1052
- ...(treeProvider !== undefined && { treeProvider }),
1053
1039
  ...(envSchema !== undefined && { envSchema }),
1054
1040
  };
1055
1041
  }