@cosmicdrift/kumiko-framework 0.201.0 → 0.203.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 (34) hide show
  1. package/package.json +7 -3
  2. package/src/api/__tests__/api-constants-completeness.test.ts +63 -0
  3. package/src/api/__tests__/body-limit.test.ts +78 -4
  4. package/src/api/__tests__/server-jwt-ttl.test.ts +2 -2
  5. package/src/api/api-constants.ts +44 -7
  6. package/src/api/auth-middleware.ts +19 -3
  7. package/src/api/index.ts +1 -0
  8. package/src/api/route-registrars.ts +19 -22
  9. package/src/api/server.ts +1 -1
  10. package/src/bun-db/__tests__/sql-expr-brand.test.ts +33 -1
  11. package/src/db/__tests__/list-pagination.test.ts +28 -0
  12. package/src/db/dialect.ts +7 -8
  13. package/src/db/entity-table-meta.ts +4 -3
  14. package/src/db/event-store-executor-read.ts +26 -2
  15. package/src/engine/__tests__/boot-validator-action-wiring.test.ts +22 -0
  16. package/src/engine/__tests__/boot-validator-detail-for.test.ts +82 -0
  17. package/src/engine/__tests__/boot-validator-projection-list.test.ts +191 -0
  18. package/src/engine/__tests__/build-app-schema.test.ts +119 -0
  19. package/src/engine/__tests__/projection-detail-actions.test.ts +137 -0
  20. package/src/engine/boot-validator/action-wiring.ts +7 -1
  21. package/src/engine/boot-validator/detail-screens.ts +35 -0
  22. package/src/engine/boot-validator/index.ts +4 -0
  23. package/src/engine/boot-validator/projection-list-screens.ts +82 -0
  24. package/src/engine/boot-validator/screens.ts +42 -1
  25. package/src/engine/build-app-schema.ts +55 -2
  26. package/src/engine/feature-ast/__tests__/patch.test.ts +58 -0
  27. package/src/engine/feature-ast/patch.ts +22 -2
  28. package/src/files/__tests__/files.integration.test.ts +2 -2
  29. package/src/http/__tests__/egress-real-endpoint.integration.test.ts +37 -0
  30. package/src/http/__tests__/egress.test.ts +440 -0
  31. package/src/http/__tests__/policy.test.ts +125 -0
  32. package/src/http/egress.ts +158 -0
  33. package/src/http/index.ts +2 -0
  34. package/src/http/policy.ts +193 -0
@@ -0,0 +1,82 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { validateBoot } from "../boot-validator";
3
+ import { defineFeature } from "../define-feature";
4
+ import { createEntity, createTextField } from "../factories";
5
+
6
+ describe("validateBoot — detailFor screens (fw#2163)", () => {
7
+ test("two screens with the same detailFor fail boot, naming both screen ids", () => {
8
+ const feature = defineFeature("demo", (r) => {
9
+ r.entity("item", createEntity({ table: "Items", fields: { name: createTextField() } }));
10
+ r.screen({
11
+ id: "item-detail-a",
12
+ type: "custom",
13
+ renderer: { react: "stub" },
14
+ detailFor: "item",
15
+ });
16
+ r.screen({
17
+ id: "item-detail-b",
18
+ type: "custom",
19
+ renderer: { react: "stub" },
20
+ detailFor: "item",
21
+ });
22
+ r.translations({
23
+ keys: {
24
+ "screen:item-detail-a.title": { de: "A", en: "A" },
25
+ "screen:item-detail-b.title": { de: "B", en: "B" },
26
+ "demo:entity:item:field:name": { de: "Name", en: "Name" },
27
+ },
28
+ });
29
+ });
30
+ expect(() => validateBoot([feature])).toThrow(/detailFor: "item"/);
31
+ expect(() => validateBoot([feature])).toThrow(/demo:screen:item-detail-a/);
32
+ expect(() => validateBoot([feature])).toThrow(/demo:screen:item-detail-b/);
33
+ });
34
+
35
+ test("detailFor on an unknown entity fails boot", () => {
36
+ const feature = defineFeature("demo", (r) => {
37
+ r.entity("item", createEntity({ table: "Items", fields: { name: createTextField() } }));
38
+ r.screen({
39
+ id: "item-detail",
40
+ type: "custom",
41
+ renderer: { react: "stub" },
42
+ detailFor: "ghost",
43
+ });
44
+ r.translations({
45
+ keys: {
46
+ "screen:item-detail.title": { de: "Detail", en: "Detail" },
47
+ "demo:entity:item:field:name": { de: "Name", en: "Name" },
48
+ },
49
+ });
50
+ });
51
+ expect(() => validateBoot([feature])).toThrow(/"ghost"/);
52
+ });
53
+
54
+ test("a valid detailFor on a custom screen passes boot", () => {
55
+ const feature = defineFeature("demo", (r) => {
56
+ r.entity("item", createEntity({ table: "Items", fields: { name: createTextField() } }));
57
+ r.screen({
58
+ id: "item-detail",
59
+ type: "custom",
60
+ renderer: { react: "stub" },
61
+ detailFor: "item",
62
+ });
63
+ r.translations({
64
+ keys: {
65
+ "screen:item-detail.title": { de: "Detail", en: "Detail" },
66
+ "demo:entity:item:field:name": { de: "Name", en: "Name" },
67
+ },
68
+ });
69
+ });
70
+ expect(() => validateBoot([feature])).not.toThrow();
71
+ });
72
+
73
+ test("an entity without any detail screen passes boot", () => {
74
+ const feature = defineFeature("demo", (r) => {
75
+ r.entity("item", createEntity({ table: "Items", fields: { name: createTextField() } }));
76
+ r.translations({
77
+ keys: { "demo:entity:item:field:name": { de: "Name", en: "Name" } },
78
+ });
79
+ });
80
+ expect(() => validateBoot([feature])).not.toThrow();
81
+ });
82
+ });
@@ -0,0 +1,191 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { z } from "zod";
3
+ import { validateBoot } from "../boot-validator";
4
+ import { defineFeature } from "../define-feature";
5
+
6
+ describe("validateBoot — projectionList screens", () => {
7
+ test("rejects hand-written searchable:true when the query schema has no search param (3a)", () => {
8
+ const feature = defineFeature("ledger", (r) => {
9
+ r.queryHandler("schedule:list", z.object({}), async () => ({ rows: [], nextCursor: null }), {
10
+ access: { openToAll: true },
11
+ });
12
+ r.screen({
13
+ id: "schedule-list",
14
+ type: "projectionList",
15
+ query: "ledger:query:schedule:list",
16
+ columns: ["description"],
17
+ searchable: true,
18
+ });
19
+ r.translations({
20
+ keys: { "screen:schedule-list.title": { de: "Liste", en: "List" } },
21
+ });
22
+ });
23
+ expect(() => validateBoot([feature])).toThrow(/searchable: true.*"search"/);
24
+ });
25
+
26
+ test("requires defaultSort when the query schema accepts search (3b)", () => {
27
+ const feature = defineFeature("ledger", (r) => {
28
+ r.queryHandler(
29
+ "schedule:list",
30
+ z.object({ search: z.string().optional() }),
31
+ async () => ({ rows: [], nextCursor: null }),
32
+ { access: { openToAll: true } },
33
+ );
34
+ r.screen({
35
+ id: "schedule-list",
36
+ type: "projectionList",
37
+ query: "ledger:query:schedule:list",
38
+ columns: ["description"],
39
+ });
40
+ r.translations({
41
+ keys: { "screen:schedule-list.title": { de: "Liste", en: "List" } },
42
+ });
43
+ });
44
+ expect(() => validateBoot([feature])).toThrow(/defaultSort required/);
45
+ });
46
+
47
+ test("requires defaultSort when the query schema accepts sort (3b)", () => {
48
+ const feature = defineFeature("ledger", (r) => {
49
+ r.queryHandler(
50
+ "schedule:list",
51
+ z.object({ sort: z.string().optional() }),
52
+ async () => ({ rows: [], nextCursor: null }),
53
+ { access: { openToAll: true } },
54
+ );
55
+ r.screen({
56
+ id: "schedule-list",
57
+ type: "projectionList",
58
+ query: "ledger:query:schedule:list",
59
+ columns: ["description"],
60
+ });
61
+ r.translations({
62
+ keys: { "screen:schedule-list.title": { de: "Liste", en: "List" } },
63
+ });
64
+ });
65
+ expect(() => validateBoot([feature])).toThrow(/defaultSort required/);
66
+ });
67
+
68
+ test("passes when search/sort are active and defaultSort is set", () => {
69
+ const feature = defineFeature("ledger", (r) => {
70
+ r.queryHandler(
71
+ "schedule:list",
72
+ z.object({ search: z.string().optional(), sort: z.string().optional() }),
73
+ async () => ({ rows: [], nextCursor: null }),
74
+ { access: { openToAll: true } },
75
+ );
76
+ r.screen({
77
+ id: "schedule-list",
78
+ type: "projectionList",
79
+ query: "ledger:query:schedule:list",
80
+ columns: ["description"],
81
+ searchable: true,
82
+ defaultSort: { field: "description", dir: "asc" },
83
+ });
84
+ r.translations({
85
+ keys: { "screen:schedule-list.title": { de: "Liste", en: "List" } },
86
+ });
87
+ });
88
+ expect(() => validateBoot([feature])).not.toThrow();
89
+ });
90
+
91
+ test("passes when the schema offers neither search nor sort and no defaultSort is set", () => {
92
+ const feature = defineFeature("ledger", (r) => {
93
+ r.queryHandler("schedule:list", z.object({}), async () => ({ rows: [], nextCursor: null }), {
94
+ access: { openToAll: true },
95
+ });
96
+ r.screen({
97
+ id: "schedule-list",
98
+ type: "projectionList",
99
+ query: "ledger:query:schedule:list",
100
+ columns: ["description"],
101
+ });
102
+ r.translations({
103
+ keys: { "screen:schedule-list.title": { de: "Liste", en: "List" } },
104
+ });
105
+ });
106
+ expect(() => validateBoot([feature])).not.toThrow();
107
+ });
108
+
109
+ test("rejects hand-written searchable:false when the query schema accepts search and the screen isn't whitelisted", () => {
110
+ const feature = defineFeature("ledger", (r) => {
111
+ r.queryHandler(
112
+ "schedule:list",
113
+ z.object({ search: z.string().optional() }),
114
+ async () => ({ rows: [], nextCursor: null }),
115
+ { access: { openToAll: true } },
116
+ );
117
+ r.screen({
118
+ id: "schedule-list",
119
+ type: "projectionList",
120
+ query: "ledger:query:schedule:list",
121
+ columns: ["description"],
122
+ searchable: false,
123
+ defaultSort: { field: "description", dir: "asc" },
124
+ });
125
+ r.translations({
126
+ keys: { "screen:schedule-list.title": { de: "Liste", en: "List" } },
127
+ });
128
+ });
129
+ expect(() => validateBoot([feature])).toThrow(/searchable: false disables it/);
130
+ });
131
+
132
+ test("passes hand-written searchable:false on a whitelisted screen id even when the schema accepts search", () => {
133
+ const feature = defineFeature("ledger", (r) => {
134
+ r.queryHandler(
135
+ "schedule:list",
136
+ z.object({ search: z.string().optional() }),
137
+ async () => ({ rows: [], nextCursor: null }),
138
+ { access: { openToAll: true } },
139
+ );
140
+ r.screen({
141
+ id: "download-attempt-list",
142
+ type: "projectionList",
143
+ query: "ledger:query:schedule:list",
144
+ columns: ["description"],
145
+ searchable: false,
146
+ });
147
+ r.translations({
148
+ keys: { "screen:download-attempt-list.title": { de: "Liste", en: "List" } },
149
+ });
150
+ });
151
+ expect(() => validateBoot([feature])).not.toThrow();
152
+ });
153
+
154
+ test("rejects hand-authored sortable on a projectionList screen (fw#2165 review)", () => {
155
+ const feature = defineFeature("ledger", (r) => {
156
+ r.queryHandler("schedule:list", z.object({}), async () => ({ rows: [], nextCursor: null }), {
157
+ access: { openToAll: true },
158
+ });
159
+ r.screen({
160
+ id: "schedule-list",
161
+ type: "projectionList",
162
+ query: "ledger:query:schedule:list",
163
+ columns: ["description"],
164
+ sortable: true,
165
+ });
166
+ r.translations({
167
+ keys: { "screen:schedule-list.title": { de: "Liste", en: "List" } },
168
+ });
169
+ });
170
+ expect(() => validateBoot([feature])).toThrow(/sortable is derived/);
171
+ });
172
+
173
+ test("rejects hand-authored paginated on a projectionList screen (fw#2165 review)", () => {
174
+ const feature = defineFeature("ledger", (r) => {
175
+ r.queryHandler("schedule:list", z.object({}), async () => ({ rows: [], nextCursor: null }), {
176
+ access: { openToAll: true },
177
+ });
178
+ r.screen({
179
+ id: "schedule-list",
180
+ type: "projectionList",
181
+ query: "ledger:query:schedule:list",
182
+ columns: ["description"],
183
+ paginated: false,
184
+ });
185
+ r.translations({
186
+ keys: { "screen:schedule-list.title": { de: "Liste", en: "List" } },
187
+ });
188
+ });
189
+ expect(() => validateBoot([feature])).toThrow(/paginated is derived/);
190
+ });
191
+ });
@@ -8,10 +8,12 @@
8
8
  // im Browser-Bundle.
9
9
 
10
10
  import { describe, expect, test } from "bun:test";
11
+ import { z } from "zod";
11
12
  import { buildAppSchema, findNonJsonSafePath } from "../build-app-schema";
12
13
  import { defineFeature } from "../define-feature";
13
14
  import { createRegistry } from "../registry";
14
15
  import type { EntityDefinition } from "../types/fields";
16
+ import type { ProjectionListScreenDefinition } from "../types/screen";
15
17
 
16
18
  describe("buildAppSchema", () => {
17
19
  test("Multi-Feature: jedes Feature wird mit eigenem featureName projiziert", () => {
@@ -91,6 +93,31 @@ describe("buildAppSchema", () => {
91
93
  expect(screen).toMatchObject({ id: "privacy-center", dormant: true });
92
94
  });
93
95
 
96
+ // fw#2163: resolveTarget (renderer) reads screen.detailFor + screen.id
97
+ // (short, unqualified) off the client FeatureSchema — this pins that both
98
+ // survive the server→client projection verbatim, on a real registry-built
99
+ // schema rather than a hand-rolled FeatureSchema literal.
100
+ test("custom screen's `detailFor` survives the buildAppSchema projection, screen id stays unqualified (#2163)", () => {
101
+ const propertyFeature = defineFeature("property", (r) => {
102
+ r.entity("lease", {
103
+ table: "leases",
104
+ fields: { name: { type: "text" } },
105
+ } as unknown as EntityDefinition);
106
+ r.screen({
107
+ id: "lease-detail",
108
+ type: "custom",
109
+ renderer: { react: { __component: "LeaseDetailScreen" } },
110
+ detailFor: "lease",
111
+ });
112
+ r.translations({ keys: { "screen:lease-detail.title": { de: "Detail", en: "Detail" } } });
113
+ });
114
+
115
+ const app = buildAppSchema(createRegistry([propertyFeature]));
116
+ const screen = app.features.find((f) => f.featureName === "property")?.screens[0];
117
+
118
+ expect(screen).toMatchObject({ id: "lease-detail", detailFor: "lease" });
119
+ });
120
+
94
121
  test("Feature ohne r.translations lässt das Feld weg (omit-undefined-Pattern)", () => {
95
122
  const f = defineFeature("bare", (r) => {
96
123
  r.nav({ id: "x", label: "X" });
@@ -430,6 +457,98 @@ describe("buildAppSchema", () => {
430
457
  expect(entity?.derivedFields?.["phase"]).not.toHaveProperty("derive");
431
458
  expect(findNonJsonSafePath(app, "schema")).toBeNull();
432
459
  });
460
+
461
+ // fw#2165: projectionList's searchable/sortable/paginated are derived from
462
+ // the bound query handler's Zod schema, not authored — see
463
+ // deriveProjectionListCapabilities in build-app-schema.ts.
464
+ test("projectionList: search/sort/cursor in the query schema become derived capabilities (fw#2165)", () => {
465
+ const f = defineFeature("ledger", (r) => {
466
+ r.queryHandler(
467
+ "schedule:list",
468
+ z.object({
469
+ search: z.string().optional(),
470
+ sort: z.string().optional(),
471
+ cursor: z.string().optional(),
472
+ }),
473
+ async () => ({ rows: [], nextCursor: null }),
474
+ );
475
+ r.screen({
476
+ id: "schedule-list",
477
+ type: "projectionList",
478
+ query: "ledger:query:schedule:list",
479
+ columns: ["description"],
480
+ });
481
+ });
482
+
483
+ const app = buildAppSchema(createRegistry([f]));
484
+ const screen = app.features[0]?.screens[0] as ProjectionListScreenDefinition;
485
+ expect(screen.searchable).toBe(true);
486
+ expect(screen.sortable).toBe(true);
487
+ expect(screen.paginated).toBe(true);
488
+ });
489
+
490
+ test("projectionList: a query schema without search/sort/cursor derives no capability", () => {
491
+ const f = defineFeature("ledger", (r) => {
492
+ r.queryHandler("schedule:list", z.object({}), async () => ({ rows: [], nextCursor: null }));
493
+ r.screen({
494
+ id: "schedule-list",
495
+ type: "projectionList",
496
+ query: "ledger:query:schedule:list",
497
+ columns: ["description"],
498
+ });
499
+ });
500
+
501
+ const app = buildAppSchema(createRegistry([f]));
502
+ const screen = app.features[0]?.screens[0] as ProjectionListScreenDefinition;
503
+ expect(screen.searchable).toBe(false);
504
+ expect(screen.sortable).toBe(false);
505
+ expect(screen.paginated).toBe(false);
506
+ });
507
+
508
+ test("projectionList: a non-ZodObject query schema (z.union) derives no capability and doesn't throw", () => {
509
+ const f = defineFeature("ledger", (r) => {
510
+ r.queryHandler(
511
+ "schedule:list",
512
+ z.union([z.object({ a: z.string() }), z.object({ b: z.string() })]),
513
+ async () => ({ rows: [], nextCursor: null }),
514
+ );
515
+ r.screen({
516
+ id: "schedule-list",
517
+ type: "projectionList",
518
+ query: "ledger:query:schedule:list",
519
+ columns: ["description"],
520
+ });
521
+ });
522
+
523
+ let app: ReturnType<typeof buildAppSchema> | undefined;
524
+ expect(() => {
525
+ app = buildAppSchema(createRegistry([f]));
526
+ }).not.toThrow();
527
+ const screen = app?.features[0]?.screens[0] as ProjectionListScreenDefinition;
528
+ expect(screen.searchable).toBe(false);
529
+ expect(screen.sortable).toBe(false);
530
+ expect(screen.paginated).toBe(false);
531
+ });
532
+
533
+ test("projectionList: author-written searchable:false survives even when the schema accepts search", () => {
534
+ const f = defineFeature("ledger", (r) => {
535
+ r.queryHandler("schedule:list", z.object({ search: z.string().optional() }), async () => ({
536
+ rows: [],
537
+ nextCursor: null,
538
+ }));
539
+ r.screen({
540
+ id: "schedule-list",
541
+ type: "projectionList",
542
+ query: "ledger:query:schedule:list",
543
+ columns: ["description"],
544
+ searchable: false,
545
+ });
546
+ });
547
+
548
+ const app = buildAppSchema(createRegistry([f]));
549
+ const screen = app.features[0]?.screens[0] as ProjectionListScreenDefinition;
550
+ expect(screen.searchable).toBe(false);
551
+ });
433
552
  });
434
553
 
435
554
  describe("findNonJsonSafePath", () => {
@@ -0,0 +1,137 @@
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
+ import { createEntity, createTextField } from "../factories";
7
+
8
+ function validateBoot(features: Parameters<typeof validateBootRaw>[0]): void {
9
+ validateBootRaw(withBootValidatorFixture(features));
10
+ }
11
+
12
+ // fw#2166: projectionDetail screens can declare header `actions`, reusing
13
+ // RowAction (the displayed record stands in for the row). `rowClick` has no
14
+ // row to target on a detail screen and is rejected outright; navigate/
15
+ // writeHandler actions get the same existence checks as entityList/
16
+ // projectionList rowActions/toolbarActions.
17
+ describe("validateBoot — projectionDetail actions (fw#2166)", () => {
18
+ test("navigate action with rowClick: true throws, naming the screen and action", () => {
19
+ const feature = defineFeature("app", (r) => {
20
+ r.screen({
21
+ id: "rent-detail",
22
+ type: "projectionDetail",
23
+ query: "app:query:rent:detail",
24
+ layout: { sections: [{ title: "s", fields: ["description"] }] },
25
+ actions: [
26
+ {
27
+ kind: "navigate",
28
+ id: "edit",
29
+ label: "actions.edit",
30
+ screen: "rent-edit",
31
+ rowClick: true,
32
+ },
33
+ ],
34
+ });
35
+ r.screen({ id: "rent-edit", type: "custom", renderer: { react: "stub" } });
36
+ });
37
+ expect(() => validateBoot([feature])).toThrow(
38
+ /Screen "app:screen:rent-detail" \(projectionDetail\) action "edit" sets rowClick: true/,
39
+ );
40
+ });
41
+
42
+ test("valid navigate + writeHandler actions boot cleanly", () => {
43
+ const feature = defineFeature("app", (r) => {
44
+ r.writeHandler(
45
+ "archive",
46
+ z.object({ id: z.string() }),
47
+ async () => ({ isSuccess: true as const, data: {} }),
48
+ { access: { openToAll: true } },
49
+ );
50
+ r.screen({
51
+ id: "rent-detail",
52
+ type: "projectionDetail",
53
+ query: "app:query:rent:detail",
54
+ layout: { sections: [{ title: "s", fields: ["description"] }] },
55
+ actions: [
56
+ { kind: "navigate", id: "edit", label: "actions.edit", screen: "rent-edit" },
57
+ {
58
+ kind: "writeHandler",
59
+ id: "archive",
60
+ label: "actions.archive",
61
+ handler: "app:write:archive",
62
+ },
63
+ ],
64
+ });
65
+ r.screen({ id: "rent-edit", type: "custom", renderer: { react: "stub" } });
66
+ });
67
+ expect(() => validateBoot([feature])).not.toThrow();
68
+ });
69
+
70
+ test("navigate action to an unregistered screen throws, via the same existence check as entityList/projectionList", () => {
71
+ const feature = defineFeature("app", (r) => {
72
+ r.screen({
73
+ id: "rent-detail",
74
+ type: "projectionDetail",
75
+ query: "app:query:rent:detail",
76
+ layout: { sections: [{ title: "s", fields: ["description"] }] },
77
+ actions: [{ kind: "navigate", id: "edit", label: "actions.edit", screen: "ghost-screen" }],
78
+ });
79
+ });
80
+ expect(() => validateBoot([feature])).toThrow(
81
+ /action "edit" navigate-target "ghost-screen" does not resolve to a registered screen/,
82
+ );
83
+ });
84
+
85
+ test("navigate action with params targeting an entityEdit of the SAME entity (via detailFor) throws — params are a no-op on an update target (review finding 3b)", () => {
86
+ const feature = defineFeature("app", (r) => {
87
+ r.entity("rent", createEntity({ fields: { name: createTextField() } }));
88
+ r.screen({
89
+ id: "rent-detail",
90
+ type: "projectionDetail",
91
+ query: "app:query:rent:detail",
92
+ layout: { sections: [{ title: "s", fields: ["description"] }] },
93
+ detailFor: "rent",
94
+ actions: [
95
+ {
96
+ kind: "navigate",
97
+ id: "edit",
98
+ label: "actions.edit",
99
+ screen: "rent-edit",
100
+ params: { pick: ["name"] },
101
+ },
102
+ ],
103
+ });
104
+ r.screen({
105
+ id: "rent-edit",
106
+ type: "entityEdit",
107
+ entity: "rent",
108
+ layout: { sections: [{ columns: 1, fields: ["name"] }] },
109
+ });
110
+ });
111
+ expect(() => validateBoot([feature])).toThrow(
112
+ /rowAction "edit" sets params on navigate-target "rent-edit" which resolves to UPDATE mode \(same entity "rent" auto-fills row\["id"\]\)/,
113
+ );
114
+ });
115
+
116
+ test("writeHandler action referencing an unregistered handler QN throws", () => {
117
+ const feature = defineFeature("app", (r) => {
118
+ r.screen({
119
+ id: "rent-detail",
120
+ type: "projectionDetail",
121
+ query: "app:query:rent:detail",
122
+ layout: { sections: [{ title: "s", fields: ["description"] }] },
123
+ actions: [
124
+ {
125
+ kind: "writeHandler",
126
+ id: "archive",
127
+ label: "actions.archive",
128
+ handler: "app:write:ghost",
129
+ },
130
+ ],
131
+ });
132
+ });
133
+ expect(() => validateBoot([feature])).toThrow(
134
+ /action "archive" handler "app:write:ghost" is not a registered write-handler/,
135
+ );
136
+ });
137
+ });
@@ -29,7 +29,7 @@ const ACTION_FUNCTION_FIELDS = ["payload", "params", "entityId", "visible"] as c
29
29
  function validateActionNoFunctions(
30
30
  featureName: string,
31
31
  screenId: string,
32
- actionKind: "rowAction" | "toolbarAction",
32
+ actionKind: "rowAction" | "toolbarAction" | "action",
33
33
  action: RowAction | ToolbarAction,
34
34
  ): void {
35
35
  const record = action as unknown as Record<string, unknown>;
@@ -45,6 +45,12 @@ function validateActionNoFunctions(
45
45
 
46
46
  export function validateActionWiring(feature: FeatureDefinition): void {
47
47
  for (const screen of Object.values(feature.screens)) {
48
+ if (screen.type === "projectionDetail") {
49
+ for (const action of screen.actions ?? []) {
50
+ validateActionNoFunctions(feature.name, screen.id, "action", action);
51
+ }
52
+ continue;
53
+ }
48
54
  if (screen.type !== "entityList" && screen.type !== "projectionList") continue;
49
55
  for (const action of screen.rowActions ?? []) {
50
56
  validateActionNoFunctions(feature.name, screen.id, "rowAction", action);
@@ -0,0 +1,35 @@
1
+ import { qualifyEntityName } from "../qualified-name";
2
+ import type { FeatureDefinition } from "../types";
3
+ import { findEntityFeature } from "./screens";
4
+
5
+ export function validateDetailForScreens(
6
+ features: readonly FeatureDefinition[],
7
+ featureMap: ReadonlyMap<string, FeatureDefinition>,
8
+ ): void {
9
+ const screenQnByEntity = new Map<string, string>();
10
+
11
+ for (const feature of features) {
12
+ for (const [screenId, screen] of Object.entries(feature.screens)) {
13
+ const detailFor = screen.detailFor;
14
+ if (detailFor === undefined) continue;
15
+
16
+ const qualified = qualifyEntityName(feature.name, "screen", screenId);
17
+
18
+ const existingQn = screenQnByEntity.get(detailFor);
19
+ if (existingQn !== undefined) {
20
+ throw new Error(
21
+ `[detailFor] Screens "${existingQn}" and "${qualified}" both declare ` +
22
+ `detailFor: "${detailFor}" — only one screen may be the detail view for an entity.`,
23
+ );
24
+ }
25
+ screenQnByEntity.set(detailFor, qualified);
26
+
27
+ if (findEntityFeature(detailFor, featureMap) === undefined) {
28
+ throw new Error(
29
+ `[detailFor] Screen "${qualified}" declares detailFor: "${detailFor}", ` +
30
+ `but no feature registers an entity with that name.`,
31
+ );
32
+ }
33
+ }
34
+ }
35
+ }
@@ -16,6 +16,7 @@ import {
16
16
  validateConfigReads,
17
17
  warnOnToggleableDependencies,
18
18
  } from "./config-deps";
19
+ import { validateDetailForScreens } from "./detail-screens";
19
20
  import {
20
21
  validateDerivedFieldCollisions,
21
22
  validateEmbeddedFields,
@@ -44,6 +45,7 @@ import {
44
45
  } from "./nav";
45
46
  import { validateOwnershipRules } from "./ownership";
46
47
  import { validatePiiAndRetention } from "./pii-retention";
48
+ import { validateProjectionListScreens } from "./projection-list-screens";
47
49
  import {
48
50
  collectScreenQns,
49
51
  collectScreensByShortId,
@@ -208,6 +210,8 @@ export function validateBoot(
208
210
  validateDefaultWorkspaceUniqueness(allWorkspaceQns);
209
211
  validateI18nSurfaceKeys(features);
210
212
  validateEntityListScreens(features);
213
+ validateDetailForScreens(features, featureMap);
214
+ validateProjectionListScreens(features);
211
215
  validateExtensionPreSaveWiring(features);
212
216
  validateGdprStoragePersistence(features);
213
217
  validateFeatureBootChecks(features);