@cosmicdrift/kumiko-framework 0.309.0 → 0.310.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.309.0",
3
+ "version": "0.310.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>",
@@ -198,8 +198,8 @@
198
198
  "./package.json": "./package.json"
199
199
  },
200
200
  "dependencies": {
201
- "@cosmicdrift/kumiko-http": "0.309.0",
202
- "@cosmicdrift/kumiko-types": "0.309.0",
201
+ "@cosmicdrift/kumiko-http": "0.310.0",
202
+ "@cosmicdrift/kumiko-types": "0.310.0",
203
203
  "bullmq": "^5.76.7",
204
204
  "bun-types": "^1.3.13",
205
205
  "hono": "^4.13.1",
@@ -215,7 +215,7 @@
215
215
  "zod": "^4.4.3"
216
216
  },
217
217
  "devDependencies": {
218
- "@cosmicdrift/kumiko-dispatcher-live": "0.309.0",
218
+ "@cosmicdrift/kumiko-dispatcher-live": "0.310.0",
219
219
  "bun-types": "^1.3.13",
220
220
  "pino-pretty": "^13.1.3"
221
221
  },
package/src/changes.json CHANGED
@@ -1,4 +1,10 @@
1
1
  [
2
+ {
3
+ "version": "0.310.0",
4
+ "type": "improvement",
5
+ "title": "Boot validator checks the target tab of navigate actions",
6
+ "detail": "A navigate action with `tab` fails boot when its target is not a\nprojectionDetail with layout.mode \"tabs\" or has no section with that id.\nThe check covers list rowActions, relatedList rowActions, screen actions,\nsection actions and relatedList emptyState actions."
7
+ },
2
8
  {
3
9
  "version": "0.309.0",
4
10
  "type": "fix",
@@ -0,0 +1,107 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import * as 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
+ import { createEntity, createTextField } from "../factories";
7
+
8
+ function validateBoot(features: Parameters<typeof validateBootRaw>[0]): void {
9
+ validateBootRaw(withBootValidatorFixture(features));
10
+ }
11
+
12
+ function buildFeature(options: {
13
+ readonly detailTabs: boolean;
14
+ readonly tab: string;
15
+ readonly via: "entityEditSection" | "entityListRowAction";
16
+ }) {
17
+ return defineFeature("app", (r) => {
18
+ r.entity(
19
+ "vehicle",
20
+ createEntity({
21
+ fields: { name: createTextField({ personal: false, reason: "test_fixture" }) },
22
+ }),
23
+ );
24
+ r.queryHandler("vehicle:detail", z.object({}), async () => ({ name: "x" }), {
25
+ access: { openToAll: { reason: "test handler callable by any signed-in test user" } },
26
+ });
27
+ r.screen({
28
+ id: "vehicle-detail",
29
+ type: "projectionDetail",
30
+ query: "app:query:vehicle:detail",
31
+ layout: options.detailTabs
32
+ ? {
33
+ mode: "tabs",
34
+ sections: [
35
+ { id: "overview", title: "Overview", fields: ["name"] },
36
+ { id: "channels", title: "Channels", fields: ["name"] },
37
+ ],
38
+ }
39
+ : { sections: [{ title: "Overview", fields: ["name"] }] },
40
+ });
41
+ const navigateAction = {
42
+ kind: "navigate" as const,
43
+ id: "view-channels",
44
+ label: "app.actions.viewChannels",
45
+ screen: "vehicle-detail",
46
+ tab: options.tab,
47
+ };
48
+ if (options.via === "entityEditSection") {
49
+ r.screen({
50
+ id: "vehicle-edit",
51
+ type: "entityEdit",
52
+ entity: "vehicle",
53
+ layout: {
54
+ sections: [
55
+ {
56
+ id: "main",
57
+ title: "Main",
58
+ columns: 1,
59
+ fields: ["name"],
60
+ actions: [{ ...navigateAction, entityId: "vehicleId" }],
61
+ },
62
+ ],
63
+ },
64
+ });
65
+ } else {
66
+ r.screen({
67
+ id: "vehicle-list",
68
+ type: "entityList",
69
+ entity: "vehicle",
70
+ columns: ["name"],
71
+ rowActions: [navigateAction],
72
+ });
73
+ }
74
+ });
75
+ }
76
+
77
+ describe("validateBoot — RowActionNavigate.tab", () => {
78
+ test("entityEdit section action navigates to a known tab boots cleanly", () => {
79
+ const feature = buildFeature({ detailTabs: true, tab: "channels", via: "entityEditSection" });
80
+ expect(() => validateBoot([feature])).not.toThrow();
81
+ });
82
+
83
+ test("entityEdit section action navigates to an unknown tab throws", () => {
84
+ const feature = buildFeature({ detailTabs: true, tab: "unknown", via: "entityEditSection" });
85
+ expect(() => validateBoot([feature])).toThrow(
86
+ /navigates to tab "unknown", which is not a section id on target screen/,
87
+ );
88
+ });
89
+
90
+ test("entityEdit section action navigating to a non-tabs projectionDetail throws", () => {
91
+ const feature = buildFeature({ detailTabs: false, tab: "overview", via: "entityEditSection" });
92
+ expect(() => validateBoot([feature])).toThrow(
93
+ /is not a projectionDetail with layout.mode "tabs"/,
94
+ );
95
+ });
96
+
97
+ test("entityList rowAction navigating to an unknown tab throws (central resolution path)", () => {
98
+ const feature = buildFeature({
99
+ detailTabs: true,
100
+ tab: "unknown",
101
+ via: "entityListRowAction",
102
+ });
103
+ expect(() => validateBoot([feature])).toThrow(
104
+ /navigates to tab "unknown", which is not a section id on target screen/,
105
+ );
106
+ });
107
+ });
@@ -542,11 +542,48 @@ type RowActionNavigateRuntime = RowActionNavigateBase & {
542
542
  readonly entity?: string;
543
543
  };
544
544
 
545
+ // The target can be any screen, so this checks the resolved target's
546
+ // sections, not the host's.
547
+ function validateRowActionNavigateTab(
548
+ featureName: string,
549
+ screenId: string,
550
+ screenType: "entityList" | "projectionList" | "projectionDetail" | "entityEdit",
551
+ actionLabel: string,
552
+ action: RowActionNavigateRuntime,
553
+ target: { readonly featureName: string; readonly screen: ScreenDefinition } | undefined,
554
+ ): void {
555
+ // skip: no tab declared — nothing to check against the target's sections
556
+ if (action.tab === undefined) return;
557
+ if (target === undefined) {
558
+ throw new Error(
559
+ `[Feature ${featureName}] Screen "${screenId}" (${screenType}) ${actionLabel} "${action.id}" ` +
560
+ `sets tab "${action.tab}", but its navigate-target could not be resolved to a screen.`,
561
+ );
562
+ }
563
+ if (target.screen.type !== "projectionDetail" || target.screen.layout.mode !== "tabs") {
564
+ throw new Error(
565
+ `[Feature ${featureName}] Screen "${screenId}" (${screenType}) ${actionLabel} "${action.id}" ` +
566
+ `sets tab "${action.tab}", but navigate-target screen "${target.screen.id}" is not a ` +
567
+ `projectionDetail with layout.mode "tabs" — only those read the tab param.`,
568
+ );
569
+ }
570
+ const sectionIds = target.screen.layout.sections
571
+ .map((section) => section.id)
572
+ .filter((id): id is string => id !== undefined);
573
+ if (!sectionIds.includes(action.tab)) {
574
+ throw new Error(
575
+ `[Feature ${featureName}] Screen "${screenId}" (${screenType}) ${actionLabel} "${action.id}" ` +
576
+ `navigates to tab "${action.tab}", which is not a section id on target screen ` +
577
+ `"${target.screen.id}". Available: ${sectionIds.join(", ") || "(none)"}.`,
578
+ );
579
+ }
580
+ }
581
+
545
582
  function resolveRowActionNavigateTarget(
546
583
  featureName: string,
547
584
  screenId: string,
548
585
  screenType: "entityList" | "projectionList" | "projectionDetail" | "entityEdit",
549
- actionLabel: "rowAction" | "action",
586
+ actionLabel: string,
550
587
  action: RowActionNavigateRuntime,
551
588
  allScreenQns: ReadonlySet<string>,
552
589
  navTargetShortIds: ReadonlySet<string>,
@@ -586,6 +623,7 @@ function resolveRowActionNavigateTarget(
586
623
  `detailFor: "${action.entity}".`,
587
624
  );
588
625
  }
626
+ validateRowActionNavigateTab(featureName, screenId, screenType, actionLabel, action, detail);
589
627
  return detail;
590
628
  }
591
629
  if (action.screen === undefined) {
@@ -601,7 +639,9 @@ function resolveRowActionNavigateTarget(
601
639
  `navigate-target "${action.screen}" does not resolve to a registered screen in any feature.`,
602
640
  );
603
641
  }
604
- return screensByShortId.get(action.screen)?.[0];
642
+ const target = screensByShortId.get(action.screen)?.[0];
643
+ validateRowActionNavigateTab(featureName, screenId, screenType, actionLabel, action, target);
644
+ return target;
605
645
  }
606
646
 
607
647
  // Shared by the mint step's own handler (screen.handler) and its optional
@@ -1225,6 +1265,19 @@ export function validateScreens(
1225
1265
  `section "${sectionLabel}" action`,
1226
1266
  action,
1227
1267
  );
1268
+ if (action.kind === "navigate" && action.tab !== undefined) {
1269
+ resolveRowActionNavigateTarget(
1270
+ feature.name,
1271
+ screenId,
1272
+ "projectionDetail",
1273
+ `section "${sectionLabel}" action`,
1274
+ action,
1275
+ allScreenQns,
1276
+ navTargetShortIds,
1277
+ screensByShortId,
1278
+ detailForScreens,
1279
+ );
1280
+ }
1228
1281
  }
1229
1282
  }
1230
1283
  if (section.kind === "relatedList" && section.emptyState?.action !== undefined) {
@@ -1235,6 +1288,22 @@ export function validateScreens(
1235
1288
  `section "${sectionLabel}" emptyState action`,
1236
1289
  section.emptyState.action,
1237
1290
  );
1291
+ if (
1292
+ section.emptyState.action.kind === "navigate" &&
1293
+ section.emptyState.action.tab !== undefined
1294
+ ) {
1295
+ resolveRowActionNavigateTarget(
1296
+ feature.name,
1297
+ screenId,
1298
+ "projectionDetail",
1299
+ `section "${sectionLabel}" emptyState action`,
1300
+ section.emptyState.action,
1301
+ allScreenQns,
1302
+ navTargetShortIds,
1303
+ screensByShortId,
1304
+ detailForScreens,
1305
+ );
1306
+ }
1238
1307
  }
1239
1308
  if (isExtensionEditSection(section)) {
1240
1309
  // projectionDetail is read-only (no composed form submit) — an
@@ -1983,6 +2052,19 @@ export function validateScreens(
1983
2052
  `section "${sectionLabel}" action`,
1984
2053
  action,
1985
2054
  );
2055
+ if (action.kind === "navigate" && action.tab !== undefined) {
2056
+ resolveRowActionNavigateTarget(
2057
+ feature.name,
2058
+ screenId,
2059
+ "entityEdit",
2060
+ `section "${sectionLabel}" action`,
2061
+ action,
2062
+ allScreenQns,
2063
+ navTargetShortIds,
2064
+ screensByShortId,
2065
+ detailForScreens,
2066
+ );
2067
+ }
1986
2068
  }
1987
2069
  }
1988
2070
  if (isExtensionEditSection(section)) {