@cosmicdrift/kumiko-framework 0.146.3 → 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.
Files changed (68) hide show
  1. package/package.json +6 -2
  2. package/src/api/auth-routes.ts +32 -67
  3. package/src/api/routes.ts +1 -3
  4. package/src/bun-db/__tests__/PATTERN.md +0 -1
  5. package/src/db/__tests__/assert-no-unreachable-live-rows.integration.test.ts +29 -3
  6. package/src/db/__tests__/schema-inspection.test.ts +7 -0
  7. package/src/db/event-store-executor-context.ts +398 -0
  8. package/src/db/event-store-executor-read.ts +276 -0
  9. package/src/db/event-store-executor-write.ts +615 -0
  10. package/src/db/event-store-executor.ts +29 -1166
  11. package/src/db/queries/shadow-swap.ts +3 -1
  12. package/src/db/schema-inspection.ts +1 -1
  13. package/src/engine/__tests__/boot-validator-dashboard.test.ts +10 -0
  14. package/src/engine/__tests__/engine.test.ts +45 -1
  15. package/src/engine/__tests__/event-migration-declarative.test.ts +6 -0
  16. package/src/engine/__tests__/membership-roles.test.ts +4 -10
  17. package/src/engine/__tests__/screen.test.ts +26 -0
  18. package/src/engine/boot-validator/entity-list-screens.ts +1 -1
  19. package/src/engine/boot-validator/gdpr-storage.ts +1 -1
  20. package/src/engine/boot-validator/index.ts +12 -8
  21. package/src/engine/boot-validator/nav.ts +120 -0
  22. package/src/engine/boot-validator/{screens-nav.ts → screens.ts} +12 -190
  23. package/src/engine/boot-validator/workspaces.ts +68 -0
  24. package/src/engine/define-feature.ts +77 -1011
  25. package/src/engine/feature-ast/__tests__/fixtures/cross-file-registrar/feature.ts +9 -0
  26. package/src/engine/feature-ast/__tests__/fixtures/cross-file-registrar/screens.ts +4 -0
  27. package/src/engine/feature-ast/__tests__/parse-real-features.test.ts +18 -8
  28. package/src/engine/feature-ast/__tests__/parse.test.ts +291 -2
  29. package/src/engine/feature-ast/__tests__/patcher.test.ts +1 -1
  30. package/src/engine/feature-ast/__tests__/render-roundtrip.test.ts +73 -0
  31. package/src/engine/feature-ast/extractors/round1.ts +3 -3
  32. package/src/engine/feature-ast/extractors/round4.ts +45 -10
  33. package/src/engine/feature-ast/extractors/shared.ts +64 -6
  34. package/src/engine/feature-ast/parse.ts +123 -10
  35. package/src/engine/feature-ast/patterns.ts +14 -7
  36. package/src/engine/feature-ast/render.ts +28 -7
  37. package/src/engine/feature-builder-state.ts +165 -0
  38. package/src/engine/feature-config-events-jobs.ts +303 -0
  39. package/src/engine/feature-entity-handlers.ts +161 -0
  40. package/src/engine/feature-ui-extensions.ts +413 -0
  41. package/src/engine/index.ts +0 -2
  42. package/src/engine/pattern-library/library.ts +44 -1131
  43. package/src/engine/pattern-library/mixed-schemas.ts +484 -0
  44. package/src/engine/pattern-library/opaque-schemas.ts +124 -0
  45. package/src/engine/pattern-library/shared-fields.ts +80 -0
  46. package/src/engine/pattern-library/static-schemas.ts +456 -0
  47. package/src/engine/registry-facade.ts +349 -0
  48. package/src/engine/registry-ingest.ts +473 -0
  49. package/src/engine/registry-state.ts +388 -0
  50. package/src/engine/registry-validate.ts +660 -0
  51. package/src/engine/registry.ts +79 -1695
  52. package/src/engine/types/screen.ts +4 -2
  53. package/src/engine/types/tree-node.ts +2 -5
  54. package/src/event-store/snapshot.ts +7 -7
  55. package/src/i18n/required-surface-keys.ts +2 -0
  56. package/src/jobs/job-runner.ts +1 -1
  57. package/src/observability/standard-metrics.ts +4 -3
  58. package/src/pipeline/dispatch-batch.ts +3 -3
  59. package/src/pipeline/dispatch-shared.ts +17 -10
  60. package/src/pipeline/event-dispatcher-admin.ts +289 -0
  61. package/src/pipeline/event-dispatcher-delivery.ts +264 -0
  62. package/src/pipeline/event-dispatcher.ts +28 -540
  63. package/src/pipeline/projection-rebuild.ts +1 -1
  64. package/src/stack/__tests__/setup-test-stack-jobs.integration.test.ts +95 -0
  65. package/src/stack/test-stack.ts +46 -1
  66. package/src/testing/boot-validator-fixture.ts +1 -2
  67. package/src/engine/__tests__/deep-link.test.ts +0 -35
  68. 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}": ${ids.length}+ live rows in "${tableName}" have no ` +
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 === undefined) {
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(/r\.describe\(\) called twice/);
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(merge(["SystemAdmin"], [])).toContain("SystemAdmin");
46
+ expect(buildSessionRoles(["SystemAdmin"], [])).toContain("SystemAdmin");
53
47
  });
54
48
 
55
49
  test("membership SystemAdmin is stripped (resurrected role neutralised)", () => {
56
- expect(merge([], ["SystemAdmin"])).not.toContain("SystemAdmin");
50
+ expect(buildSessionRoles([], ["SystemAdmin"])).not.toContain("SystemAdmin");
57
51
  });
58
52
 
59
53
  test("global admin + tenant membership keeps both, deduped", () => {
60
- expect([...merge(["SystemAdmin"], ["Admin", "SystemAdmin"])].sort()).toEqual(
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 "./screens-nav";
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 "./screens-nav";
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, NavDefinition, WorkspaceDefinition } from "../types";
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
+ }