@cosmicdrift/kumiko-framework 0.204.0 → 0.205.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.204.0",
3
+ "version": "0.205.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>",
@@ -190,7 +190,7 @@
190
190
  "./package.json": "./package.json"
191
191
  },
192
192
  "dependencies": {
193
- "@cosmicdrift/kumiko-types": "0.204.0",
193
+ "@cosmicdrift/kumiko-types": "0.205.0",
194
194
  "bullmq": "^5.76.7",
195
195
  "bun-types": "^1.3.13",
196
196
  "hono": "^4.13.1",
@@ -206,7 +206,7 @@
206
206
  "zod": "^4.4.3"
207
207
  },
208
208
  "devDependencies": {
209
- "@cosmicdrift/kumiko-dispatcher-live": "0.204.0",
209
+ "@cosmicdrift/kumiko-dispatcher-live": "0.205.0",
210
210
  "bun-types": "^1.3.13",
211
211
  "pino-pretty": "^13.1.3"
212
212
  },
@@ -1,4 +1,5 @@
1
1
  import { describe, expect, test } from "bun:test";
2
+ import { z } from "zod";
2
3
  import { requiredKeysFromScreen } from "../../i18n/required-surface-keys";
3
4
  import { validateBoot } from "../boot-validator";
4
5
  import { defineFeature } from "../define-feature";
@@ -17,6 +18,12 @@ function dashboardFeature(
17
18
  filter?: DashboardScreenDefinition["filter"],
18
19
  ) {
19
20
  return defineFeature("demo", (r) => {
21
+ r.queryHandler("incident:open-count", z.object({}), async () => ({ count: 3 }), {
22
+ access: { openToAll: true },
23
+ });
24
+ r.queryHandler("incident:latest", z.object({}), async () => ({ rows: [], nextCursor: null }), {
25
+ access: { openToAll: true },
26
+ });
20
27
  r.screen({
21
28
  id: "overview",
22
29
  type: "dashboard",
@@ -0,0 +1,151 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { z } from "zod";
3
+ import { withBootValidatorFixture } from "../../testing/boot-validator-fixture";
4
+ import { validateBoot as validateBootRaw } from "../boot-validator";
5
+ import { defineFeature } from "../define-feature";
6
+
7
+ function validateBoot(features: Parameters<typeof validateBootRaw>[0]): void {
8
+ validateBootRaw(withBootValidatorFixture(features));
9
+ }
10
+
11
+ // fw#2178: a screen's `query` (or a relatedList/dashboard-panel query) must
12
+ // resolve to a query-handler actually registered via r.queryHandler(...) —
13
+ // mirrors the existing rowAction/toolbarAction handler-existence check.
14
+ describe("validateBoot — query QN refs (fw#2178)", () => {
15
+ test("projectionList with a dead query QN throws (a)", () => {
16
+ const feature = defineFeature("ledger", (r) => {
17
+ r.screen({
18
+ id: "schedule-list",
19
+ type: "projectionList",
20
+ query: "ledger:query:schedule:ghost",
21
+ columns: ["description"],
22
+ });
23
+ });
24
+ expect(() => validateBoot([feature])).toThrow(
25
+ /query "ledger:query:schedule:ghost" is not a registered query-handler/,
26
+ );
27
+ });
28
+
29
+ // (b) targets projectionDetail rather than entityEdit: screens.ts
30
+ // unconditionally rejects a relatedList section on entityEdit/actionForm/
31
+ // configEdit with its own "projectionDetail-only primitive" error
32
+ // (fw#2166, screens.ts:955/:621/:506) — that fires first in the per-feature
33
+ // loop, before validateQueryRefs ever runs, so a dead QN there can't reach
34
+ // this check. projectionDetail is the one screen type where relatedList is
35
+ // actually reachable.
36
+ test("projectionDetail relatedList section with a dead query QN throws (b)", () => {
37
+ const feature = defineFeature("app", (r) => {
38
+ r.queryHandler("rent:detail", z.object({}), async () => ({ description: "x" }), {
39
+ access: { openToAll: true },
40
+ });
41
+ r.screen({
42
+ id: "rent-detail",
43
+ type: "projectionDetail",
44
+ query: "app:query:rent:detail",
45
+ layout: {
46
+ sections: [
47
+ {
48
+ kind: "relatedList",
49
+ title: "Payments",
50
+ query: "app:query:rent:payments-ghost",
51
+ columns: ["amount"],
52
+ },
53
+ ],
54
+ },
55
+ });
56
+ });
57
+ expect(() => validateBoot([feature])).toThrow(
58
+ /relatedList section "Payments" query "app:query:rent:payments-ghost" is not a registered query-handler/,
59
+ );
60
+ });
61
+
62
+ test("dashboard stat-group child with a dead query QN throws (c)", () => {
63
+ const feature = defineFeature("demo", (r) => {
64
+ r.screen({
65
+ id: "overview",
66
+ type: "dashboard",
67
+ panels: [
68
+ {
69
+ kind: "stat-group",
70
+ id: "net-worth",
71
+ label: "demo:dashboard:group:net-worth",
72
+ stats: [
73
+ {
74
+ kind: "stat",
75
+ id: "assets",
76
+ label: "demo:dashboard:panel:assets",
77
+ query: "demo:query:net-worth:assets-ghost",
78
+ valueField: "value",
79
+ },
80
+ ],
81
+ },
82
+ ],
83
+ });
84
+ r.translations({
85
+ keys: {
86
+ "demo:dashboard:group:net-worth": { de: "Net Worth", en: "Net Worth" },
87
+ "demo:dashboard:panel:assets": { de: "Assets", en: "Assets" },
88
+ },
89
+ });
90
+ });
91
+ expect(() => validateBoot([feature])).toThrow(
92
+ /stat-group "net-worth" child "assets" query "demo:query:net-worth:assets-ghost" is not a registered query-handler/,
93
+ );
94
+ });
95
+
96
+ test("dashboard filter optionsQuery with a dead query QN throws", () => {
97
+ const feature = defineFeature("demo", (r) => {
98
+ r.queryHandler("open-count", z.object({}), async () => ({ value: "0" }), {
99
+ access: { openToAll: true },
100
+ });
101
+ r.screen({
102
+ id: "overview",
103
+ type: "dashboard",
104
+ filter: {
105
+ id: "region",
106
+ label: "demo:dashboard:filter:region",
107
+ kind: "select",
108
+ optionsQuery: "demo:query:region:options-ghost",
109
+ },
110
+ panels: [
111
+ {
112
+ kind: "stat",
113
+ id: "open",
114
+ label: "demo:dashboard:panel:open",
115
+ query: "demo:query:open-count",
116
+ valueField: "value",
117
+ },
118
+ ],
119
+ });
120
+ r.translations({
121
+ keys: {
122
+ "demo:dashboard:panel:open": { de: "Offen", en: "Open" },
123
+ "demo:dashboard:filter:region": { de: "Region", en: "Region" },
124
+ },
125
+ });
126
+ });
127
+ expect(() => validateBoot([feature])).toThrow(
128
+ /filter "region" query "demo:query:region:options-ghost" is not a registered query-handler/,
129
+ );
130
+ });
131
+
132
+ test("a screen referencing a query registered by a different mounted feature does not throw (d)", () => {
133
+ const provider = defineFeature("catalog", (r) => {
134
+ r.queryHandler("items:list", z.object({}), async () => ({ rows: [], nextCursor: null }), {
135
+ access: { openToAll: true },
136
+ });
137
+ });
138
+ const consumer = defineFeature("storefront", (r) => {
139
+ r.screen({
140
+ id: "items",
141
+ type: "projectionList",
142
+ query: "catalog:query:items:list",
143
+ columns: ["name"],
144
+ });
145
+ r.translations({
146
+ keys: { "screen:items.title": { de: "Artikel", en: "Items" } },
147
+ });
148
+ });
149
+ expect(() => validateBoot([provider, consumer])).not.toThrow();
150
+ });
151
+ });
@@ -3838,6 +3838,9 @@ describe("boot-validator — config key backing × scope", () => {
3838
3838
  test("projectionList rowAction: navigate with params to an entityEdit-create target (no entityId) → no throw", () => {
3839
3839
  const feature = defineFeature("shop", (r) => {
3840
3840
  r.entity("product", createEntity({ fields: { name: createTextField() } }));
3841
+ r.queryHandler("products", z.object({}), async () => ({ rows: [], nextCursor: null }), {
3842
+ access: { openToAll: true },
3843
+ });
3841
3844
  r.screen({
3842
3845
  id: "product-projection",
3843
3846
  type: "projectionList",
@@ -41,6 +41,9 @@ describe("validateBoot — projectionDetail actions (fw#2166)", () => {
41
41
 
42
42
  test("valid navigate + writeHandler actions boot cleanly", () => {
43
43
  const feature = defineFeature("app", (r) => {
44
+ r.queryHandler("rent:detail", z.object({}), async () => ({ description: "x" }), {
45
+ access: { openToAll: true },
46
+ });
44
47
  r.writeHandler(
45
48
  "archive",
46
49
  z.object({ id: z.string() }),
@@ -214,6 +214,12 @@ describe("r.screen() — registration", () => {
214
214
  test("validateBoot accepts a relatedList rowClick with a detailFor screen in another feature (fw#2166)", () => {
215
215
  const features = [
216
216
  defineFeature("app", (r) => {
217
+ r.queryHandler("foo:detail", z.object({}), async () => ({}), {
218
+ access: { openToAll: true },
219
+ });
220
+ r.queryHandler("foo:list", z.object({}), async () => ({ rows: [], nextCursor: null }), {
221
+ access: { openToAll: true },
222
+ });
217
223
  r.screen({
218
224
  id: "x",
219
225
  type: "projectionDetail",
@@ -269,6 +275,12 @@ describe("r.screen() — registration", () => {
269
275
  test("validateBoot accepts a valid relatedList section without rowClick (fw#2166)", () => {
270
276
  const features = [
271
277
  defineFeature("app", (r) => {
278
+ r.queryHandler("foo:detail", z.object({}), async () => ({}), {
279
+ access: { openToAll: true },
280
+ });
281
+ r.queryHandler("foo:list", z.object({}), async () => ({ rows: [], nextCursor: null }), {
282
+ access: { openToAll: true },
283
+ });
272
284
  r.screen({
273
285
  id: "x",
274
286
  type: "projectionDetail",
@@ -46,6 +46,7 @@ import {
46
46
  import { validateOwnershipRules } from "./ownership";
47
47
  import { validatePiiAndRetention } from "./pii-retention";
48
48
  import { validateProjectionListScreens } from "./projection-list-screens";
49
+ import { validateQueryRefs } from "./query-refs";
49
50
  import {
50
51
  collectScreenQns,
51
52
  collectScreensByShortId,
@@ -211,6 +212,11 @@ export function validateBoot(
211
212
  validateI18nSurfaceKeys(features);
212
213
  validateEntityListScreens(features);
213
214
  validateDetailForScreens(features, featureMap);
215
+ // Must run before validateProjectionListScreens: an unresolvable query
216
+ // there is silently treated as "capability absent" and surfaces as a
217
+ // misleading "no search parameter in its Zod schema" error instead of
218
+ // the clear typo message below.
219
+ validateQueryRefs(features);
214
220
  validateProjectionListScreens(features);
215
221
  validateExtensionPreSaveWiring(features);
216
222
  validateGdprStoragePersistence(features);
@@ -9,7 +9,9 @@ import { SEARCHABLE_FALSE_WHITELIST } from "./entity-list-screens";
9
9
  // entity, so sharing the function would mean threading a discriminated
10
10
  // union through every entity-bound helper it calls.
11
11
 
12
- function buildQueryHandlerMap(
12
+ // Exported for query-refs.ts (fw#2178) — one source for the QN-derivation
13
+ // logic instead of duplicating it in both boot-validators.
14
+ export function buildQueryHandlerMap(
13
15
  features: readonly FeatureDefinition[],
14
16
  ): ReadonlyMap<string, QueryHandlerDef> {
15
17
  const out = new Map<string, QueryHandlerDef>();
@@ -0,0 +1,129 @@
1
+ import type {
2
+ DashboardScreenDefinition,
3
+ EditLayout,
4
+ FeatureDefinition,
5
+ QueryHandlerDef,
6
+ ScreenDefinition,
7
+ } from "../types";
8
+ import { buildQueryHandlerMap } from "./projection-list-screens";
9
+
10
+ const NOT_REGISTERED_SUFFIX =
11
+ "is not a registered query-handler. Check the QN spelling (expected " +
12
+ '"<feature>:query:<short>") and that the handler is declared via r.queryHandler(...).';
13
+
14
+ function isNonEmptyQueryString(value: unknown): value is string {
15
+ return typeof value === "string" && value.length > 0;
16
+ }
17
+
18
+ function checkQueryRef(
19
+ queryHandlers: ReadonlyMap<string, QueryHandlerDef>,
20
+ query: string,
21
+ buildPrefix: () => string,
22
+ ): void {
23
+ // skip: empty/non-string queries get their own message in screens.ts;
24
+ // a registered query has nothing left to check.
25
+ if (!isNonEmptyQueryString(query) || queryHandlers.has(query)) return;
26
+ throw new Error(`${buildPrefix()} query "${query}" ${NOT_REGISTERED_SUFFIX}`);
27
+ }
28
+
29
+ // relatedList is currently only reachable on projectionDetail — screens.ts
30
+ // rejects it outright on entityEdit/actionForm/configEdit (fw#2166). Walking
31
+ // the shared EditLayout uniformly here (instead of excluding those three
32
+ // types) keeps this collector correct if that restriction is ever lifted.
33
+ function checkEditLayoutQueryRefs(
34
+ queryHandlers: ReadonlyMap<string, QueryHandlerDef>,
35
+ featureName: string,
36
+ screenId: string,
37
+ screenType: string,
38
+ layout: EditLayout,
39
+ ): void {
40
+ for (const section of layout.sections) {
41
+ if (section.kind !== "relatedList") continue;
42
+ checkQueryRef(
43
+ queryHandlers,
44
+ section.query,
45
+ () =>
46
+ `[Feature ${featureName}] Screen "${screenId}" (${screenType}) relatedList section "${section.title}"`,
47
+ );
48
+ }
49
+ }
50
+
51
+ function checkDashboardQueryRefs(
52
+ queryHandlers: ReadonlyMap<string, QueryHandlerDef>,
53
+ featureName: string,
54
+ screenId: string,
55
+ screen: DashboardScreenDefinition,
56
+ ): void {
57
+ for (const panel of screen.panels) {
58
+ if (panel.kind === "custom") continue;
59
+ if (panel.kind === "stat-group") {
60
+ for (const stat of panel.stats) {
61
+ checkQueryRef(
62
+ queryHandlers,
63
+ stat.query,
64
+ () =>
65
+ `[Feature ${featureName}] Screen "${screenId}" (dashboard) stat-group "${panel.id}" child "${stat.id}"`,
66
+ );
67
+ }
68
+ continue;
69
+ }
70
+ checkQueryRef(
71
+ queryHandlers,
72
+ panel.query,
73
+ () => `[Feature ${featureName}] Screen "${screenId}" (dashboard) panel "${panel.id}"`,
74
+ );
75
+ }
76
+ if (screen.filter?.optionsQuery !== undefined) {
77
+ checkQueryRef(
78
+ queryHandlers,
79
+ screen.filter.optionsQuery,
80
+ () =>
81
+ `[Feature ${featureName}] Screen "${screenId}" (dashboard) filter "${screen.filter?.id}"`,
82
+ );
83
+ }
84
+ }
85
+
86
+ function checkScreenQueryRefs(
87
+ queryHandlers: ReadonlyMap<string, QueryHandlerDef>,
88
+ featureName: string,
89
+ screenId: string,
90
+ screen: ScreenDefinition,
91
+ ): void {
92
+ if (screen.type === "projectionList") {
93
+ checkQueryRef(
94
+ queryHandlers,
95
+ screen.query,
96
+ () => `[Feature ${featureName}] Screen "${screenId}" (projectionList)`,
97
+ );
98
+ } else if (screen.type === "projectionDetail") {
99
+ checkQueryRef(
100
+ queryHandlers,
101
+ screen.query,
102
+ () => `[Feature ${featureName}] Screen "${screenId}" (projectionDetail)`,
103
+ );
104
+ checkEditLayoutQueryRefs(
105
+ queryHandlers,
106
+ featureName,
107
+ screenId,
108
+ "projectionDetail",
109
+ screen.layout,
110
+ );
111
+ } else if (
112
+ screen.type === "entityEdit" ||
113
+ screen.type === "actionForm" ||
114
+ screen.type === "configEdit"
115
+ ) {
116
+ checkEditLayoutQueryRefs(queryHandlers, featureName, screenId, screen.type, screen.layout);
117
+ } else if (screen.type === "dashboard") {
118
+ checkDashboardQueryRefs(queryHandlers, featureName, screenId, screen);
119
+ }
120
+ }
121
+
122
+ export function validateQueryRefs(features: readonly FeatureDefinition[]): void {
123
+ const queryHandlers = buildQueryHandlerMap(features);
124
+ for (const feature of features) {
125
+ for (const [screenId, screen] of Object.entries(feature.screens)) {
126
+ checkScreenQueryRefs(queryHandlers, feature.name, screenId, screen);
127
+ }
128
+ }
129
+ }