@cosmicdrift/kumiko-framework 0.2.2 → 0.3.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/CHANGELOG.md +54 -0
- package/package.json +124 -38
- package/src/__tests__/full-stack.integration.ts +2 -2
- package/src/api/auth-routes.ts +5 -5
- package/src/api/jwt.ts +2 -2
- package/src/api/route-registrars.ts +1 -1
- package/src/api/routes.ts +3 -3
- package/src/api/server.ts +6 -7
- package/src/auth/__tests__/roles.test.ts +24 -0
- package/src/auth/index.ts +7 -0
- package/src/auth/roles.ts +42 -0
- package/src/compliance/__tests__/duration-spec.test.ts +72 -0
- package/src/compliance/__tests__/profiles.test.ts +308 -0
- package/src/compliance/__tests__/sub-processors.test.ts +139 -0
- package/src/compliance/duration-spec.ts +44 -0
- package/src/compliance/index.ts +31 -0
- package/src/compliance/override-schema.ts +136 -0
- package/src/compliance/profiles.ts +427 -0
- package/src/compliance/sub-processors.ts +152 -0
- package/src/db/__tests__/big-int-field.test.ts +131 -0
- package/src/db/assert-exists-in.ts +2 -2
- package/src/db/cursor.ts +3 -3
- package/src/db/event-store-executor.ts +19 -13
- package/src/db/located-timestamp.ts +1 -1
- package/src/db/money.ts +12 -2
- package/src/db/pg-error.ts +1 -1
- package/src/db/row-helpers.ts +1 -1
- package/src/db/table-builder.ts +20 -5
- package/src/db/tenant-db.ts +9 -9
- package/src/engine/__tests__/_pipeline-test-utils.ts +23 -0
- package/src/engine/__tests__/boot-validator-api-exposure.test.ts +142 -0
- package/src/engine/__tests__/boot-validator-pii-retention.test.ts +570 -0
- package/src/engine/__tests__/boot-validator-s0-integration.test.ts +160 -0
- package/src/engine/__tests__/build-target.test.ts +135 -0
- package/src/engine/__tests__/codemod-pipeline.test.ts +551 -0
- package/src/engine/__tests__/entity-handlers.test.ts +3 -3
- package/src/engine/__tests__/event-helpers.test.ts +4 -4
- package/src/engine/__tests__/pipeline-engine.test.ts +215 -0
- package/src/engine/__tests__/pipeline-handler.integration.ts +894 -0
- package/src/engine/__tests__/pipeline-observability.integration.ts +142 -0
- package/src/engine/__tests__/pipeline-performance.integration.ts +152 -0
- package/src/engine/__tests__/pipeline-sub-pipelines.test.ts +288 -0
- package/src/engine/__tests__/raw-table.test.ts +2 -2
- package/src/engine/__tests__/steps-aggregate-append-event.test.ts +115 -0
- package/src/engine/__tests__/steps-aggregate-create.test.ts +92 -0
- package/src/engine/__tests__/steps-aggregate-update.test.ts +127 -0
- package/src/engine/__tests__/steps-call-feature.test.ts +123 -0
- package/src/engine/__tests__/steps-mail-send.test.ts +136 -0
- package/src/engine/__tests__/steps-read.test.ts +142 -0
- package/src/engine/__tests__/steps-resolver-utils.test.ts +50 -0
- package/src/engine/__tests__/steps-unsafe-projection-delete.test.ts +69 -0
- package/src/engine/__tests__/steps-unsafe-projection-upsert.test.ts +117 -0
- package/src/engine/__tests__/steps-webhook-send.test.ts +135 -0
- package/src/engine/__tests__/steps-workflow.test.ts +198 -0
- package/src/engine/__tests__/validate-projection-allowlist.test.ts +491 -0
- package/src/engine/__tests__/visual-tree-patterns.test.ts +251 -0
- package/src/engine/boot-validator/api-ext.ts +77 -0
- package/src/engine/boot-validator/config-deps.ts +163 -0
- package/src/engine/boot-validator/entity-handler.ts +466 -0
- package/src/engine/boot-validator/index.ts +159 -0
- package/src/engine/boot-validator/ownership.ts +198 -0
- package/src/engine/boot-validator/pii-retention.ts +155 -0
- package/src/engine/boot-validator/screens-nav.ts +624 -0
- package/src/engine/boot-validator.ts +1 -1528
- package/src/engine/build-app-schema.ts +1 -1
- package/src/engine/build-target.ts +99 -0
- package/src/engine/codemod/index.ts +15 -0
- package/src/engine/codemod/pipeline-codemod.ts +641 -0
- package/src/engine/config-helpers.ts +9 -19
- package/src/engine/constants.ts +1 -1
- package/src/engine/define-feature.ts +127 -9
- package/src/engine/define-handler.ts +89 -3
- package/src/engine/define-roles.ts +2 -2
- package/src/engine/define-step.ts +28 -0
- package/src/engine/define-workflow.ts +110 -0
- package/src/engine/entity-handlers.ts +10 -9
- package/src/engine/event-helpers.ts +4 -4
- package/src/engine/extension-names.ts +105 -0
- package/src/engine/extensions/user-data.ts +106 -0
- package/src/engine/factories.ts +26 -16
- package/src/engine/feature-ast/__tests__/visual-tree-parse.test.ts +184 -0
- package/src/engine/feature-ast/extractors/index.ts +74 -0
- package/src/engine/feature-ast/extractors/round1.ts +110 -0
- package/src/engine/feature-ast/extractors/round2.ts +253 -0
- package/src/engine/feature-ast/extractors/round3.ts +471 -0
- package/src/engine/feature-ast/extractors/round4.ts +1365 -0
- package/src/engine/feature-ast/extractors/round5.ts +72 -0
- package/src/engine/feature-ast/extractors/round6.ts +66 -0
- package/src/engine/feature-ast/extractors/shared.ts +177 -0
- package/src/engine/feature-ast/parse.ts +13 -0
- package/src/engine/feature-ast/patch.ts +9 -1
- package/src/engine/feature-ast/patcher.ts +10 -3
- package/src/engine/feature-ast/patterns.ts +71 -1
- package/src/engine/feature-ast/render.ts +31 -1
- package/src/engine/index.ts +66 -2
- package/src/engine/pattern-library/__tests__/library.test.ts +11 -0
- package/src/engine/pattern-library/library.ts +78 -2
- package/src/engine/pipeline.ts +88 -0
- package/src/engine/projection-helpers.ts +1 -1
- package/src/engine/read-claim.ts +1 -1
- package/src/engine/registry.ts +30 -2
- package/src/engine/resolve-config-or-param.ts +4 -0
- package/src/engine/run-pipeline.ts +162 -0
- package/src/engine/schema-builder.ts +10 -4
- package/src/engine/state-machine.ts +1 -1
- package/src/engine/steps/_drizzle-boundary.ts +19 -0
- package/src/engine/steps/_duration-utils.ts +33 -0
- package/src/engine/steps/_no-return-guard.ts +21 -0
- package/src/engine/steps/_resolver-utils.ts +42 -0
- package/src/engine/steps/_step-dispatch-constants.ts +38 -0
- package/src/engine/steps/aggregate-append-event.ts +56 -0
- package/src/engine/steps/aggregate-create.ts +56 -0
- package/src/engine/steps/aggregate-update.ts +68 -0
- package/src/engine/steps/branch.ts +84 -0
- package/src/engine/steps/call-feature.ts +49 -0
- package/src/engine/steps/compute.ts +41 -0
- package/src/engine/steps/for-each.ts +111 -0
- package/src/engine/steps/mail-send.ts +44 -0
- package/src/engine/steps/read-find-many.ts +51 -0
- package/src/engine/steps/read-find-one.ts +58 -0
- package/src/engine/steps/retry.ts +87 -0
- package/src/engine/steps/return.ts +34 -0
- package/src/engine/steps/unsafe-projection-delete.ts +46 -0
- package/src/engine/steps/unsafe-projection-upsert.ts +69 -0
- package/src/engine/steps/wait-for-event.ts +71 -0
- package/src/engine/steps/wait.ts +69 -0
- package/src/engine/steps/webhook-send.ts +71 -0
- package/src/engine/system-user.ts +1 -1
- package/src/engine/types/feature.ts +143 -1
- package/src/engine/types/fields.ts +134 -10
- package/src/engine/types/handlers.ts +18 -10
- package/src/engine/types/identifiers.ts +1 -0
- package/src/engine/types/index.ts +15 -1
- package/src/engine/types/step.ts +334 -0
- package/src/engine/types/target-ref.ts +21 -0
- package/src/engine/types/tree-node.ts +130 -0
- package/src/engine/types/workspace.ts +7 -0
- package/src/engine/validate-projection-allowlist.ts +161 -0
- package/src/event-store/snapshot.ts +1 -1
- package/src/event-store/upcaster-dead-letter.ts +1 -1
- package/src/event-store/upcaster.ts +1 -1
- package/src/files/__tests__/read-stream.test.ts +105 -0
- package/src/files/__tests__/write-stream.test.ts +233 -0
- package/src/files/__tests__/zip-stream.test.ts +357 -0
- package/src/files/file-routes.ts +1 -1
- package/src/files/in-memory-provider.ts +38 -0
- package/src/files/index.ts +3 -0
- package/src/files/local-provider.ts +58 -1
- package/src/files/types.ts +36 -8
- package/src/files/zip-stream.ts +251 -0
- package/src/jobs/job-runner.ts +10 -10
- package/src/lifecycle/lifecycle.ts +0 -3
- package/src/logging/index.ts +1 -0
- package/src/logging/pino-logger.ts +11 -7
- package/src/logging/utils.ts +24 -0
- package/src/observability/prometheus-meter.ts +7 -5
- package/src/pipeline/__tests__/archive-stream.integration.ts +1 -1
- package/src/pipeline/__tests__/causation-chain.integration.ts +1 -1
- package/src/pipeline/__tests__/domain-events-projections.integration.ts +3 -3
- package/src/pipeline/__tests__/event-define-event-strict.integration.ts +4 -4
- package/src/pipeline/__tests__/load-aggregate-query.integration.ts +1 -1
- package/src/pipeline/__tests__/msp-multi-hop.integration.ts +3 -3
- package/src/pipeline/__tests__/msp-rebuild.integration.ts +3 -3
- package/src/pipeline/__tests__/multi-stream-projection.integration.ts +2 -2
- package/src/pipeline/__tests__/query-projection.integration.ts +5 -5
- package/src/pipeline/append-event-core.ts +22 -6
- package/src/pipeline/dispatcher-utils.ts +188 -0
- package/src/pipeline/dispatcher.ts +63 -283
- package/src/pipeline/distributed-lock.ts +1 -1
- package/src/pipeline/entity-cache.ts +2 -2
- package/src/pipeline/event-consumer-state.ts +0 -13
- package/src/pipeline/event-dispatcher.ts +4 -4
- package/src/pipeline/index.ts +0 -2
- package/src/pipeline/lifecycle-pipeline.ts +6 -12
- package/src/pipeline/msp-rebuild.ts +5 -5
- package/src/pipeline/multi-stream-apply-context.ts +6 -7
- package/src/pipeline/projection-rebuild.ts +2 -2
- package/src/pipeline/projection-state.ts +0 -12
- package/src/rate-limit/__tests__/resolver.integration.ts +8 -4
- package/src/rate-limit/resolver.ts +1 -1
- package/src/search/in-memory-adapter.ts +1 -1
- package/src/search/meilisearch-adapter.ts +3 -3
- package/src/search/types.ts +1 -1
- package/src/secrets/leak-guard.ts +2 -2
- package/src/stack/request-helper.ts +9 -5
- package/src/stack/test-stack.ts +1 -1
- package/src/testing/handler-context.ts +4 -4
- package/src/testing/http-cookies.ts +1 -1
- package/src/time/tz-context.ts +1 -2
- package/src/ui-types/index.ts +4 -0
- package/src/engine/feature-ast/extractors.ts +0 -2562
|
@@ -0,0 +1,624 @@
|
|
|
1
|
+
import { qualifyEntityName } from "../qualified-name";
|
|
2
|
+
import { getAllowedFilterOps, isFieldFilterable } from "../screen-filter-ops";
|
|
3
|
+
import type { FeatureDefinition, NavDefinition, WorkspaceDefinition } from "../types";
|
|
4
|
+
import { normalizeEditField, normalizeListColumn } from "../types/screen";
|
|
5
|
+
|
|
6
|
+
// --- Screen validation ---
|
|
7
|
+
//
|
|
8
|
+
// For every r.screen() declaration check what's locally knowable at boot:
|
|
9
|
+
// - entityList / entityEdit: the referenced entity must exist in the
|
|
10
|
+
// feature (cross-feature entity-refs aren't allowed — a feature owns
|
|
11
|
+
// the screens over its own entities) and every column/field ref must
|
|
12
|
+
// name a real field on that entity
|
|
13
|
+
// - custom: the renderer must at least have one platform component set
|
|
14
|
+
// (react OR native), otherwise the screen is structurally empty
|
|
15
|
+
//
|
|
16
|
+
// Field-level renderer QN strings (cross-feature `component:` references)
|
|
17
|
+
// are NOT validated here — the r.uiComponent registry that would resolve
|
|
18
|
+
// them ships in M4/M5. Until then those are kept opaque on purpose.
|
|
19
|
+
export function validateScreens(
|
|
20
|
+
feature: FeatureDefinition,
|
|
21
|
+
featureMap: ReadonlyMap<string, FeatureDefinition>,
|
|
22
|
+
allWriteHandlerQns: ReadonlySet<string>,
|
|
23
|
+
allScreenQns: ReadonlySet<string>,
|
|
24
|
+
allConfigKeyQns: ReadonlySet<string>,
|
|
25
|
+
): void {
|
|
26
|
+
for (const [screenId, screen] of Object.entries(feature.screens)) {
|
|
27
|
+
if (screen.type === "custom") {
|
|
28
|
+
if (!screen.renderer.react && !screen.renderer.native) {
|
|
29
|
+
throw new Error(
|
|
30
|
+
`[Feature ${feature.name}] Screen "${screenId}" has type="custom" but the renderer ` +
|
|
31
|
+
`declares neither a react nor a native component — at least one platform must be set.`,
|
|
32
|
+
);
|
|
33
|
+
}
|
|
34
|
+
continue;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
if (screen.type === "configEdit") {
|
|
38
|
+
// configEdit: layout/fields wie actionForm validieren, plus
|
|
39
|
+
// Cross-Check dass jeder qualifizierte Config-Key registriert
|
|
40
|
+
// ist und der scope mit dem Key matcht.
|
|
41
|
+
const fieldNames = new Set(Object.keys(screen.fields));
|
|
42
|
+
if (fieldNames.size === 0) {
|
|
43
|
+
throw new Error(
|
|
44
|
+
`[Feature ${feature.name}] Screen "${screenId}" (configEdit) has empty fields map — ` +
|
|
45
|
+
`declare at least one field.`,
|
|
46
|
+
);
|
|
47
|
+
}
|
|
48
|
+
for (const [fname, fdef] of Object.entries(screen.fields)) {
|
|
49
|
+
// @cast-boundary schema-walk — feature-config inspection
|
|
50
|
+
const ftype = (fdef as { type?: unknown }).type;
|
|
51
|
+
if (typeof ftype !== "string" || ftype.length === 0) {
|
|
52
|
+
throw new Error(
|
|
53
|
+
`[Feature ${feature.name}] Screen "${screenId}" (configEdit) field "${fname}" has no ` +
|
|
54
|
+
`\`type\` set. Each field must declare a type (e.g. "text", "number", "select").`,
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
if (screen.layout.sections.length === 0) {
|
|
59
|
+
throw new Error(
|
|
60
|
+
`[Feature ${feature.name}] Screen "${screenId}" (configEdit) has an empty sections list — ` +
|
|
61
|
+
`declare at least one section.`,
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
for (const section of screen.layout.sections) {
|
|
65
|
+
if (section.fields.length === 0) {
|
|
66
|
+
throw new Error(
|
|
67
|
+
`[Feature ${feature.name}] Screen "${screenId}" (configEdit) has a section "${section.title}" ` +
|
|
68
|
+
`with zero fields — drop the section or add fields to it.`,
|
|
69
|
+
);
|
|
70
|
+
}
|
|
71
|
+
for (const fieldSpec of section.fields) {
|
|
72
|
+
const normalized = normalizeEditField(fieldSpec);
|
|
73
|
+
if (!fieldNames.has(normalized.field)) {
|
|
74
|
+
throw new Error(
|
|
75
|
+
`[Feature ${feature.name}] Screen "${screenId}" (configEdit) layout references unknown ` +
|
|
76
|
+
`field "${normalized.field}". Known fields: ${[...fieldNames].sort().join(", ")}`,
|
|
77
|
+
);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
// configKeys: jeder fieldName muss einen Mapping-Eintrag haben,
|
|
82
|
+
// jeder qualifizierte Key muss in der Registry existieren.
|
|
83
|
+
for (const fname of fieldNames) {
|
|
84
|
+
const qualified = screen.configKeys[fname];
|
|
85
|
+
if (qualified === undefined) {
|
|
86
|
+
throw new Error(
|
|
87
|
+
`[Feature ${feature.name}] Screen "${screenId}" (configEdit) field "${fname}" hat ` +
|
|
88
|
+
`keinen Eintrag in configKeys-Map. Jedes deklarierte Field braucht ein Mapping zu ` +
|
|
89
|
+
`einem qualifizierten Config-Key (\`<feature>:config:<short>\`).`,
|
|
90
|
+
);
|
|
91
|
+
}
|
|
92
|
+
if (!allConfigKeyQns.has(qualified)) {
|
|
93
|
+
throw new Error(
|
|
94
|
+
`[Feature ${feature.name}] Screen "${screenId}" (configEdit) field "${fname}" → ` +
|
|
95
|
+
`Config-Key "${qualified}" ist in keiner Feature-Registry deklariert. Tippfehler? ` +
|
|
96
|
+
`Erwartetes Format: "<feature>:config:<short>". Bekannte Keys: ${
|
|
97
|
+
[...allConfigKeyQns].sort().join(", ") || "(keine)"
|
|
98
|
+
}`,
|
|
99
|
+
);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
continue;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
if (screen.type === "actionForm") {
|
|
106
|
+
// Tier 2.7d: Action-Form-Screens haben keinen entity-Link, nur
|
|
107
|
+
// einen Write-Handler-QN + Inline-Fields. Sechs Author-Code-
|
|
108
|
+
// Checks am Boot:
|
|
109
|
+
// 1) handler ist non-empty String.
|
|
110
|
+
// 2) handler ist als Write-Handler registriert (cross-feature-
|
|
111
|
+
// Lookup gegen die collected QN-Map). Tippfehler/umbenannte
|
|
112
|
+
// Handler fallen sonst erst beim ersten Klick als 404 auf.
|
|
113
|
+
// 3) fields-Map ist non-empty.
|
|
114
|
+
// 4) Jeder Field-Eintrag hat einen `type`-Discriminator
|
|
115
|
+
// (Tippfehler in Schema → Renderer crasht stumm sonst).
|
|
116
|
+
// 5) layout.sections + jedes referenced field existiert in
|
|
117
|
+
// fields.
|
|
118
|
+
// 6) redirect (wenn gesetzt) verweist auf einen registrierten
|
|
119
|
+
// Screen-QN (Cross-Feature ok).
|
|
120
|
+
if (!screen.handler || typeof screen.handler !== "string") {
|
|
121
|
+
throw new Error(
|
|
122
|
+
`[Feature ${feature.name}] Screen "${screenId}" (actionForm) has empty or non-string handler.`,
|
|
123
|
+
);
|
|
124
|
+
}
|
|
125
|
+
if (!allWriteHandlerQns.has(screen.handler)) {
|
|
126
|
+
throw new Error(
|
|
127
|
+
`[Feature ${feature.name}] Screen "${screenId}" (actionForm) handler "${screen.handler}" ` +
|
|
128
|
+
`is not a registered write-handler. Check the QN spelling (expected ` +
|
|
129
|
+
`"<feature>:write:<short>") and that the handler is declared via r.writeHandler(...).`,
|
|
130
|
+
);
|
|
131
|
+
}
|
|
132
|
+
const fieldNames = new Set(Object.keys(screen.fields));
|
|
133
|
+
if (fieldNames.size === 0) {
|
|
134
|
+
throw new Error(
|
|
135
|
+
`[Feature ${feature.name}] Screen "${screenId}" (actionForm) has empty fields map — ` +
|
|
136
|
+
`declare at least one field.`,
|
|
137
|
+
);
|
|
138
|
+
}
|
|
139
|
+
// Jeder Field-Eintrag muss einen `type`-Discriminator haben.
|
|
140
|
+
// Author-Tippfehler (`title: { required: true }` ohne type) →
|
|
141
|
+
// RenderField fällt zur Laufzeit auf den Default-Renderer und
|
|
142
|
+
// schickt einen leeren String — silent broken. Boot-Fail ist
|
|
143
|
+
// klarer. `type as unknown` weil FieldDefinition als Union nur
|
|
144
|
+
// bekannte Strings erlaubt; wir prüfen Author-Code, der ggf.
|
|
145
|
+
// den Type-Check umgangen hat.
|
|
146
|
+
for (const [fname, fdef] of Object.entries(screen.fields)) {
|
|
147
|
+
// @cast-boundary schema-walk — feature-config inspection (Author may circumvent type-check)
|
|
148
|
+
const ftype = (fdef as { type?: unknown }).type;
|
|
149
|
+
if (typeof ftype !== "string" || ftype.length === 0) {
|
|
150
|
+
throw new Error(
|
|
151
|
+
`[Feature ${feature.name}] Screen "${screenId}" (actionForm) field "${fname}" has no ` +
|
|
152
|
+
`\`type\` set. Each field must declare a type (e.g. "text", "number", "select").`,
|
|
153
|
+
);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
if (screen.layout.sections.length === 0) {
|
|
157
|
+
throw new Error(
|
|
158
|
+
`[Feature ${feature.name}] Screen "${screenId}" (actionForm) has an empty sections list — ` +
|
|
159
|
+
`declare at least one section.`,
|
|
160
|
+
);
|
|
161
|
+
}
|
|
162
|
+
for (const section of screen.layout.sections) {
|
|
163
|
+
if (section.fields.length === 0) {
|
|
164
|
+
throw new Error(
|
|
165
|
+
`[Feature ${feature.name}] Screen "${screenId}" (actionForm) has a section "${section.title}" ` +
|
|
166
|
+
`with zero fields — drop the section or add fields to it.`,
|
|
167
|
+
);
|
|
168
|
+
}
|
|
169
|
+
for (const fieldSpec of section.fields) {
|
|
170
|
+
const normalized = normalizeEditField(fieldSpec);
|
|
171
|
+
if (!fieldNames.has(normalized.field)) {
|
|
172
|
+
throw new Error(
|
|
173
|
+
`[Feature ${feature.name}] Screen "${screenId}" (actionForm) layout references unknown field ` +
|
|
174
|
+
`"${normalized.field}". Known fields: ${[...fieldNames].sort().join(", ")}`,
|
|
175
|
+
);
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
if (screen.redirect !== undefined) {
|
|
180
|
+
// redirect ist die kurze Screen-ID (z.B. "item-list"); der
|
|
181
|
+
// nav-Router resolved sie beim Mount gegen die Schema-Map.
|
|
182
|
+
// Cross-Feature-Redirect ist nicht supported — der nav-Router
|
|
183
|
+
// baut die URL aus screenId direkt, eine voll-QN würde als
|
|
184
|
+
// `/shop:screen:foo/` landen und nirgendwo greifen.
|
|
185
|
+
const candidateQn = qualifyEntityName(feature.name, "screen", screen.redirect);
|
|
186
|
+
if (!allScreenQns.has(candidateQn)) {
|
|
187
|
+
throw new Error(
|
|
188
|
+
`[Feature ${feature.name}] Screen "${screenId}" (actionForm) redirect "${screen.redirect}" ` +
|
|
189
|
+
`does not resolve to a registered screen in this feature. Known screens: ${
|
|
190
|
+
[...Object.keys(feature.screens)].sort().join(", ") || "(none)"
|
|
191
|
+
}.`,
|
|
192
|
+
);
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
continue;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// entityList / entityEdit: entity-refs are feature-local.
|
|
199
|
+
const entityDef = feature.entities[screen.entity];
|
|
200
|
+
if (!entityDef) {
|
|
201
|
+
const known = Object.keys(feature.entities).sort().join(", ") || "(none)";
|
|
202
|
+
const crossFeature = findEntityFeature(screen.entity, featureMap);
|
|
203
|
+
const hint = crossFeature
|
|
204
|
+
? ` Entity "${screen.entity}" is owned by feature "${crossFeature}" — cross-feature screen ownership is not supported.`
|
|
205
|
+
: "";
|
|
206
|
+
throw new Error(
|
|
207
|
+
`[Feature ${feature.name}] Screen "${screenId}" references entity "${screen.entity}" ` +
|
|
208
|
+
`which is not declared in this feature (known: ${known}).${hint}`,
|
|
209
|
+
);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
const fieldNames = new Set(Object.keys(entityDef.fields));
|
|
213
|
+
if (screen.type === "entityList") {
|
|
214
|
+
// Empty column list would render as a blank table — almost always the
|
|
215
|
+
// sign of an in-progress screen the author forgot to fill in. Fail
|
|
216
|
+
// loud: ui-core's computeListViewModel can't do anything useful with
|
|
217
|
+
// zero columns either.
|
|
218
|
+
if (screen.columns.length === 0) {
|
|
219
|
+
throw new Error(
|
|
220
|
+
`[Feature ${feature.name}] Screen "${screenId}" (entityList) has an empty columns list — ` +
|
|
221
|
+
`declare at least one column.`,
|
|
222
|
+
);
|
|
223
|
+
}
|
|
224
|
+
for (const col of screen.columns) {
|
|
225
|
+
const normalized = normalizeListColumn(col);
|
|
226
|
+
if (!fieldNames.has(normalized.field)) {
|
|
227
|
+
throw new Error(
|
|
228
|
+
buildUnknownFieldMessage(
|
|
229
|
+
feature.name,
|
|
230
|
+
screenId,
|
|
231
|
+
normalized.field,
|
|
232
|
+
screen.entity,
|
|
233
|
+
fieldNames,
|
|
234
|
+
),
|
|
235
|
+
);
|
|
236
|
+
}
|
|
237
|
+
validateColumnRendererForm(feature.name, screenId, normalized);
|
|
238
|
+
}
|
|
239
|
+
// Pagination/Sort/Search-Validierung: Author-Fehler beim Boot
|
|
240
|
+
// fangen, damit kein "warum kommt die Liste leer / falsch
|
|
241
|
+
// sortiert"-Debug-Cycle zur Laufzeit losgeht.
|
|
242
|
+
if (screen.pageSize !== undefined && screen.pageSize <= 0) {
|
|
243
|
+
throw new Error(
|
|
244
|
+
`[Feature ${feature.name}] Screen "${screenId}" (entityList) has pageSize=${screen.pageSize} — ` +
|
|
245
|
+
`must be a positive integer.`,
|
|
246
|
+
);
|
|
247
|
+
}
|
|
248
|
+
if (screen.defaultSort !== undefined) {
|
|
249
|
+
const sortField = screen.defaultSort.field;
|
|
250
|
+
if (!fieldNames.has(sortField)) {
|
|
251
|
+
throw new Error(
|
|
252
|
+
`[Feature ${feature.name}] Screen "${screenId}" (entityList) defaultSort references unknown ` +
|
|
253
|
+
`field "${sortField}". Known fields: ${[...fieldNames].sort().join(", ")}`,
|
|
254
|
+
);
|
|
255
|
+
}
|
|
256
|
+
// sortable: true Pflicht — verhindert dass das UI auf einer
|
|
257
|
+
// Spalte sortiert, die Server-Side gar keinen DB-Index hat
|
|
258
|
+
// oder im Schema absichtlich nicht sortiert werden soll
|
|
259
|
+
// (Audit-Felder, Computed-Werte). `sortable` lebt heute nur
|
|
260
|
+
// auf TextFieldDef; "in"-narrow lässt das auch für andere
|
|
261
|
+
// Field-Types ohne explizites Flag durchfallen, was ok ist:
|
|
262
|
+
// Number/Date sind natürlich sortierbar, der Author kann sie
|
|
263
|
+
// im Author-Code als sortable markieren wenn das Field-Type
|
|
264
|
+
// es trägt (Erweiterung folgt).
|
|
265
|
+
const fieldDef = entityDef.fields[sortField];
|
|
266
|
+
const isSortable =
|
|
267
|
+
fieldDef !== undefined && "sortable" in fieldDef && fieldDef.sortable === true;
|
|
268
|
+
if (!isSortable) {
|
|
269
|
+
throw new Error(
|
|
270
|
+
`[Feature ${feature.name}] Screen "${screenId}" (entityList) defaultSort.field "${sortField}" ` +
|
|
271
|
+
`is not sortable. Set sortable: true on the field definition or pick another field.`,
|
|
272
|
+
);
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
// Screen-Filter (Tier 2.7c) — drei Layer Author-Code-Check:
|
|
276
|
+
// 1) Field existiert auf der Entity (Tippfehler = leere Liste
|
|
277
|
+
// statt Crash; Boot-Fail ist deutlich besser).
|
|
278
|
+
// 2) Field hat `filterable: true` (Author opt-in, analog zu
|
|
279
|
+
// `sortable`). Verhindert dass Audit-/Computed-/encrypted-
|
|
280
|
+
// Felder unbeabsichtigt filterbar werden.
|
|
281
|
+
// 3) Op passt zum Field-Type. Lt/gt auf text-Feldern → Boot-
|
|
282
|
+
// Fail mit Hinweis statt String-Sort-Surprise zur Laufzeit.
|
|
283
|
+
// Außerdem: "in" verlangt readonly Array.
|
|
284
|
+
if (screen.filter !== undefined) {
|
|
285
|
+
const filterField = screen.filter.field;
|
|
286
|
+
if (!fieldNames.has(filterField)) {
|
|
287
|
+
throw new Error(
|
|
288
|
+
`[Feature ${feature.name}] Screen "${screenId}" (entityList) filter references unknown ` +
|
|
289
|
+
`field "${filterField}". Known fields: ${[...fieldNames].sort().join(", ")}`,
|
|
290
|
+
);
|
|
291
|
+
}
|
|
292
|
+
const fieldDef = entityDef.fields[filterField];
|
|
293
|
+
if (fieldDef !== undefined && !isFieldFilterable(fieldDef)) {
|
|
294
|
+
throw new Error(
|
|
295
|
+
`[Feature ${feature.name}] Screen "${screenId}" (entityList) filter references field ` +
|
|
296
|
+
`"${filterField}" which is not filterable. Set filterable: true on the field ` +
|
|
297
|
+
`definition or pick another field.`,
|
|
298
|
+
);
|
|
299
|
+
}
|
|
300
|
+
if (fieldDef !== undefined) {
|
|
301
|
+
const allowedOps = getAllowedFilterOps(fieldDef);
|
|
302
|
+
if (!allowedOps.includes(screen.filter.op)) {
|
|
303
|
+
throw new Error(
|
|
304
|
+
`[Feature ${feature.name}] Screen "${screenId}" (entityList) filter.op ` +
|
|
305
|
+
`"${screen.filter.op}" is not allowed on field "${filterField}" ` +
|
|
306
|
+
`(type "${fieldDef.type}"). Allowed ops: ${allowedOps.join(", ") || "(none)"}.`,
|
|
307
|
+
);
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
if (screen.filter.op === "in" && !Array.isArray(screen.filter.value)) {
|
|
311
|
+
throw new Error(
|
|
312
|
+
`[Feature ${feature.name}] Screen "${screenId}" (entityList) filter.op "in" requires ` +
|
|
313
|
+
`filter.value to be a readonly array.`,
|
|
314
|
+
);
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
// Tier 2.7e-1: rowActions mit kind:"navigate" pinst dass das
|
|
318
|
+
// referenced screen tatsächlich existiert (selbes Feature). Ein
|
|
319
|
+
// typo'd target landet sonst beim Klick als "Screen not found"-
|
|
320
|
+
// Banner.
|
|
321
|
+
if (screen.rowActions !== undefined) {
|
|
322
|
+
for (const action of screen.rowActions) {
|
|
323
|
+
if (action.kind !== "navigate") continue;
|
|
324
|
+
const candidateQn = qualifyEntityName(feature.name, "screen", action.screen);
|
|
325
|
+
if (!allScreenQns.has(candidateQn)) {
|
|
326
|
+
throw new Error(
|
|
327
|
+
`[Feature ${feature.name}] Screen "${screenId}" (entityList) rowAction "${action.id}" ` +
|
|
328
|
+
`navigate-target "${action.screen}" does not resolve to a registered screen in this feature.`,
|
|
329
|
+
);
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
} else {
|
|
334
|
+
// Same rationale as the columns check: an entityEdit layout with zero
|
|
335
|
+
// sections (or sections without any fields) renders as nothing — reject
|
|
336
|
+
// at boot so the author sees it before the blank form surprises them.
|
|
337
|
+
if (screen.layout.sections.length === 0) {
|
|
338
|
+
throw new Error(
|
|
339
|
+
`[Feature ${feature.name}] Screen "${screenId}" (entityEdit) has an empty sections list — ` +
|
|
340
|
+
`declare at least one section.`,
|
|
341
|
+
);
|
|
342
|
+
}
|
|
343
|
+
for (const section of screen.layout.sections) {
|
|
344
|
+
if (section.fields.length === 0) {
|
|
345
|
+
throw new Error(
|
|
346
|
+
`[Feature ${feature.name}] Screen "${screenId}" (entityEdit) has a section "${section.title}" ` +
|
|
347
|
+
`with zero fields — drop the section or add fields to it.`,
|
|
348
|
+
);
|
|
349
|
+
}
|
|
350
|
+
for (const fieldSpec of section.fields) {
|
|
351
|
+
const normalized = normalizeEditField(fieldSpec);
|
|
352
|
+
if (!fieldNames.has(normalized.field)) {
|
|
353
|
+
throw new Error(
|
|
354
|
+
buildUnknownFieldMessage(
|
|
355
|
+
feature.name,
|
|
356
|
+
screenId,
|
|
357
|
+
normalized.field,
|
|
358
|
+
screen.entity,
|
|
359
|
+
fieldNames,
|
|
360
|
+
),
|
|
361
|
+
);
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
// Form-check für ListColumn-Renderer in der PlatformComponent-Form
|
|
370
|
+
// (`{ react: { __component: "Name" } }`). Der Server kennt die client-
|
|
371
|
+
// seitige columnRenderers-Map nicht — also nur prüfen ob die Struktur
|
|
372
|
+
// stimmt: wenn `react` als Object gesetzt ist, MUSS `__component` ein
|
|
373
|
+
// nicht-leerer String sein. Ein client-seitig ausgelassener Key löst
|
|
374
|
+
// nur eine Warnung aus, kein Boot-Fail.
|
|
375
|
+
export function validateColumnRendererForm(
|
|
376
|
+
featureName: string,
|
|
377
|
+
screenId: string,
|
|
378
|
+
column: { readonly field: string; readonly renderer?: unknown },
|
|
379
|
+
): void {
|
|
380
|
+
const renderer = column.renderer;
|
|
381
|
+
// skip: nur die PlatformComponent-Form ({ react: { __component: "..." } })
|
|
382
|
+
// wird strukturell validiert. Funktions-, String-QN- und null/undefined-
|
|
383
|
+
// Renderer sind alle gültige andere Formen — kein Form-Fehler.
|
|
384
|
+
if (renderer === null || typeof renderer !== "object") return;
|
|
385
|
+
// @cast-boundary schema-walk — feature-config renderer-shape introspection
|
|
386
|
+
const react = (renderer as { react?: unknown }).react;
|
|
387
|
+
// skip: kein react-Branch → entweder native-only oder kein
|
|
388
|
+
// PlatformComponent — beides außerhalb dieses Checks.
|
|
389
|
+
if (react === undefined || react === null) return;
|
|
390
|
+
if (typeof react !== "object") {
|
|
391
|
+
throw new Error(
|
|
392
|
+
`[Feature ${featureName}] Screen "${screenId}" column "${column.field}" has a renderer with ` +
|
|
393
|
+
`a non-object \`react\` branch — expected \`{ react: { __component: "Name" } }\`.`,
|
|
394
|
+
);
|
|
395
|
+
}
|
|
396
|
+
// @cast-boundary schema-walk — feature-config react-branch introspection
|
|
397
|
+
const component = (react as { __component?: unknown }).__component;
|
|
398
|
+
// skip: ohne __component-Schlüssel ist das keine String-Key-Form
|
|
399
|
+
// (z.B. ein zukünftiger direkter Component-Ref); nicht unsere Domäne.
|
|
400
|
+
if (component === undefined) return;
|
|
401
|
+
if (typeof component !== "string" || component.length === 0) {
|
|
402
|
+
throw new Error(
|
|
403
|
+
`[Feature ${featureName}] Screen "${screenId}" column "${column.field}" has a renderer with ` +
|
|
404
|
+
`\`react.__component\` = ${JSON.stringify(component)} — expected a non-empty string identifying ` +
|
|
405
|
+
`a client-side columnRenderers entry.`,
|
|
406
|
+
);
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
export function findEntityFeature(
|
|
411
|
+
entityName: string,
|
|
412
|
+
featureMap: ReadonlyMap<string, FeatureDefinition>,
|
|
413
|
+
): string | undefined {
|
|
414
|
+
for (const [name, feature] of featureMap) {
|
|
415
|
+
if (feature.entities[entityName]) return name;
|
|
416
|
+
}
|
|
417
|
+
return undefined;
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
export function buildUnknownFieldMessage(
|
|
421
|
+
featureName: string,
|
|
422
|
+
screenId: string,
|
|
423
|
+
fieldName: string,
|
|
424
|
+
entityName: string,
|
|
425
|
+
knownFields: ReadonlySet<string>,
|
|
426
|
+
): string {
|
|
427
|
+
const known = [...knownFields].sort().join(", ");
|
|
428
|
+
return (
|
|
429
|
+
`[Feature ${featureName}] Screen "${screenId}" references field "${fieldName}" ` +
|
|
430
|
+
`which does not exist on entity "${entityName}" (known: ${known}).`
|
|
431
|
+
);
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
// --- Nav validation ---
|
|
435
|
+
//
|
|
436
|
+
// The boot-validator runs BEFORE createRegistry builds the final maps, so we
|
|
437
|
+
// pre-build the qualified name sets for screens + navs here. `qualifyEntityName`
|
|
438
|
+
// is the shared helper with the registry — changing the qualification rule
|
|
439
|
+
// in one place flows through both ingest paths.
|
|
440
|
+
|
|
441
|
+
export function collectScreenQns(features: readonly FeatureDefinition[]): Set<string> {
|
|
442
|
+
const set = new Set<string>();
|
|
443
|
+
for (const f of features) {
|
|
444
|
+
for (const screenId of Object.keys(f.screens)) {
|
|
445
|
+
set.add(qualifyEntityName(f.name, "screen", screenId));
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
return set;
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
// Sammelt alle qualifizierten Write-Handler-QNs (`<feature>:write:<short>`).
|
|
452
|
+
// Wird vom actionForm-Screen-Validator genutzt um zu prüfen ob der
|
|
453
|
+
// im Schema deklarierte handler tatsächlich registriert ist —
|
|
454
|
+
// Tippfehler/umbenannte Handler fallen sonst erst zur Laufzeit auf.
|
|
455
|
+
export function collectWriteHandlerQns(features: readonly FeatureDefinition[]): Set<string> {
|
|
456
|
+
const set = new Set<string>();
|
|
457
|
+
for (const f of features) {
|
|
458
|
+
for (const handlerName of Object.keys(f.writeHandlers)) {
|
|
459
|
+
set.add(qualifyEntityName(f.name, "write", handlerName));
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
return set;
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
export function collectNavQns(
|
|
466
|
+
features: readonly FeatureDefinition[],
|
|
467
|
+
): Map<string, NavDefinition & { readonly featureName: string }> {
|
|
468
|
+
const map = new Map<string, NavDefinition & { readonly featureName: string }>();
|
|
469
|
+
for (const f of features) {
|
|
470
|
+
for (const [navId, navDef] of Object.entries(f.navs)) {
|
|
471
|
+
const qualified = qualifyEntityName(f.name, "nav", navId);
|
|
472
|
+
map.set(qualified, { ...navDef, featureName: f.name });
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
return map;
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
// Per-feature ref validation: screen + parent refs point at real QNs. Cycle
|
|
479
|
+
// detection runs once globally afterwards (it's cheaper to do a single DFS
|
|
480
|
+
// over the merged graph than restart it per feature).
|
|
481
|
+
export function validateNavs(
|
|
482
|
+
feature: FeatureDefinition,
|
|
483
|
+
allScreenQns: ReadonlySet<string>,
|
|
484
|
+
allNavQns: ReadonlyMap<string, NavDefinition & { readonly featureName: string }>,
|
|
485
|
+
allWorkspaceQns: ReadonlyMap<string, WorkspaceDefinition & { readonly featureName: string }>,
|
|
486
|
+
): void {
|
|
487
|
+
for (const [navId, navDef] of Object.entries(feature.navs)) {
|
|
488
|
+
if (navDef.screen !== undefined && !allScreenQns.has(navDef.screen)) {
|
|
489
|
+
throw new Error(
|
|
490
|
+
`[Feature ${feature.name}] Nav entry "${navId}" references screen "${navDef.screen}" ` +
|
|
491
|
+
`which is not registered. Expected a qualified name of the form ` +
|
|
492
|
+
`"<feature>:screen:<id>" pointing at an r.screen() declaration.`,
|
|
493
|
+
);
|
|
494
|
+
}
|
|
495
|
+
if (navDef.parent !== undefined && !allNavQns.has(navDef.parent)) {
|
|
496
|
+
throw new Error(
|
|
497
|
+
`[Feature ${feature.name}] Nav entry "${navId}" references parent "${navDef.parent}" ` +
|
|
498
|
+
`which is not a registered nav entry. Expected a qualified name of the form ` +
|
|
499
|
+
`"<feature>:nav:<id>".`,
|
|
500
|
+
);
|
|
501
|
+
}
|
|
502
|
+
if (navDef.workspaces !== undefined) {
|
|
503
|
+
for (const wsQn of navDef.workspaces) {
|
|
504
|
+
if (!allWorkspaceQns.has(wsQn)) {
|
|
505
|
+
throw new Error(
|
|
506
|
+
`[Feature ${feature.name}] Nav entry "${navId}" self-assigns to workspace "${wsQn}" ` +
|
|
507
|
+
`which is not registered. Expected a qualified name of the form ` +
|
|
508
|
+
`"<feature>:workspace:<id>" pointing at an r.workspace() declaration.`,
|
|
509
|
+
);
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
// Walks parent-refs across ALL nav entries (cross-feature). A cycle here
|
|
517
|
+
// would crash client-side tree assembly — easier to fail loud at boot than
|
|
518
|
+
// to debug a React "Maximum update depth exceeded" stack trace.
|
|
519
|
+
export function validateNavCycles(
|
|
520
|
+
allNavQns: ReadonlyMap<string, NavDefinition & { readonly featureName: string }>,
|
|
521
|
+
): void {
|
|
522
|
+
const visited = new Set<string>();
|
|
523
|
+
const stack = new Set<string>();
|
|
524
|
+
|
|
525
|
+
function visit(qualified: string, path: string[]): void {
|
|
526
|
+
if (stack.has(qualified)) {
|
|
527
|
+
throw new Error(
|
|
528
|
+
`[Kumiko Nav] Nav entry parent cycle detected: ${[...path, qualified].join(" → ")}`,
|
|
529
|
+
);
|
|
530
|
+
}
|
|
531
|
+
// skip: already visited — cycle-detection only needs to traverse each
|
|
532
|
+
// node once, and the `stack` check above catches any actual cycles
|
|
533
|
+
// reached via a different path.
|
|
534
|
+
if (visited.has(qualified)) return;
|
|
535
|
+
visited.add(qualified);
|
|
536
|
+
stack.add(qualified);
|
|
537
|
+
const navDef = allNavQns.get(qualified);
|
|
538
|
+
if (navDef?.parent) {
|
|
539
|
+
visit(navDef.parent, [...path, qualified]);
|
|
540
|
+
}
|
|
541
|
+
stack.delete(qualified);
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
for (const qualified of allNavQns.keys()) {
|
|
545
|
+
visit(qualified, []);
|
|
546
|
+
}
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
// Roles we recognise at boot time. The framework has no explicit
|
|
550
|
+
// role-registry (r.defineRoles is a type helper only), so we synthesise
|
|
551
|
+
// one from every handler-access rule plus the "all"/"system" built-ins.
|
|
552
|
+
export function collectKnownRoles(features: readonly FeatureDefinition[]): Set<string> {
|
|
553
|
+
const roles = new Set<string>(["all", "system"]);
|
|
554
|
+
for (const f of features) {
|
|
555
|
+
for (const def of Object.values(f.writeHandlers)) {
|
|
556
|
+
if (def.access && "roles" in def.access) {
|
|
557
|
+
for (const r of def.access.roles) roles.add(r);
|
|
558
|
+
}
|
|
559
|
+
}
|
|
560
|
+
for (const def of Object.values(f.queryHandlers)) {
|
|
561
|
+
if (def.access && "roles" in def.access) {
|
|
562
|
+
for (const r of def.access.roles) roles.add(r);
|
|
563
|
+
}
|
|
564
|
+
}
|
|
565
|
+
}
|
|
566
|
+
return roles;
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
// --- Workspace validation ---
|
|
570
|
+
//
|
|
571
|
+
// Per-app workspace registry, built once up front. Carries `featureName`
|
|
572
|
+
// alongside the definition so error messages can point at the offending
|
|
573
|
+
// feature without a parallel reverse index.
|
|
574
|
+
|
|
575
|
+
export function collectWorkspaceQns(
|
|
576
|
+
features: readonly FeatureDefinition[],
|
|
577
|
+
): Map<string, WorkspaceDefinition & { readonly featureName: string }> {
|
|
578
|
+
const map = new Map<string, WorkspaceDefinition & { readonly featureName: string }>();
|
|
579
|
+
for (const f of features) {
|
|
580
|
+
for (const [wsId, wsDef] of Object.entries(f.workspaces)) {
|
|
581
|
+
const qualified = qualifyEntityName(f.name, "workspace", wsId);
|
|
582
|
+
map.set(qualified, { ...wsDef, featureName: f.name });
|
|
583
|
+
}
|
|
584
|
+
}
|
|
585
|
+
return map;
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
export function validateWorkspaces(
|
|
589
|
+
feature: FeatureDefinition,
|
|
590
|
+
allNavQns: ReadonlyMap<string, NavDefinition & { readonly featureName: string }>,
|
|
591
|
+
): void {
|
|
592
|
+
for (const [wsId, wsDef] of Object.entries(feature.workspaces)) {
|
|
593
|
+
if (wsDef.nav !== undefined) {
|
|
594
|
+
for (const navQn of wsDef.nav) {
|
|
595
|
+
if (!allNavQns.has(navQn)) {
|
|
596
|
+
throw new Error(
|
|
597
|
+
`[Feature ${feature.name}] Workspace "${wsId}" references nav "${navQn}" ` +
|
|
598
|
+
`which is not registered. Expected a qualified name of the form ` +
|
|
599
|
+
`"<feature>:nav:<id>" pointing at an r.nav() declaration.`,
|
|
600
|
+
);
|
|
601
|
+
}
|
|
602
|
+
}
|
|
603
|
+
}
|
|
604
|
+
}
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
// Single-default rule across the entire app. Mirrors how createApp validates
|
|
608
|
+
// roles up front — a second `default: true` is a configuration error, not a
|
|
609
|
+
// runtime fallback. Apps without any default fall back to "first workspace
|
|
610
|
+
// the user has access to" at render time (handled by shellWorkspaces).
|
|
611
|
+
export function validateDefaultWorkspaceUniqueness(
|
|
612
|
+
allWorkspaceQns: ReadonlyMap<string, WorkspaceDefinition & { readonly featureName: string }>,
|
|
613
|
+
): void {
|
|
614
|
+
const defaults: string[] = [];
|
|
615
|
+
for (const [qn, ws] of allWorkspaceQns) {
|
|
616
|
+
if (ws.default === true) defaults.push(qn);
|
|
617
|
+
}
|
|
618
|
+
if (defaults.length > 1) {
|
|
619
|
+
throw new Error(
|
|
620
|
+
`[Kumiko Workspaces] Multiple workspaces declare default: true — ` +
|
|
621
|
+
`${defaults.join(", ")}. At most one workspace per app may be the default.`,
|
|
622
|
+
);
|
|
623
|
+
}
|
|
624
|
+
}
|