@cosmicdrift/kumiko-framework 0.204.1 → 0.206.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 +3 -3
- package/src/db/index.ts +1 -0
- package/src/db/queries/advisory-lock.ts +11 -0
- package/src/engine/__tests__/boot-validator-dashboard.test.ts +7 -0
- package/src/engine/__tests__/boot-validator-projection-list.test.ts +50 -0
- package/src/engine/__tests__/boot-validator-query-refs.test.ts +151 -0
- package/src/engine/__tests__/boot-validator.test.ts +3 -0
- package/src/engine/__tests__/projection-detail-actions.test.ts +3 -0
- package/src/engine/__tests__/screen.test.ts +12 -0
- package/src/engine/boot-validator/index.ts +6 -0
- package/src/engine/boot-validator/projection-list-screens.ts +3 -1
- package/src/engine/boot-validator/query-refs.ts +129 -0
- package/src/engine/boot-validator/screens.ts +20 -9
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-framework",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.206.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.
|
|
193
|
+
"@cosmicdrift/kumiko-types": "0.206.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.
|
|
209
|
+
"@cosmicdrift/kumiko-dispatcher-live": "0.206.0",
|
|
210
210
|
"bun-types": "^1.3.13",
|
|
211
211
|
"pino-pretty": "^13.1.3"
|
|
212
212
|
},
|
package/src/db/index.ts
CHANGED
|
@@ -108,6 +108,7 @@ export {
|
|
|
108
108
|
isUniqueViolation,
|
|
109
109
|
type PgErrorInfo,
|
|
110
110
|
} from "./pg-error";
|
|
111
|
+
export { acquireNamespacedAdvisoryLock } from "./queries/advisory-lock";
|
|
111
112
|
export type { SelectOptions, WhereObject, WhereValue } from "./query-api";
|
|
112
113
|
export {
|
|
113
114
|
asRawClient,
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { AnyDb } from "../query";
|
|
2
|
+
import { asRawClient } from "../query";
|
|
3
|
+
|
|
4
|
+
/** pg_advisory_xact_lock keyed on namespace+key hash — xact-scoped, auto-released at commit/rollback. */
|
|
5
|
+
export async function acquireNamespacedAdvisoryLock(
|
|
6
|
+
db: AnyDb,
|
|
7
|
+
namespace: number,
|
|
8
|
+
key: string,
|
|
9
|
+
): Promise<void> {
|
|
10
|
+
await asRawClient(db).unsafe(`SELECT pg_advisory_xact_lock($1, hashtext($2))`, [namespace, key]);
|
|
11
|
+
}
|
|
@@ -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",
|
|
@@ -188,4 +188,54 @@ describe("validateBoot — projectionList screens", () => {
|
|
|
188
188
|
});
|
|
189
189
|
expect(() => validateBoot([feature])).toThrow(/paginated is derived/);
|
|
190
190
|
});
|
|
191
|
+
|
|
192
|
+
// fw#2164 Nebenbefund: the "at most one rowClick:true" cap (already
|
|
193
|
+
// enforced for entityList) didn't run for projectionList — a boot-time
|
|
194
|
+
// gap, not a rendering one (the renderer only wires the first match, see
|
|
195
|
+
// ProjectionListBody), but two conflicting rowClick actions on the same
|
|
196
|
+
// screen should still fail loud instead of silently picking one.
|
|
197
|
+
describe("projectionList rowAction rowClick", () => {
|
|
198
|
+
function makeFeature(rowClickCount: number) {
|
|
199
|
+
return defineFeature("ledger", (r) => {
|
|
200
|
+
r.queryHandler(
|
|
201
|
+
"schedule:list",
|
|
202
|
+
z.object({}),
|
|
203
|
+
async () => ({ rows: [], nextCursor: null }),
|
|
204
|
+
{
|
|
205
|
+
access: { openToAll: true },
|
|
206
|
+
},
|
|
207
|
+
);
|
|
208
|
+
r.screen({ id: "schedule-detail", type: "custom", renderer: { react: "stub" } });
|
|
209
|
+
r.screen({
|
|
210
|
+
id: "schedule-list",
|
|
211
|
+
type: "projectionList",
|
|
212
|
+
query: "ledger:query:schedule:list",
|
|
213
|
+
columns: ["description"],
|
|
214
|
+
rowActions: Array.from({ length: rowClickCount }, (_, i) => ({
|
|
215
|
+
kind: "navigate" as const,
|
|
216
|
+
id: `open-${i}`,
|
|
217
|
+
label: "actions.open",
|
|
218
|
+
screen: "schedule-detail",
|
|
219
|
+
rowClick: true,
|
|
220
|
+
})),
|
|
221
|
+
});
|
|
222
|
+
r.translations({
|
|
223
|
+
keys: {
|
|
224
|
+
"screen:schedule-list.title": { de: "Liste", en: "List" },
|
|
225
|
+
"screen:schedule-detail.title": { de: "Detail", en: "Detail" },
|
|
226
|
+
},
|
|
227
|
+
});
|
|
228
|
+
});
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
test("one rowClick navigate action passes boot", () => {
|
|
232
|
+
expect(() => validateBoot([makeFeature(1)])).not.toThrow();
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
test("more than one rowClick action per list is rejected", () => {
|
|
236
|
+
expect(() => validateBoot([makeFeature(2)])).toThrow(
|
|
237
|
+
/at most one may fire on a row-body click/i,
|
|
238
|
+
);
|
|
239
|
+
});
|
|
240
|
+
});
|
|
191
241
|
});
|
|
@@ -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
|
-
|
|
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
|
+
}
|
|
@@ -25,6 +25,24 @@ import type {
|
|
|
25
25
|
ToolbarAction,
|
|
26
26
|
} from "../types/screen";
|
|
27
27
|
|
|
28
|
+
// entityList and projectionList both allow a rowAction to double as the
|
|
29
|
+
// row-body click target (rowClick: true, fw#1708/#2164) — at most one per
|
|
30
|
+
// screen, or the renderer can't tell which one should fire.
|
|
31
|
+
function validateAtMostOneRowClick(
|
|
32
|
+
featureName: string,
|
|
33
|
+
screenId: string,
|
|
34
|
+
screenType: "entityList" | "projectionList",
|
|
35
|
+
rowActions: readonly RowAction[],
|
|
36
|
+
): void {
|
|
37
|
+
const rowClickActions = rowActions.filter((a) => a.kind === "navigate" && a.rowClick === true);
|
|
38
|
+
if (rowClickActions.length > 1) {
|
|
39
|
+
throw new Error(
|
|
40
|
+
`[Feature ${featureName}] Screen "${screenId}" (${screenType}) has ${rowClickActions.length} ` +
|
|
41
|
+
"rowActions marked rowClick:true — at most one may fire on a row-body click.",
|
|
42
|
+
);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
28
46
|
// A field type in NO_WIDGET_FIELD_TYPES renders read-only on the auto-wired
|
|
29
47
|
// entityEdit path (#1925) — a required field the user can never fill would
|
|
30
48
|
// block every save. Only the statically-resolvable case is caught here: a
|
|
@@ -354,6 +372,7 @@ export function validateScreens(
|
|
|
354
372
|
);
|
|
355
373
|
}
|
|
356
374
|
}
|
|
375
|
+
validateAtMostOneRowClick(feature.name, screenId, "projectionList", screen.rowActions);
|
|
357
376
|
}
|
|
358
377
|
continue;
|
|
359
378
|
}
|
|
@@ -889,15 +908,7 @@ export function validateScreens(
|
|
|
889
908
|
rowMeta,
|
|
890
909
|
);
|
|
891
910
|
}
|
|
892
|
-
|
|
893
|
-
(a) => a.kind === "navigate" && a.rowClick === true,
|
|
894
|
-
);
|
|
895
|
-
if (rowClickActions.length > 1) {
|
|
896
|
-
throw new Error(
|
|
897
|
-
`[Feature ${feature.name}] Screen "${screenId}" (entityList) has ${rowClickActions.length} ` +
|
|
898
|
-
"rowActions marked rowClick:true — at most one may fire on a row-body click.",
|
|
899
|
-
);
|
|
900
|
-
}
|
|
911
|
+
validateAtMostOneRowClick(feature.name, screenId, "entityList", screen.rowActions);
|
|
901
912
|
}
|
|
902
913
|
// Tier 2.7e-2: toolbarActions — analog zu rowActions, aber bisher
|
|
903
914
|
// ohne Validator. Typo'd navigate-targets und unregistrierte
|