@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.
- package/package.json +7 -3
- package/src/api/__tests__/api-constants-completeness.test.ts +63 -0
- package/src/api/__tests__/body-limit.test.ts +78 -4
- package/src/api/__tests__/server-jwt-ttl.test.ts +2 -2
- package/src/api/api-constants.ts +44 -7
- package/src/api/auth-middleware.ts +19 -3
- package/src/api/index.ts +1 -0
- package/src/api/route-registrars.ts +19 -22
- package/src/api/server.ts +1 -1
- package/src/bun-db/__tests__/sql-expr-brand.test.ts +33 -1
- package/src/db/__tests__/list-pagination.test.ts +28 -0
- package/src/db/dialect.ts +7 -8
- package/src/db/entity-table-meta.ts +4 -3
- package/src/db/event-store-executor-read.ts +26 -2
- package/src/engine/__tests__/boot-validator-action-wiring.test.ts +22 -0
- package/src/engine/__tests__/boot-validator-detail-for.test.ts +82 -0
- package/src/engine/__tests__/boot-validator-projection-list.test.ts +191 -0
- package/src/engine/__tests__/build-app-schema.test.ts +119 -0
- package/src/engine/__tests__/projection-detail-actions.test.ts +137 -0
- package/src/engine/boot-validator/action-wiring.ts +7 -1
- package/src/engine/boot-validator/detail-screens.ts +35 -0
- package/src/engine/boot-validator/index.ts +4 -0
- package/src/engine/boot-validator/projection-list-screens.ts +82 -0
- package/src/engine/boot-validator/screens.ts +42 -1
- package/src/engine/build-app-schema.ts +55 -2
- package/src/engine/feature-ast/__tests__/patch.test.ts +58 -0
- package/src/engine/feature-ast/patch.ts +22 -2
- package/src/files/__tests__/files.integration.test.ts +2 -2
- package/src/http/__tests__/egress-real-endpoint.integration.test.ts +37 -0
- package/src/http/__tests__/egress.test.ts +440 -0
- package/src/http/__tests__/policy.test.ts +125 -0
- package/src/http/egress.ts +158 -0
- package/src/http/index.ts +2 -0
- 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);
|