@cosmicdrift/kumiko-framework 0.146.4 → 0.147.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 +6 -2
- package/src/api/auth-routes.ts +32 -67
- package/src/api/routes.ts +1 -3
- package/src/bun-db/__tests__/PATTERN.md +0 -1
- package/src/db/__tests__/assert-no-unreachable-live-rows.integration.test.ts +29 -3
- package/src/db/__tests__/schema-inspection.test.ts +7 -0
- package/src/db/event-store-executor-context.ts +398 -0
- package/src/db/event-store-executor-read.ts +276 -0
- package/src/db/event-store-executor-write.ts +615 -0
- package/src/db/event-store-executor.ts +29 -1166
- package/src/db/queries/shadow-swap.ts +3 -1
- package/src/db/schema-inspection.ts +1 -1
- package/src/engine/__tests__/boot-validator-dashboard.test.ts +10 -0
- package/src/engine/__tests__/engine.test.ts +45 -1
- package/src/engine/__tests__/event-migration-declarative.test.ts +6 -0
- package/src/engine/__tests__/membership-roles.test.ts +4 -10
- package/src/engine/__tests__/screen.test.ts +26 -0
- package/src/engine/boot-validator/entity-list-screens.ts +1 -1
- package/src/engine/boot-validator/gdpr-storage.ts +1 -1
- package/src/engine/boot-validator/index.ts +12 -8
- package/src/engine/boot-validator/nav.ts +120 -0
- package/src/engine/boot-validator/{screens-nav.ts → screens.ts} +12 -190
- package/src/engine/boot-validator/workspaces.ts +68 -0
- package/src/engine/define-feature.ts +77 -1011
- package/src/engine/feature-ast/__tests__/fixtures/cross-file-registrar/feature.ts +9 -0
- package/src/engine/feature-ast/__tests__/fixtures/cross-file-registrar/screens.ts +4 -0
- package/src/engine/feature-ast/__tests__/parse-real-features.test.ts +18 -8
- package/src/engine/feature-ast/__tests__/parse.test.ts +291 -2
- package/src/engine/feature-ast/__tests__/patcher.test.ts +1 -1
- package/src/engine/feature-ast/__tests__/render-roundtrip.test.ts +73 -0
- package/src/engine/feature-ast/extractors/round1.ts +3 -3
- package/src/engine/feature-ast/extractors/round4.ts +45 -10
- package/src/engine/feature-ast/extractors/shared.ts +64 -6
- package/src/engine/feature-ast/parse.ts +123 -10
- package/src/engine/feature-ast/patterns.ts +14 -7
- package/src/engine/feature-ast/render.ts +28 -7
- package/src/engine/feature-builder-state.ts +165 -0
- package/src/engine/feature-config-events-jobs.ts +303 -0
- package/src/engine/feature-entity-handlers.ts +161 -0
- package/src/engine/feature-ui-extensions.ts +413 -0
- package/src/engine/index.ts +0 -2
- package/src/engine/pattern-library/library.ts +44 -1131
- package/src/engine/pattern-library/mixed-schemas.ts +484 -0
- package/src/engine/pattern-library/opaque-schemas.ts +124 -0
- package/src/engine/pattern-library/shared-fields.ts +80 -0
- package/src/engine/pattern-library/static-schemas.ts +456 -0
- package/src/engine/registry-facade.ts +349 -0
- package/src/engine/registry-ingest.ts +473 -0
- package/src/engine/registry-state.ts +388 -0
- package/src/engine/registry-validate.ts +660 -0
- package/src/engine/registry.ts +79 -1695
- package/src/engine/types/screen.ts +4 -2
- package/src/engine/types/tree-node.ts +2 -5
- package/src/event-store/snapshot.ts +7 -7
- package/src/i18n/required-surface-keys.ts +2 -0
- package/src/jobs/job-runner.ts +1 -1
- package/src/observability/standard-metrics.ts +4 -3
- package/src/pipeline/dispatch-batch.ts +3 -3
- package/src/pipeline/dispatch-shared.ts +17 -10
- package/src/pipeline/event-dispatcher-admin.ts +289 -0
- package/src/pipeline/event-dispatcher-delivery.ts +264 -0
- package/src/pipeline/event-dispatcher.ts +28 -540
- package/src/pipeline/projection-rebuild.ts +1 -1
- package/src/stack/__tests__/setup-test-stack-jobs.integration.test.ts +95 -0
- package/src/stack/test-stack.ts +46 -1
- package/src/testing/boot-validator-fixture.ts +1 -2
- package/src/engine/__tests__/deep-link.test.ts +0 -35
- package/src/engine/deep-link.ts +0 -23
|
@@ -201,8 +201,10 @@ export async function assertNoUnreachableLiveRows(
|
|
|
201
201
|
// skip: every live row has a backing event — nothing unreachable, swap is safe
|
|
202
202
|
if (ghosts.length === 0) return;
|
|
203
203
|
const ids = ghosts.map((r) => String(r.id));
|
|
204
|
+
const countLabel =
|
|
205
|
+
ids.length === UNREACHABLE_SAMPLE_LIMIT ? `${ids.length}+` : String(ids.length);
|
|
204
206
|
throw new Error(
|
|
205
|
-
`projection-rebuild "${projectionName}": ${
|
|
207
|
+
`projection-rebuild "${projectionName}": ${countLabel} live rows in "${tableName}" have no ` +
|
|
206
208
|
`event in the projection's source streams and cannot be reconstructed by replay — the swap ` +
|
|
207
209
|
`would silently drop them (ids: ${ids.join(", ")}). A handler direct-inserted these rows ` +
|
|
208
210
|
`without emitting a .created event. Fix: register the table with r.unmanagedTable(meta, ` +
|
|
@@ -13,7 +13,7 @@ function resolveUnsafeClient(db: DbConnection | DbTx): UnsafeFn {
|
|
|
13
13
|
};
|
|
14
14
|
const client = dbAny.$client ?? dbAny.session?.client ?? dbAny;
|
|
15
15
|
const fn = (client as { unsafe?: UnsafeFn }).unsafe;
|
|
16
|
-
if (fn
|
|
16
|
+
if (typeof fn !== "function") {
|
|
17
17
|
throw new Error(
|
|
18
18
|
"resolveUnsafeClient: no `.unsafe(sql, params)` fn on the db connection " +
|
|
19
19
|
"(checked $client, session.client, db itself) — schema-inspection needs the raw postgres escape hatch.",
|
|
@@ -193,6 +193,16 @@ describe("validateBoot — dashboard screens", () => {
|
|
|
193
193
|
expect(() => validateBoot([feature])).toThrow(/filter.options is empty/);
|
|
194
194
|
});
|
|
195
195
|
|
|
196
|
+
test("rejects a filter with an empty optionsQuery", () => {
|
|
197
|
+
const feature = dashboardFeature([STAT_PANEL], {
|
|
198
|
+
id: "region",
|
|
199
|
+
label: "demo:dashboard:filter:region",
|
|
200
|
+
kind: "select",
|
|
201
|
+
optionsQuery: "",
|
|
202
|
+
});
|
|
203
|
+
expect(() => validateBoot([feature])).toThrow(/filter.optionsQuery is empty/);
|
|
204
|
+
});
|
|
205
|
+
|
|
196
206
|
test("requiredKeysFromScreen sammelt stat-group-Kinder- und Filter-Labels, aber keine custom-Panel-Keys", () => {
|
|
197
207
|
const screen: DashboardScreenDefinition = {
|
|
198
208
|
id: "overview",
|
|
@@ -41,7 +41,9 @@ describe("defineFeature", () => {
|
|
|
41
41
|
r.describe("first");
|
|
42
42
|
r.describe("second");
|
|
43
43
|
}),
|
|
44
|
-
).toThrow(
|
|
44
|
+
).toThrow(
|
|
45
|
+
"[Feature test] r.describe() called twice — a feature's description is declared once",
|
|
46
|
+
);
|
|
45
47
|
});
|
|
46
48
|
|
|
47
49
|
test("r.describe() throws on empty or whitespace-only text", () => {
|
|
@@ -274,6 +276,24 @@ describe("defineFeature", () => {
|
|
|
274
276
|
}),
|
|
275
277
|
).toThrow(/r\.uiHints\(\) called twice/);
|
|
276
278
|
});
|
|
279
|
+
|
|
280
|
+
test("r.toggleable() throws when called twice", () => {
|
|
281
|
+
expect(() =>
|
|
282
|
+
defineFeature("test", (r) => {
|
|
283
|
+
r.toggleable({ default: true });
|
|
284
|
+
r.toggleable({ default: false });
|
|
285
|
+
}),
|
|
286
|
+
).toThrow(/r\.toggleable\(\) called twice/);
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
test("r.envSchema() throws when called twice", () => {
|
|
290
|
+
expect(() =>
|
|
291
|
+
defineFeature("test", (r) => {
|
|
292
|
+
r.envSchema(z.object({ FOO: z.string() }));
|
|
293
|
+
r.envSchema(z.object({ BAR: z.string() }));
|
|
294
|
+
}),
|
|
295
|
+
).toThrow(/r\.envSchema\(\) called twice/);
|
|
296
|
+
});
|
|
277
297
|
});
|
|
278
298
|
|
|
279
299
|
// --- Field Factories ---
|
|
@@ -1325,6 +1345,30 @@ describe("registry relations", () => {
|
|
|
1325
1345
|
// Last write wins
|
|
1326
1346
|
expect((rels["roles"] as { through: { table: string } }).through.table).toBe("UR2");
|
|
1327
1347
|
});
|
|
1348
|
+
|
|
1349
|
+
test("getRelations aggregates relations on the same entity from two different features", () => {
|
|
1350
|
+
const core = defineFeature("core", (r) => {
|
|
1351
|
+
r.entity("user", createEntity({ table: "Users", fields: {} }));
|
|
1352
|
+
r.entity("role", createEntity({ table: "Roles", fields: {} }));
|
|
1353
|
+
r.relation("user", "roles", {
|
|
1354
|
+
type: "manyToMany",
|
|
1355
|
+
target: "role",
|
|
1356
|
+
through: { table: "UserRoles", sourceKey: "userId", targetKey: "roleId" },
|
|
1357
|
+
});
|
|
1358
|
+
});
|
|
1359
|
+
const billing = defineFeature("billing", (r) => {
|
|
1360
|
+
r.entity("invoice", createEntity({ table: "Invoices", fields: {} }));
|
|
1361
|
+
r.relation("user", "invoices", {
|
|
1362
|
+
type: "hasMany",
|
|
1363
|
+
target: "invoice",
|
|
1364
|
+
foreignKey: "userId",
|
|
1365
|
+
});
|
|
1366
|
+
});
|
|
1367
|
+
|
|
1368
|
+
const registry = createRegistry([core, billing]);
|
|
1369
|
+
const rels = registry.getRelations("user");
|
|
1370
|
+
expect(Object.keys(rels).sort()).toEqual(["invoices", "roles"]);
|
|
1371
|
+
});
|
|
1328
1372
|
});
|
|
1329
1373
|
|
|
1330
1374
|
// --- Global Search (new tenant-based API) ---
|
|
@@ -42,6 +42,12 @@ describe("declarative eventMigration", () => {
|
|
|
42
42
|
expect(() => run([1, 2])).toThrow(/object payload/);
|
|
43
43
|
});
|
|
44
44
|
|
|
45
|
+
test("two rename sources mapping to the same target fail loud at registration", () => {
|
|
46
|
+
expect(() => compile({ rename: { amount: "total", subtotal: "total" } })).toThrow(
|
|
47
|
+
/rename collision.*"total"/,
|
|
48
|
+
);
|
|
49
|
+
});
|
|
50
|
+
|
|
45
51
|
test("imperative function variant is stored untouched", () => {
|
|
46
52
|
const fn = (payload: unknown) => payload;
|
|
47
53
|
const feature = defineFeature("billing", (r) => {
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { describe, expect, test } from "bun:test";
|
|
2
2
|
import {
|
|
3
|
+
buildSessionRoles,
|
|
3
4
|
FORBIDDEN_MEMBERSHIP_ROLES,
|
|
4
5
|
findForbiddenMembershipRole,
|
|
5
6
|
isForbiddenMembershipRole,
|
|
@@ -41,23 +42,16 @@ describe("forbidden membership roles", () => {
|
|
|
41
42
|
// ONLY the membership portion, never the merged result — so a legitimate
|
|
42
43
|
// SystemAdmin in globalRoles survives, a resurrected one in membership does not.
|
|
43
44
|
describe("merge semantics (globalRoles never filtered)", () => {
|
|
44
|
-
function merge(
|
|
45
|
-
globalRoles: readonly string[],
|
|
46
|
-
membershipRoles: readonly string[],
|
|
47
|
-
): readonly string[] {
|
|
48
|
-
return Array.from(new Set([...globalRoles, ...stripForbiddenMembershipRoles(membershipRoles)]));
|
|
49
|
-
}
|
|
50
|
-
|
|
51
45
|
test("global SystemAdmin survives (no regression for real admins)", () => {
|
|
52
|
-
expect(
|
|
46
|
+
expect(buildSessionRoles(["SystemAdmin"], [])).toContain("SystemAdmin");
|
|
53
47
|
});
|
|
54
48
|
|
|
55
49
|
test("membership SystemAdmin is stripped (resurrected role neutralised)", () => {
|
|
56
|
-
expect(
|
|
50
|
+
expect(buildSessionRoles([], ["SystemAdmin"])).not.toContain("SystemAdmin");
|
|
57
51
|
});
|
|
58
52
|
|
|
59
53
|
test("global admin + tenant membership keeps both, deduped", () => {
|
|
60
|
-
expect([...
|
|
54
|
+
expect([...buildSessionRoles(["SystemAdmin"], ["Admin", "SystemAdmin"])].sort()).toEqual(
|
|
61
55
|
["Admin", "SystemAdmin"].sort(),
|
|
62
56
|
);
|
|
63
57
|
});
|
|
@@ -264,6 +264,32 @@ describe("createRegistry — screen indexing", () => {
|
|
|
264
264
|
]);
|
|
265
265
|
});
|
|
266
266
|
|
|
267
|
+
test("getScreensByEntity aggregates screens on the same entity from two different features", () => {
|
|
268
|
+
const shop = defineFeature("shop", (r) => {
|
|
269
|
+
r.entity("product", productEntity());
|
|
270
|
+
r.screen({
|
|
271
|
+
id: "product-list",
|
|
272
|
+
type: "entityList",
|
|
273
|
+
entity: "product",
|
|
274
|
+
columns: ["name"],
|
|
275
|
+
});
|
|
276
|
+
});
|
|
277
|
+
const reporting = defineFeature("reporting", (r) => {
|
|
278
|
+
r.screen({
|
|
279
|
+
id: "product-stats",
|
|
280
|
+
type: "entityList",
|
|
281
|
+
entity: "product",
|
|
282
|
+
columns: ["name"],
|
|
283
|
+
});
|
|
284
|
+
});
|
|
285
|
+
const registry = createRegistry([shop, reporting]);
|
|
286
|
+
const byProduct = registry.getScreensByEntity("product");
|
|
287
|
+
expect(byProduct.map((s) => s.id).sort()).toEqual([
|
|
288
|
+
"reporting:screen:product-stats",
|
|
289
|
+
"shop:screen:product-list",
|
|
290
|
+
]);
|
|
291
|
+
});
|
|
292
|
+
|
|
267
293
|
test("getScreen / getScreensByEntity return stored screens with qualified id", () => {
|
|
268
294
|
const feature = defineFeature("shop", (r) => {
|
|
269
295
|
r.entity("product", productEntity());
|
|
@@ -2,7 +2,7 @@ import type { EntityListScreenDefinition, FeatureDefinition } from "../types";
|
|
|
2
2
|
import { normalizeListColumn } from "../types/screen";
|
|
3
3
|
|
|
4
4
|
/** Operator lists default searchable; low-cardinality audit trails stay opt-out. */
|
|
5
|
-
const SEARCHABLE_FALSE_WHITELIST = new Set(["download-attempt-list"]);
|
|
5
|
+
export const SEARCHABLE_FALSE_WHITELIST = new Set(["download-attempt-list"]);
|
|
6
6
|
|
|
7
7
|
function hasFilterableFields(feature: FeatureDefinition, entityName: string): boolean {
|
|
8
8
|
const entities = feature.entities;
|
|
@@ -12,7 +12,7 @@ function entitiesOf(
|
|
|
12
12
|
feature: FeatureDefinition,
|
|
13
13
|
): ReadonlyArray<readonly [string, EntityDefinition]> {
|
|
14
14
|
const fromEntities = Object.entries(feature.entities ?? {});
|
|
15
|
-
const fromProjections = Object.values(feature.projections)
|
|
15
|
+
const fromProjections = Object.values(feature.projections ?? {})
|
|
16
16
|
.filter((p): p is typeof p & { entity: EntityDefinition } => p.entity !== undefined)
|
|
17
17
|
.map((p) => [p.name, p.entity] as const);
|
|
18
18
|
return [...fromEntities, ...fromProjections];
|
|
@@ -35,28 +35,32 @@ import {
|
|
|
35
35
|
validateTenantDataHookCoverage,
|
|
36
36
|
} from "./gdpr-storage";
|
|
37
37
|
import { validateI18nSurfaceKeys } from "./i18n-keys";
|
|
38
|
-
import { validateOwnershipRules } from "./ownership";
|
|
39
|
-
import { validatePiiAndRetention } from "./pii-retention";
|
|
40
38
|
import {
|
|
41
39
|
collectKnownRoles,
|
|
42
40
|
collectNavQns,
|
|
43
|
-
collectScreenQns,
|
|
44
|
-
collectScreensByShortId,
|
|
45
|
-
collectWorkspaceQns,
|
|
46
41
|
collectWriteHandlerQns,
|
|
47
|
-
validateDefaultWorkspaceUniqueness,
|
|
48
42
|
validateNavCycles,
|
|
49
43
|
validateNavs,
|
|
44
|
+
} from "./nav";
|
|
45
|
+
import { validateOwnershipRules } from "./ownership";
|
|
46
|
+
import { validatePiiAndRetention } from "./pii-retention";
|
|
47
|
+
import {
|
|
48
|
+
collectScreenQns,
|
|
49
|
+
collectScreensByShortId,
|
|
50
50
|
validateScreenShortIdCollisions,
|
|
51
51
|
validateScreens,
|
|
52
|
+
} from "./screens";
|
|
53
|
+
import {
|
|
54
|
+
collectWorkspaceQns,
|
|
55
|
+
validateDefaultWorkspaceUniqueness,
|
|
52
56
|
validateWorkspaces,
|
|
53
|
-
} from "./
|
|
57
|
+
} from "./workspaces";
|
|
54
58
|
|
|
55
59
|
export { validateAppCustomScreenWriteQns } from "./custom-screen-write-qns";
|
|
56
60
|
// Re-export: wird von run-dev-app.ts benötigt um Write-Handler-QNs
|
|
57
61
|
// an den Codegen zu übergeben. Nicht Teil von validateBoot, aber
|
|
58
62
|
// dieselbe Extraktionslogik.
|
|
59
|
-
export { collectWriteHandlerQns } from "./
|
|
63
|
+
export { collectWriteHandlerQns } from "./nav";
|
|
60
64
|
|
|
61
65
|
/**
|
|
62
66
|
* Validates all feature configurations at boot time.
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
// Nav validation + collectKnownRoles (role synthesis for ownership
|
|
2
|
+
// validation in index.ts — thematically adjacent to ownership.ts, kept
|
|
3
|
+
// here since this is a structural move, not a semantic reorg).
|
|
4
|
+
|
|
5
|
+
import { qualifyEntityName } from "../qualified-name";
|
|
6
|
+
import type { FeatureDefinition, NavDefinition, WorkspaceDefinition } from "../types";
|
|
7
|
+
|
|
8
|
+
export function collectWriteHandlerQns(features: readonly FeatureDefinition[]): Set<string> {
|
|
9
|
+
const set = new Set<string>();
|
|
10
|
+
for (const f of features) {
|
|
11
|
+
for (const handlerName of Object.keys(f.writeHandlers)) {
|
|
12
|
+
set.add(qualifyEntityName(f.name, "write", handlerName));
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
return set;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function collectNavQns(
|
|
19
|
+
features: readonly FeatureDefinition[],
|
|
20
|
+
): Map<string, NavDefinition & { readonly featureName: string }> {
|
|
21
|
+
const map = new Map<string, NavDefinition & { readonly featureName: string }>();
|
|
22
|
+
for (const f of features) {
|
|
23
|
+
for (const [navId, navDef] of Object.entries(f.navs)) {
|
|
24
|
+
const qualified = qualifyEntityName(f.name, "nav", navId);
|
|
25
|
+
map.set(qualified, { ...navDef, featureName: f.name });
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
return map;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// Per-feature ref validation: screen + parent refs point at real QNs. Cycle
|
|
32
|
+
// detection runs once globally afterwards (it's cheaper to do a single DFS
|
|
33
|
+
// over the merged graph than restart it per feature).
|
|
34
|
+
export function validateNavs(
|
|
35
|
+
feature: FeatureDefinition,
|
|
36
|
+
allScreenQns: ReadonlySet<string>,
|
|
37
|
+
allNavQns: ReadonlyMap<string, NavDefinition & { readonly featureName: string }>,
|
|
38
|
+
allWorkspaceQns: ReadonlyMap<string, WorkspaceDefinition & { readonly featureName: string }>,
|
|
39
|
+
): void {
|
|
40
|
+
for (const [navId, navDef] of Object.entries(feature.navs)) {
|
|
41
|
+
if (navDef.screen !== undefined && !allScreenQns.has(navDef.screen)) {
|
|
42
|
+
throw new Error(
|
|
43
|
+
`[Feature ${feature.name}] Nav entry "${navId}" references screen "${navDef.screen}" ` +
|
|
44
|
+
`which is not registered. Expected a qualified name of the form ` +
|
|
45
|
+
`"<feature>:screen:<id>" pointing at an r.screen() declaration.`,
|
|
46
|
+
);
|
|
47
|
+
}
|
|
48
|
+
if (navDef.parent !== undefined && !allNavQns.has(navDef.parent)) {
|
|
49
|
+
throw new Error(
|
|
50
|
+
`[Feature ${feature.name}] Nav entry "${navId}" references parent "${navDef.parent}" ` +
|
|
51
|
+
`which is not a registered nav entry. Expected a qualified name of the form ` +
|
|
52
|
+
`"<feature>:nav:<id>".`,
|
|
53
|
+
);
|
|
54
|
+
}
|
|
55
|
+
if (navDef.workspaces !== undefined) {
|
|
56
|
+
for (const wsQn of navDef.workspaces) {
|
|
57
|
+
if (!allWorkspaceQns.has(wsQn)) {
|
|
58
|
+
throw new Error(
|
|
59
|
+
`[Feature ${feature.name}] Nav entry "${navId}" self-assigns to workspace "${wsQn}" ` +
|
|
60
|
+
`which is not registered. Expected a qualified name of the form ` +
|
|
61
|
+
`"<feature>:workspace:<id>" pointing at an r.workspace() declaration.`,
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// Walks parent-refs across ALL nav entries (cross-feature). A cycle here
|
|
70
|
+
// would crash client-side tree assembly — easier to fail loud at boot than
|
|
71
|
+
// to debug a React "Maximum update depth exceeded" stack trace.
|
|
72
|
+
export function validateNavCycles(
|
|
73
|
+
allNavQns: ReadonlyMap<string, NavDefinition & { readonly featureName: string }>,
|
|
74
|
+
): void {
|
|
75
|
+
const visited = new Set<string>();
|
|
76
|
+
const stack = new Set<string>();
|
|
77
|
+
|
|
78
|
+
function visit(qualified: string, path: string[]): void {
|
|
79
|
+
if (stack.has(qualified)) {
|
|
80
|
+
throw new Error(
|
|
81
|
+
`[Kumiko Nav] Nav entry parent cycle detected: ${[...path, qualified].join(" → ")}`,
|
|
82
|
+
);
|
|
83
|
+
}
|
|
84
|
+
// skip: already visited — cycle-detection only needs to traverse each
|
|
85
|
+
// node once, and the `stack` check above catches any actual cycles
|
|
86
|
+
// reached via a different path.
|
|
87
|
+
if (visited.has(qualified)) return;
|
|
88
|
+
visited.add(qualified);
|
|
89
|
+
stack.add(qualified);
|
|
90
|
+
const navDef = allNavQns.get(qualified);
|
|
91
|
+
if (navDef?.parent) {
|
|
92
|
+
visit(navDef.parent, [...path, qualified]);
|
|
93
|
+
}
|
|
94
|
+
stack.delete(qualified);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
for (const qualified of allNavQns.keys()) {
|
|
98
|
+
visit(qualified, []);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// Roles we recognise at boot time. The framework has no explicit
|
|
103
|
+
// role-registry (r.defineRoles is a type helper only), so we synthesise
|
|
104
|
+
// one from every handler-access rule plus the "all"/"system" built-ins.
|
|
105
|
+
export function collectKnownRoles(features: readonly FeatureDefinition[]): Set<string> {
|
|
106
|
+
const roles = new Set<string>(["all", "system"]);
|
|
107
|
+
for (const f of features) {
|
|
108
|
+
for (const def of Object.values(f.writeHandlers)) {
|
|
109
|
+
if (def.access && "roles" in def.access) {
|
|
110
|
+
for (const r of def.access.roles) roles.add(r);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
for (const def of Object.values(f.queryHandlers)) {
|
|
114
|
+
if (def.access && "roles" in def.access) {
|
|
115
|
+
for (const r of def.access.roles) roles.add(r);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
return roles;
|
|
120
|
+
}
|
|
@@ -1,8 +1,13 @@
|
|
|
1
|
+
// Screen validation, including the dashboard-panel sub-validators.
|
|
2
|
+
// Dashboard stays here (not a separate dashboard.ts) — validateScreens
|
|
3
|
+
// calls validateDashboardScreen and the dashboard validators call back
|
|
4
|
+
// into validateColumnRendererForm, splitting them would create a
|
|
5
|
+
// same-folder require cycle.
|
|
6
|
+
|
|
1
7
|
import { rowMetaFieldNames } from "../../db/table-builder";
|
|
2
|
-
import { SETTINGS_HUB_AUDIENCE_NAV_QNS } from "../build-config-feature-schema";
|
|
3
8
|
import { qualifyEntityName } from "../qualified-name";
|
|
4
9
|
import { getAllowedFilterOps, isFieldFilterable } from "../screen-filter-ops";
|
|
5
|
-
import type { FeatureDefinition
|
|
10
|
+
import type { FeatureDefinition } from "../types";
|
|
6
11
|
import type {
|
|
7
12
|
DashboardCustomPanel,
|
|
8
13
|
DashboardFilterDefinition,
|
|
@@ -779,6 +784,11 @@ function validateDashboardFilterDefinition(
|
|
|
779
784
|
`declare at least one option or use optionsQuery instead.`,
|
|
780
785
|
);
|
|
781
786
|
}
|
|
787
|
+
if (hasOptionsQuery && filter.optionsQuery?.length === 0) {
|
|
788
|
+
throw new Error(
|
|
789
|
+
`[Feature ${featureName}] Screen "${screenId}" (dashboard) filter.optionsQuery is empty.`,
|
|
790
|
+
);
|
|
791
|
+
}
|
|
782
792
|
}
|
|
783
793
|
|
|
784
794
|
// Form-check für ListColumn-Renderer in der PlatformComponent-Form
|
|
@@ -846,13 +856,6 @@ export function buildUnknownFieldMessage(
|
|
|
846
856
|
);
|
|
847
857
|
}
|
|
848
858
|
|
|
849
|
-
// --- Nav validation ---
|
|
850
|
-
//
|
|
851
|
-
// The boot-validator runs BEFORE createRegistry builds the final maps, so we
|
|
852
|
-
// pre-build the qualified name sets for screens + navs here. `qualifyEntityName`
|
|
853
|
-
// is the shared helper with the registry — changing the qualification rule
|
|
854
|
-
// in one place flows through both ingest paths.
|
|
855
|
-
|
|
856
859
|
export function collectScreenQns(features: readonly FeatureDefinition[]): Set<string> {
|
|
857
860
|
const set = new Set<string>();
|
|
858
861
|
for (const f of features) {
|
|
@@ -899,184 +902,3 @@ export function collectScreensByShortId(
|
|
|
899
902
|
}
|
|
900
903
|
return map;
|
|
901
904
|
}
|
|
902
|
-
|
|
903
|
-
// Sammelt alle qualifizierten Write-Handler-QNs (`<feature>:write:<short>`).
|
|
904
|
-
// Wird vom actionForm-Screen-Validator genutzt um zu prüfen ob der
|
|
905
|
-
// im Schema deklarierte handler tatsächlich registriert ist —
|
|
906
|
-
// Tippfehler/umbenannte Handler fallen sonst erst zur Laufzeit auf.
|
|
907
|
-
export function collectWriteHandlerQns(features: readonly FeatureDefinition[]): Set<string> {
|
|
908
|
-
const set = new Set<string>();
|
|
909
|
-
for (const f of features) {
|
|
910
|
-
for (const handlerName of Object.keys(f.writeHandlers)) {
|
|
911
|
-
set.add(qualifyEntityName(f.name, "write", handlerName));
|
|
912
|
-
}
|
|
913
|
-
}
|
|
914
|
-
return set;
|
|
915
|
-
}
|
|
916
|
-
|
|
917
|
-
export function collectNavQns(
|
|
918
|
-
features: readonly FeatureDefinition[],
|
|
919
|
-
): Map<string, NavDefinition & { readonly featureName: string }> {
|
|
920
|
-
const map = new Map<string, NavDefinition & { readonly featureName: string }>();
|
|
921
|
-
for (const f of features) {
|
|
922
|
-
for (const [navId, navDef] of Object.entries(f.navs)) {
|
|
923
|
-
const qualified = qualifyEntityName(f.name, "nav", navId);
|
|
924
|
-
map.set(qualified, { ...navDef, featureName: f.name });
|
|
925
|
-
}
|
|
926
|
-
}
|
|
927
|
-
return map;
|
|
928
|
-
}
|
|
929
|
-
|
|
930
|
-
// Per-feature ref validation: screen + parent refs point at real QNs. Cycle
|
|
931
|
-
// detection runs once globally afterwards (it's cheaper to do a single DFS
|
|
932
|
-
// over the merged graph than restart it per feature).
|
|
933
|
-
export function validateNavs(
|
|
934
|
-
feature: FeatureDefinition,
|
|
935
|
-
allScreenQns: ReadonlySet<string>,
|
|
936
|
-
allNavQns: ReadonlyMap<string, NavDefinition & { readonly featureName: string }>,
|
|
937
|
-
allWorkspaceQns: ReadonlyMap<string, WorkspaceDefinition & { readonly featureName: string }>,
|
|
938
|
-
): void {
|
|
939
|
-
for (const [navId, navDef] of Object.entries(feature.navs)) {
|
|
940
|
-
if (navDef.screen !== undefined && !allScreenQns.has(navDef.screen)) {
|
|
941
|
-
throw new Error(
|
|
942
|
-
`[Feature ${feature.name}] Nav entry "${navId}" references screen "${navDef.screen}" ` +
|
|
943
|
-
`which is not registered. Expected a qualified name of the form ` +
|
|
944
|
-
`"<feature>:screen:<id>" pointing at an r.screen() declaration.`,
|
|
945
|
-
);
|
|
946
|
-
}
|
|
947
|
-
if (navDef.parent !== undefined && !allNavQns.has(navDef.parent)) {
|
|
948
|
-
throw new Error(
|
|
949
|
-
`[Feature ${feature.name}] Nav entry "${navId}" references parent "${navDef.parent}" ` +
|
|
950
|
-
`which is not a registered nav entry. Expected a qualified name of the form ` +
|
|
951
|
-
`"<feature>:nav:<id>".`,
|
|
952
|
-
);
|
|
953
|
-
}
|
|
954
|
-
if (navDef.workspaces !== undefined) {
|
|
955
|
-
for (const wsQn of navDef.workspaces) {
|
|
956
|
-
if (!allWorkspaceQns.has(wsQn)) {
|
|
957
|
-
throw new Error(
|
|
958
|
-
`[Feature ${feature.name}] Nav entry "${navId}" self-assigns to workspace "${wsQn}" ` +
|
|
959
|
-
`which is not registered. Expected a qualified name of the form ` +
|
|
960
|
-
`"<feature>:workspace:<id>" pointing at an r.workspace() declaration.`,
|
|
961
|
-
);
|
|
962
|
-
}
|
|
963
|
-
}
|
|
964
|
-
}
|
|
965
|
-
}
|
|
966
|
-
}
|
|
967
|
-
|
|
968
|
-
// Walks parent-refs across ALL nav entries (cross-feature). A cycle here
|
|
969
|
-
// would crash client-side tree assembly — easier to fail loud at boot than
|
|
970
|
-
// to debug a React "Maximum update depth exceeded" stack trace.
|
|
971
|
-
export function validateNavCycles(
|
|
972
|
-
allNavQns: ReadonlyMap<string, NavDefinition & { readonly featureName: string }>,
|
|
973
|
-
): void {
|
|
974
|
-
const visited = new Set<string>();
|
|
975
|
-
const stack = new Set<string>();
|
|
976
|
-
|
|
977
|
-
function visit(qualified: string, path: string[]): void {
|
|
978
|
-
if (stack.has(qualified)) {
|
|
979
|
-
throw new Error(
|
|
980
|
-
`[Kumiko Nav] Nav entry parent cycle detected: ${[...path, qualified].join(" → ")}`,
|
|
981
|
-
);
|
|
982
|
-
}
|
|
983
|
-
// skip: already visited — cycle-detection only needs to traverse each
|
|
984
|
-
// node once, and the `stack` check above catches any actual cycles
|
|
985
|
-
// reached via a different path.
|
|
986
|
-
if (visited.has(qualified)) return;
|
|
987
|
-
visited.add(qualified);
|
|
988
|
-
stack.add(qualified);
|
|
989
|
-
const navDef = allNavQns.get(qualified);
|
|
990
|
-
if (navDef?.parent) {
|
|
991
|
-
visit(navDef.parent, [...path, qualified]);
|
|
992
|
-
}
|
|
993
|
-
stack.delete(qualified);
|
|
994
|
-
}
|
|
995
|
-
|
|
996
|
-
for (const qualified of allNavQns.keys()) {
|
|
997
|
-
visit(qualified, []);
|
|
998
|
-
}
|
|
999
|
-
}
|
|
1000
|
-
|
|
1001
|
-
// Roles we recognise at boot time. The framework has no explicit
|
|
1002
|
-
// role-registry (r.defineRoles is a type helper only), so we synthesise
|
|
1003
|
-
// one from every handler-access rule plus the "all"/"system" built-ins.
|
|
1004
|
-
export function collectKnownRoles(features: readonly FeatureDefinition[]): Set<string> {
|
|
1005
|
-
const roles = new Set<string>(["all", "system"]);
|
|
1006
|
-
for (const f of features) {
|
|
1007
|
-
for (const def of Object.values(f.writeHandlers)) {
|
|
1008
|
-
if (def.access && "roles" in def.access) {
|
|
1009
|
-
for (const r of def.access.roles) roles.add(r);
|
|
1010
|
-
}
|
|
1011
|
-
}
|
|
1012
|
-
for (const def of Object.values(f.queryHandlers)) {
|
|
1013
|
-
if (def.access && "roles" in def.access) {
|
|
1014
|
-
for (const r of def.access.roles) roles.add(r);
|
|
1015
|
-
}
|
|
1016
|
-
}
|
|
1017
|
-
}
|
|
1018
|
-
return roles;
|
|
1019
|
-
}
|
|
1020
|
-
|
|
1021
|
-
// --- Workspace validation ---
|
|
1022
|
-
//
|
|
1023
|
-
// Per-app workspace registry, built once up front. Carries `featureName`
|
|
1024
|
-
// alongside the definition so error messages can point at the offending
|
|
1025
|
-
// feature without a parallel reverse index.
|
|
1026
|
-
|
|
1027
|
-
export function collectWorkspaceQns(
|
|
1028
|
-
features: readonly FeatureDefinition[],
|
|
1029
|
-
): Map<string, WorkspaceDefinition & { readonly featureName: string }> {
|
|
1030
|
-
const map = new Map<string, WorkspaceDefinition & { readonly featureName: string }>();
|
|
1031
|
-
for (const f of features) {
|
|
1032
|
-
for (const [wsId, wsDef] of Object.entries(f.workspaces)) {
|
|
1033
|
-
const qualified = qualifyEntityName(f.name, "workspace", wsId);
|
|
1034
|
-
map.set(qualified, { ...wsDef, featureName: f.name });
|
|
1035
|
-
}
|
|
1036
|
-
}
|
|
1037
|
-
return map;
|
|
1038
|
-
}
|
|
1039
|
-
|
|
1040
|
-
export function validateWorkspaces(
|
|
1041
|
-
feature: FeatureDefinition,
|
|
1042
|
-
allNavQns: ReadonlyMap<string, NavDefinition & { readonly featureName: string }>,
|
|
1043
|
-
): void {
|
|
1044
|
-
for (const [wsId, wsDef] of Object.entries(feature.workspaces)) {
|
|
1045
|
-
if (wsDef.nav !== undefined) {
|
|
1046
|
-
for (const navQn of wsDef.nav) {
|
|
1047
|
-
// Settings-Hub audience navs are generated post-boot (buildAppSchema), never via r.nav() — exempt so an inline-placement reference doesn't trip the boot validator.
|
|
1048
|
-
if (SETTINGS_HUB_AUDIENCE_NAV_QN_SET.has(navQn)) continue;
|
|
1049
|
-
if (!allNavQns.has(navQn)) {
|
|
1050
|
-
throw new Error(
|
|
1051
|
-
`[Feature ${feature.name}] Workspace "${wsId}" references nav "${navQn}" ` +
|
|
1052
|
-
`which is not registered. Expected a qualified name of the form ` +
|
|
1053
|
-
`"<feature>:nav:<id>" pointing at an r.nav() declaration.`,
|
|
1054
|
-
);
|
|
1055
|
-
}
|
|
1056
|
-
}
|
|
1057
|
-
}
|
|
1058
|
-
}
|
|
1059
|
-
}
|
|
1060
|
-
|
|
1061
|
-
const SETTINGS_HUB_AUDIENCE_NAV_QN_SET: ReadonlySet<string> = new Set(
|
|
1062
|
-
SETTINGS_HUB_AUDIENCE_NAV_QNS,
|
|
1063
|
-
);
|
|
1064
|
-
|
|
1065
|
-
// Single-default rule across the entire app. Mirrors how createApp validates
|
|
1066
|
-
// roles up front — a second `default: true` is a configuration error, not a
|
|
1067
|
-
// runtime fallback. Apps without any default fall back to "first workspace
|
|
1068
|
-
// the user has access to" at render time (handled by shellWorkspaces).
|
|
1069
|
-
export function validateDefaultWorkspaceUniqueness(
|
|
1070
|
-
allWorkspaceQns: ReadonlyMap<string, WorkspaceDefinition & { readonly featureName: string }>,
|
|
1071
|
-
): void {
|
|
1072
|
-
const defaults: string[] = [];
|
|
1073
|
-
for (const [qn, ws] of allWorkspaceQns) {
|
|
1074
|
-
if (ws.default === true) defaults.push(qn);
|
|
1075
|
-
}
|
|
1076
|
-
if (defaults.length > 1) {
|
|
1077
|
-
throw new Error(
|
|
1078
|
-
`[Kumiko Workspaces] Multiple workspaces declare default: true — ` +
|
|
1079
|
-
`${defaults.join(", ")}. At most one workspace per app may be the default.`,
|
|
1080
|
-
);
|
|
1081
|
-
}
|
|
1082
|
-
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
// Workspace validation.
|
|
2
|
+
|
|
3
|
+
import { SETTINGS_HUB_AUDIENCE_NAV_QNS } from "../build-config-feature-schema";
|
|
4
|
+
import { qualifyEntityName } from "../qualified-name";
|
|
5
|
+
import type { FeatureDefinition, NavDefinition, WorkspaceDefinition } from "../types";
|
|
6
|
+
|
|
7
|
+
// --- Workspace validation ---
|
|
8
|
+
//
|
|
9
|
+
// Per-app workspace registry, built once up front. Carries `featureName`
|
|
10
|
+
// alongside the definition so error messages can point at the offending
|
|
11
|
+
// feature without a parallel reverse index.
|
|
12
|
+
|
|
13
|
+
export function collectWorkspaceQns(
|
|
14
|
+
features: readonly FeatureDefinition[],
|
|
15
|
+
): Map<string, WorkspaceDefinition & { readonly featureName: string }> {
|
|
16
|
+
const map = new Map<string, WorkspaceDefinition & { readonly featureName: string }>();
|
|
17
|
+
for (const f of features) {
|
|
18
|
+
for (const [wsId, wsDef] of Object.entries(f.workspaces)) {
|
|
19
|
+
const qualified = qualifyEntityName(f.name, "workspace", wsId);
|
|
20
|
+
map.set(qualified, { ...wsDef, featureName: f.name });
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
return map;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function validateWorkspaces(
|
|
27
|
+
feature: FeatureDefinition,
|
|
28
|
+
allNavQns: ReadonlyMap<string, NavDefinition & { readonly featureName: string }>,
|
|
29
|
+
): void {
|
|
30
|
+
for (const [wsId, wsDef] of Object.entries(feature.workspaces)) {
|
|
31
|
+
if (wsDef.nav !== undefined) {
|
|
32
|
+
for (const navQn of wsDef.nav) {
|
|
33
|
+
// Settings-Hub audience navs are generated post-boot (buildAppSchema), never via r.nav() — exempt so an inline-placement reference doesn't trip the boot validator.
|
|
34
|
+
if (SETTINGS_HUB_AUDIENCE_NAV_QN_SET.has(navQn)) continue;
|
|
35
|
+
if (!allNavQns.has(navQn)) {
|
|
36
|
+
throw new Error(
|
|
37
|
+
`[Feature ${feature.name}] Workspace "${wsId}" references nav "${navQn}" ` +
|
|
38
|
+
`which is not registered. Expected a qualified name of the form ` +
|
|
39
|
+
`"<feature>:nav:<id>" pointing at an r.nav() declaration.`,
|
|
40
|
+
);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const SETTINGS_HUB_AUDIENCE_NAV_QN_SET: ReadonlySet<string> = new Set(
|
|
48
|
+
SETTINGS_HUB_AUDIENCE_NAV_QNS,
|
|
49
|
+
);
|
|
50
|
+
|
|
51
|
+
// Single-default rule across the entire app. Mirrors how createApp validates
|
|
52
|
+
// roles up front — a second `default: true` is a configuration error, not a
|
|
53
|
+
// runtime fallback. Apps without any default fall back to "first workspace
|
|
54
|
+
// the user has access to" at render time (handled by shellWorkspaces).
|
|
55
|
+
export function validateDefaultWorkspaceUniqueness(
|
|
56
|
+
allWorkspaceQns: ReadonlyMap<string, WorkspaceDefinition & { readonly featureName: string }>,
|
|
57
|
+
): void {
|
|
58
|
+
const defaults: string[] = [];
|
|
59
|
+
for (const [qn, ws] of allWorkspaceQns) {
|
|
60
|
+
if (ws.default === true) defaults.push(qn);
|
|
61
|
+
}
|
|
62
|
+
if (defaults.length > 1) {
|
|
63
|
+
throw new Error(
|
|
64
|
+
`[Kumiko Workspaces] Multiple workspaces declare default: true — ` +
|
|
65
|
+
`${defaults.join(", ")}. At most one workspace per app may be the default.`,
|
|
66
|
+
);
|
|
67
|
+
}
|
|
68
|
+
}
|