@cosmicdrift/kumiko-framework 0.2.3 → 0.4.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 (166) hide show
  1. package/CHANGELOG.md +93 -0
  2. package/package.json +124 -39
  3. package/src/__tests__/full-stack.integration.ts +2 -2
  4. package/src/api/auth-routes.ts +5 -5
  5. package/src/api/jwt.ts +2 -2
  6. package/src/api/route-registrars.ts +1 -1
  7. package/src/api/routes.ts +3 -3
  8. package/src/api/server.ts +6 -7
  9. package/src/compliance/profiles.ts +8 -8
  10. package/src/db/assert-exists-in.ts +2 -2
  11. package/src/db/cursor.ts +3 -3
  12. package/src/db/event-store-executor.ts +19 -13
  13. package/src/db/located-timestamp.ts +1 -1
  14. package/src/db/money.ts +12 -2
  15. package/src/db/pg-error.ts +1 -1
  16. package/src/db/row-helpers.ts +1 -1
  17. package/src/db/table-builder.ts +3 -5
  18. package/src/db/tenant-db.ts +9 -9
  19. package/src/engine/__tests__/_pipeline-test-utils.ts +23 -0
  20. package/src/engine/__tests__/build-target.test.ts +135 -0
  21. package/src/engine/__tests__/codemod-pipeline.test.ts +551 -0
  22. package/src/engine/__tests__/entity-handlers.test.ts +3 -3
  23. package/src/engine/__tests__/event-helpers.test.ts +4 -4
  24. package/src/engine/__tests__/pipeline-engine.test.ts +215 -0
  25. package/src/engine/__tests__/pipeline-handler.integration.ts +894 -0
  26. package/src/engine/__tests__/pipeline-observability.integration.ts +142 -0
  27. package/src/engine/__tests__/pipeline-performance.integration.ts +152 -0
  28. package/src/engine/__tests__/pipeline-sub-pipelines.test.ts +288 -0
  29. package/src/engine/__tests__/raw-table.test.ts +2 -2
  30. package/src/engine/__tests__/steps-aggregate-append-event.test.ts +115 -0
  31. package/src/engine/__tests__/steps-aggregate-create.test.ts +92 -0
  32. package/src/engine/__tests__/steps-aggregate-update.test.ts +127 -0
  33. package/src/engine/__tests__/steps-call-feature.test.ts +123 -0
  34. package/src/engine/__tests__/steps-mail-send.test.ts +136 -0
  35. package/src/engine/__tests__/steps-read.test.ts +142 -0
  36. package/src/engine/__tests__/steps-resolver-utils.test.ts +50 -0
  37. package/src/engine/__tests__/steps-unsafe-projection-delete.test.ts +69 -0
  38. package/src/engine/__tests__/steps-unsafe-projection-upsert.test.ts +117 -0
  39. package/src/engine/__tests__/steps-webhook-send.test.ts +135 -0
  40. package/src/engine/__tests__/steps-workflow.test.ts +198 -0
  41. package/src/engine/__tests__/validate-projection-allowlist.test.ts +491 -0
  42. package/src/engine/__tests__/visual-tree-patterns.test.ts +251 -0
  43. package/src/engine/boot-validator/api-ext.ts +77 -0
  44. package/src/engine/boot-validator/config-deps.ts +163 -0
  45. package/src/engine/boot-validator/entity-handler.ts +466 -0
  46. package/src/engine/boot-validator/index.ts +159 -0
  47. package/src/engine/boot-validator/ownership.ts +198 -0
  48. package/src/engine/boot-validator/pii-retention.ts +155 -0
  49. package/src/engine/boot-validator/screens-nav.ts +624 -0
  50. package/src/engine/boot-validator.ts +1 -1804
  51. package/src/engine/build-app-schema.ts +1 -1
  52. package/src/engine/build-target.ts +99 -0
  53. package/src/engine/codemod/index.ts +15 -0
  54. package/src/engine/codemod/pipeline-codemod.ts +641 -0
  55. package/src/engine/config-helpers.ts +9 -19
  56. package/src/engine/constants.ts +1 -1
  57. package/src/engine/define-feature.ts +88 -9
  58. package/src/engine/define-handler.ts +89 -3
  59. package/src/engine/define-roles.ts +2 -2
  60. package/src/engine/define-step.ts +28 -0
  61. package/src/engine/define-workflow.ts +110 -0
  62. package/src/engine/entity-handlers.ts +10 -9
  63. package/src/engine/event-helpers.ts +4 -4
  64. package/src/engine/factories.ts +12 -12
  65. package/src/engine/feature-ast/__tests__/visual-tree-parse.test.ts +184 -0
  66. package/src/engine/feature-ast/extractors/index.ts +74 -0
  67. package/src/engine/feature-ast/extractors/round1.ts +110 -0
  68. package/src/engine/feature-ast/extractors/round2.ts +253 -0
  69. package/src/engine/feature-ast/extractors/round3.ts +471 -0
  70. package/src/engine/feature-ast/extractors/round4.ts +1365 -0
  71. package/src/engine/feature-ast/extractors/round5.ts +72 -0
  72. package/src/engine/feature-ast/extractors/round6.ts +66 -0
  73. package/src/engine/feature-ast/extractors/shared.ts +177 -0
  74. package/src/engine/feature-ast/parse.ts +7 -0
  75. package/src/engine/feature-ast/patch.ts +9 -1
  76. package/src/engine/feature-ast/patcher.ts +10 -3
  77. package/src/engine/feature-ast/patterns.ts +49 -1
  78. package/src/engine/feature-ast/render.ts +17 -1
  79. package/src/engine/index.ts +44 -2
  80. package/src/engine/pattern-library/__tests__/library.test.ts +6 -0
  81. package/src/engine/pattern-library/library.ts +42 -2
  82. package/src/engine/pipeline.ts +88 -0
  83. package/src/engine/projection-helpers.ts +1 -1
  84. package/src/engine/read-claim.ts +1 -1
  85. package/src/engine/registry.ts +30 -2
  86. package/src/engine/resolve-config-or-param.ts +4 -0
  87. package/src/engine/run-pipeline.ts +162 -0
  88. package/src/engine/schema-builder.ts +2 -4
  89. package/src/engine/state-machine.ts +1 -1
  90. package/src/engine/steps/_drizzle-boundary.ts +19 -0
  91. package/src/engine/steps/_duration-utils.ts +33 -0
  92. package/src/engine/steps/_no-return-guard.ts +21 -0
  93. package/src/engine/steps/_resolver-utils.ts +42 -0
  94. package/src/engine/steps/_step-dispatch-constants.ts +38 -0
  95. package/src/engine/steps/aggregate-append-event.ts +56 -0
  96. package/src/engine/steps/aggregate-create.ts +56 -0
  97. package/src/engine/steps/aggregate-update.ts +68 -0
  98. package/src/engine/steps/branch.ts +84 -0
  99. package/src/engine/steps/call-feature.ts +49 -0
  100. package/src/engine/steps/compute.ts +41 -0
  101. package/src/engine/steps/for-each.ts +111 -0
  102. package/src/engine/steps/mail-send.ts +44 -0
  103. package/src/engine/steps/read-find-many.ts +51 -0
  104. package/src/engine/steps/read-find-one.ts +58 -0
  105. package/src/engine/steps/retry.ts +87 -0
  106. package/src/engine/steps/return.ts +34 -0
  107. package/src/engine/steps/unsafe-projection-delete.ts +46 -0
  108. package/src/engine/steps/unsafe-projection-upsert.ts +69 -0
  109. package/src/engine/steps/wait-for-event.ts +71 -0
  110. package/src/engine/steps/wait.ts +69 -0
  111. package/src/engine/steps/webhook-send.ts +71 -0
  112. package/src/engine/system-user.ts +1 -1
  113. package/src/engine/types/feature.ts +93 -1
  114. package/src/engine/types/handlers.ts +18 -10
  115. package/src/engine/types/index.ts +11 -1
  116. package/src/engine/types/step.ts +334 -0
  117. package/src/engine/types/target-ref.ts +21 -0
  118. package/src/engine/types/tree-node.ts +132 -0
  119. package/src/engine/types/workspace.ts +7 -0
  120. package/src/engine/validate-projection-allowlist.ts +161 -0
  121. package/src/event-store/snapshot.ts +1 -1
  122. package/src/event-store/upcaster-dead-letter.ts +1 -1
  123. package/src/event-store/upcaster.ts +1 -1
  124. package/src/files/file-routes.ts +1 -1
  125. package/src/files/types.ts +2 -2
  126. package/src/jobs/job-runner.ts +10 -10
  127. package/src/lifecycle/lifecycle.ts +0 -3
  128. package/src/logging/index.ts +1 -0
  129. package/src/logging/pino-logger.ts +11 -7
  130. package/src/logging/utils.ts +24 -0
  131. package/src/observability/prometheus-meter.ts +7 -5
  132. package/src/pipeline/__tests__/archive-stream.integration.ts +1 -1
  133. package/src/pipeline/__tests__/causation-chain.integration.ts +1 -1
  134. package/src/pipeline/__tests__/domain-events-projections.integration.ts +3 -3
  135. package/src/pipeline/__tests__/event-define-event-strict.integration.ts +4 -4
  136. package/src/pipeline/__tests__/load-aggregate-query.integration.ts +1 -1
  137. package/src/pipeline/__tests__/msp-multi-hop.integration.ts +3 -3
  138. package/src/pipeline/__tests__/msp-rebuild.integration.ts +3 -3
  139. package/src/pipeline/__tests__/multi-stream-projection.integration.ts +2 -2
  140. package/src/pipeline/__tests__/query-projection.integration.ts +5 -5
  141. package/src/pipeline/append-event-core.ts +22 -6
  142. package/src/pipeline/dispatcher-utils.ts +188 -0
  143. package/src/pipeline/dispatcher.ts +63 -283
  144. package/src/pipeline/distributed-lock.ts +1 -1
  145. package/src/pipeline/entity-cache.ts +2 -2
  146. package/src/pipeline/event-consumer-state.ts +0 -13
  147. package/src/pipeline/event-dispatcher.ts +4 -4
  148. package/src/pipeline/index.ts +0 -2
  149. package/src/pipeline/lifecycle-pipeline.ts +6 -12
  150. package/src/pipeline/msp-rebuild.ts +5 -5
  151. package/src/pipeline/multi-stream-apply-context.ts +6 -7
  152. package/src/pipeline/projection-rebuild.ts +2 -2
  153. package/src/pipeline/projection-state.ts +0 -12
  154. package/src/rate-limit/__tests__/resolver.integration.ts +8 -4
  155. package/src/rate-limit/resolver.ts +1 -1
  156. package/src/search/in-memory-adapter.ts +1 -1
  157. package/src/search/meilisearch-adapter.ts +3 -3
  158. package/src/search/types.ts +1 -1
  159. package/src/secrets/leak-guard.ts +2 -2
  160. package/src/stack/request-helper.ts +9 -5
  161. package/src/stack/test-stack.ts +1 -1
  162. package/src/testing/handler-context.ts +4 -4
  163. package/src/testing/http-cookies.ts +1 -1
  164. package/src/time/tz-context.ts +1 -2
  165. package/src/ui-types/index.ts +4 -0
  166. package/src/engine/feature-ast/extractors.ts +0 -2602
@@ -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
+ }