@cosmicdrift/kumiko-framework 1.0.0 → 2.0.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 (424) hide show
  1. package/README.md +9 -38
  2. package/package.json +21 -2
  3. package/src/__tests__/anonymous-access.integration.test.ts +185 -0
  4. package/src/__tests__/consumer-cli.integration.test.ts +172 -0
  5. package/src/__tests__/entity-permalink-open.integration.test.ts +94 -0
  6. package/src/__tests__/full-stack.integration.test.ts +1 -1
  7. package/src/__tests__/schema-cli-temporal-polyfill.test.ts +48 -0
  8. package/src/__tests__/schema-cli.integration.test.ts +50 -0
  9. package/src/__tests__/store-table.integration.test.ts +93 -0
  10. package/src/api/__tests__/api.test.ts +373 -0
  11. package/src/api/__tests__/auth-middleware-anonymous-access-boot.test.ts +40 -0
  12. package/src/api/__tests__/auth-routes-cookie.test.ts +17 -1
  13. package/src/api/__tests__/auth-routes-invalid-body-invite.test.ts +253 -0
  14. package/src/api/__tests__/auth-routes-mfa-preauth-confirm.test.ts +222 -0
  15. package/src/api/__tests__/auth-routes-mfa-preauth-enable-start.test.ts +249 -0
  16. package/src/api/__tests__/auth-routes-mfa-verify.test.ts +202 -0
  17. package/src/api/__tests__/auth-routes-trusted-proxy.test.ts +135 -0
  18. package/src/api/__tests__/batch.integration.test.ts +101 -11
  19. package/src/api/__tests__/csrf-constants-sync.test.ts +20 -0
  20. package/src/api/__tests__/dispatcher-live.integration.test.ts +74 -0
  21. package/src/api/__tests__/jwt.test.ts +200 -1
  22. package/src/api/__tests__/login-rate-limiter-sweep.test.ts +50 -0
  23. package/src/api/__tests__/pat-scope.test.ts +36 -0
  24. package/src/api/__tests__/pii-leak-guard.integration.test.ts +103 -0
  25. package/src/api/__tests__/redis-login-rate-limiter.integration.test.ts +138 -0
  26. package/src/api/__tests__/request-id-middleware.test.ts +51 -0
  27. package/src/api/__tests__/server-boot-guards.test.ts +71 -0
  28. package/src/api/__tests__/server-jwt-ttl.test.ts +58 -0
  29. package/src/api/__tests__/sse-broker.test.ts +57 -0
  30. package/src/api/__tests__/sse-route.test.ts +4 -0
  31. package/src/api/api-constants.ts +11 -0
  32. package/src/api/auth-middleware.ts +248 -40
  33. package/src/api/auth-routes.ts +576 -95
  34. package/src/api/index.ts +13 -4
  35. package/src/api/jwt.ts +170 -11
  36. package/src/api/pat-scope.ts +14 -0
  37. package/src/api/pii-leak-guard.ts +47 -0
  38. package/src/api/request-context.ts +3 -0
  39. package/src/api/request-id-middleware.ts +2 -0
  40. package/src/api/routes.ts +169 -2
  41. package/src/api/server.ts +112 -16
  42. package/src/api/sse-broker.ts +39 -0
  43. package/src/bun-db/__tests__/PATTERN.md +0 -1
  44. package/src/bun-db/__tests__/coerce-row-temporal.test.ts +41 -0
  45. package/src/bun-db/__tests__/select-many-retry.test.ts +79 -0
  46. package/src/bun-db/__tests__/write-brand.test.ts +21 -0
  47. package/src/bun-db/connection.ts +3 -3
  48. package/src/bun-db/index.ts +2 -0
  49. package/src/bun-db/query.ts +105 -32
  50. package/src/consumer-cli.ts +134 -0
  51. package/src/crypto/__tests__/blind-index.test.ts +130 -0
  52. package/src/crypto/__tests__/event-pii.test.ts +161 -0
  53. package/src/crypto/__tests__/kek-rotation.integration.test.ts +180 -0
  54. package/src/crypto/__tests__/kms-adapter-contract.ts +134 -0
  55. package/src/crypto/__tests__/kms-adapter.contract.test.ts +4 -0
  56. package/src/crypto/__tests__/pg-kms-adapter.integration.test.ts +117 -0
  57. package/src/crypto/__tests__/pii-field-encryption.test.ts +376 -0
  58. package/src/crypto/__tests__/request-kms-cache.test.ts +74 -0
  59. package/src/crypto/__tests__/subject-resolver.test.ts +108 -0
  60. package/src/crypto/blind-index.ts +118 -0
  61. package/src/crypto/event-pii.ts +70 -0
  62. package/src/crypto/in-memory-kms-adapter.ts +50 -0
  63. package/src/crypto/index.ts +67 -0
  64. package/src/crypto/kms-adapter.ts +2 -0
  65. package/src/crypto/pg-kms-adapter.ts +295 -0
  66. package/src/crypto/pii-field-encryption.ts +248 -0
  67. package/src/crypto/request-kms-cache.ts +38 -0
  68. package/src/crypto/subject-resolver.ts +91 -0
  69. package/src/db/__tests__/assert-no-unreachable-live-rows.integration.test.ts +191 -0
  70. package/src/db/__tests__/blind-index.integration.test.ts +248 -0
  71. package/src/db/__tests__/build-filter-where.test.ts +34 -0
  72. package/src/db/__tests__/collect-table-metas.test.ts +10 -10
  73. package/src/db/__tests__/config-seed.integration.test.ts +13 -5
  74. package/src/db/__tests__/dialect-instant.test.ts +1 -4
  75. package/src/db/__tests__/entity-field-encryption.test.ts +7 -7
  76. package/src/db/__tests__/entity-table-meta-source.test.ts +50 -0
  77. package/src/db/__tests__/event-store-executor-context.pii-roundtrip.test.ts +67 -0
  78. package/src/db/__tests__/event-store-executor-write-verbs.integration.test.ts +402 -0
  79. package/src/db/__tests__/event-store-executor.integration.test.ts +299 -29
  80. package/src/db/__tests__/feature-table-sources.test.ts +34 -0
  81. package/src/db/__tests__/implicit-projection-equivalence.integration.test.ts +118 -39
  82. package/src/db/__tests__/instant-to-driver-temporal.test.ts +21 -0
  83. package/src/db/__tests__/located-timestamp.test.ts +19 -0
  84. package/src/db/__tests__/migrate-generator.test.ts +21 -0
  85. package/src/db/__tests__/migrate-runner.test.ts +61 -0
  86. package/src/db/__tests__/number-field-fractional.integration.test.ts +58 -0
  87. package/src/db/__tests__/rebuild-marker.test.ts +13 -3
  88. package/src/db/__tests__/replay-migration-sql.test.ts +384 -0
  89. package/src/db/__tests__/schema-inspection.test.ts +7 -0
  90. package/src/db/__tests__/schema-migration.integration.test.ts +1 -1
  91. package/src/db/__tests__/table-builder-meta-lockstep.test.ts +39 -0
  92. package/src/db/__tests__/tenant-db-where-merge.test.ts +49 -3
  93. package/src/db/__tests__/tenant-db.integration.test.ts +6 -2
  94. package/src/db/api.ts +2 -2
  95. package/src/db/apply-entity-event.ts +18 -14
  96. package/src/db/blind-index-cleanup.ts +55 -0
  97. package/src/db/bun-provider.ts +2 -2
  98. package/src/db/collect-table-metas.ts +6 -7
  99. package/src/db/config-seed.ts +9 -9
  100. package/src/db/connection.ts +7 -12
  101. package/src/db/cursor.ts +1 -18
  102. package/src/db/dialect.ts +20 -26
  103. package/src/db/entity-field-encryption.ts +81 -24
  104. package/src/db/entity-table-meta-types.ts +2 -0
  105. package/src/db/entity-table-meta.ts +63 -90
  106. package/src/db/event-store-executor-context.ts +404 -0
  107. package/src/db/event-store-executor-read.ts +275 -0
  108. package/src/db/event-store-executor-write.ts +644 -0
  109. package/src/db/event-store-executor.ts +28 -1155
  110. package/src/db/feature-table-sources.ts +6 -5
  111. package/src/db/index.ts +20 -3
  112. package/src/db/located-timestamp.ts +4 -0
  113. package/src/db/migrate-generator.ts +39 -6
  114. package/src/db/migrate-runner.ts +107 -11
  115. package/src/db/pg-error.ts +9 -1
  116. package/src/db/postgres-provider.ts +2 -2
  117. package/src/db/queries/__tests__/event-store-idempotency-index.integration.test.ts +80 -0
  118. package/src/db/queries/backfill-pii.ts +277 -0
  119. package/src/db/queries/ddl.ts +45 -0
  120. package/src/db/queries/event-consumer.ts +35 -2
  121. package/src/db/queries/event-store.ts +126 -19
  122. package/src/db/queries/projection-rebuild.ts +22 -8
  123. package/src/db/queries/shadow-swap.ts +183 -0
  124. package/src/db/queries/test-stack.ts +4 -30
  125. package/src/db/query-api.ts +1 -0
  126. package/src/db/query.ts +1 -0
  127. package/src/db/rebuild-marker.ts +18 -2
  128. package/src/db/reference-data.ts +2 -3
  129. package/src/db/replay-migration-sql.ts +257 -0
  130. package/src/db/schema-inspection.ts +1 -1
  131. package/src/db/table-builder.ts +102 -71
  132. package/src/db/tenant-db.ts +15 -53
  133. package/src/engine/__tests__/boot-validator-action-wiring.test.ts +242 -0
  134. package/src/engine/__tests__/boot-validator-boot-check.test.ts +99 -0
  135. package/src/engine/__tests__/boot-validator-dashboard.test.ts +237 -0
  136. package/src/engine/__tests__/boot-validator-entity-list.test.ts +104 -0
  137. package/src/engine/__tests__/boot-validator-gdpr-storage.test.ts +7 -69
  138. package/src/engine/__tests__/boot-validator-i18n-keys.test.ts +26 -5
  139. package/src/engine/__tests__/boot-validator-located-timestamps.test.ts +3 -19
  140. package/src/engine/__tests__/boot-validator-pii-retention.test.ts +246 -4
  141. package/src/engine/__tests__/boot-validator.test.ts +219 -15
  142. package/src/engine/__tests__/build-app-schema.test.ts +82 -0
  143. package/src/engine/__tests__/build-config-feature-schema.test.ts +125 -0
  144. package/src/engine/__tests__/build-target.test.ts +3 -11
  145. package/src/engine/__tests__/codemod-pipeline.test.ts +152 -21
  146. package/src/engine/__tests__/config-helpers.test.ts +16 -0
  147. package/src/engine/__tests__/define-feature-entity-mapping.test.ts +6 -0
  148. package/src/engine/__tests__/define-roles.test.ts +21 -0
  149. package/src/engine/__tests__/engine.test.ts +163 -1
  150. package/src/engine/__tests__/entity-handlers.test.ts +80 -0
  151. package/src/engine/__tests__/event-migration-declarative.test.ts +65 -0
  152. package/src/engine/__tests__/event-type-map-augmentation.test.ts +24 -0
  153. package/src/engine/__tests__/extend-entity-projection.test.ts +123 -0
  154. package/src/engine/__tests__/factories-time.test.ts +2 -66
  155. package/src/engine/__tests__/feature-crud-shorthand.test.ts +28 -0
  156. package/src/engine/__tests__/feature-manifest.test.ts +31 -1
  157. package/src/engine/__tests__/field-access.test.ts +23 -1
  158. package/src/engine/__tests__/hook-phases.test.ts +5 -5
  159. package/src/engine/__tests__/membership-roles.test.ts +4 -10
  160. package/src/engine/__tests__/nav.test.ts +86 -1
  161. package/src/engine/__tests__/pipeline-engine.test.ts +9 -9
  162. package/src/engine/__tests__/pipeline-handler.integration.test.ts +18 -18
  163. package/src/engine/__tests__/pipeline-observability.integration.test.ts +2 -2
  164. package/src/engine/__tests__/pipeline-performance.integration.test.ts +3 -3
  165. package/src/engine/__tests__/pipeline-sub-pipelines.test.ts +9 -9
  166. package/src/engine/__tests__/post-query-hook.test.ts +7 -7
  167. package/src/engine/__tests__/registrar-object-form.test.ts +141 -0
  168. package/src/engine/__tests__/registry.test.ts +144 -0
  169. package/src/engine/__tests__/schema-builder.test.ts +18 -0
  170. package/src/engine/__tests__/screen.test.ts +147 -1
  171. package/src/engine/__tests__/soft-delete-cleanup.test.ts +3 -0
  172. package/src/engine/__tests__/store-table.test.ts +229 -0
  173. package/src/engine/__tests__/tier-resolver-extension.test.ts +19 -1
  174. package/src/engine/__tests__/{visual-tree-patterns.test.ts → tree-actions-patterns.test.ts} +7 -95
  175. package/src/engine/__tests__/validate-projection-allowlist.test.ts +15 -15
  176. package/src/engine/boot-validator/__tests__/config-deps.test.ts +92 -0
  177. package/src/engine/boot-validator/action-wiring.ts +149 -0
  178. package/src/engine/boot-validator/boot-check.ts +21 -0
  179. package/src/engine/boot-validator/config-deps.ts +40 -4
  180. package/src/engine/boot-validator/entity-handler.ts +20 -21
  181. package/src/engine/boot-validator/entity-list-screens.ts +88 -0
  182. package/src/engine/boot-validator/gdpr-storage.ts +0 -20
  183. package/src/engine/boot-validator/i18n-keys.ts +58 -4
  184. package/src/engine/boot-validator/index.ts +33 -12
  185. package/src/engine/boot-validator/nav.ts +125 -0
  186. package/src/engine/boot-validator/pii-retention.ts +95 -10
  187. package/src/engine/boot-validator/{screens-nav.ts → screens.ts} +214 -191
  188. package/src/engine/boot-validator/workspaces.ts +68 -0
  189. package/src/engine/build-app-schema.ts +16 -0
  190. package/src/engine/build-config-feature-schema.ts +44 -7
  191. package/src/engine/codemod/pipeline-codemod.ts +5 -5
  192. package/src/engine/config-helpers.ts +15 -0
  193. package/src/engine/constants.ts +32 -6
  194. package/src/engine/create-app.ts +11 -0
  195. package/src/engine/define-feature.ts +95 -947
  196. package/src/engine/define-handler.ts +28 -94
  197. package/src/engine/define-workflow.ts +1 -1
  198. package/src/engine/effective-features.ts +12 -2
  199. package/src/engine/entity-handlers.ts +76 -11
  200. package/src/engine/extensions/tenant-data.ts +19 -0
  201. package/src/engine/extensions/user-data.ts +29 -2
  202. package/src/engine/factories.ts +8 -49
  203. package/src/engine/feature-ast/__tests__/canonical-form.test.ts +26 -29
  204. package/src/engine/feature-ast/__tests__/fixtures/cross-file-registrar/feature.ts +9 -0
  205. package/src/engine/feature-ast/__tests__/fixtures/cross-file-registrar/screens.ts +4 -0
  206. package/src/engine/feature-ast/__tests__/parse-happy-path.test.ts +1 -2
  207. package/src/engine/feature-ast/__tests__/parse-real-features.test.ts +18 -8
  208. package/src/engine/feature-ast/__tests__/parse.test.ts +1640 -160
  209. package/src/engine/feature-ast/__tests__/patch.test.ts +206 -12
  210. package/src/engine/feature-ast/__tests__/patcher.test.ts +20 -23
  211. package/src/engine/feature-ast/__tests__/render-roundtrip.test.ts +380 -5
  212. package/src/engine/feature-ast/extractors/events.ts +322 -0
  213. package/src/engine/feature-ast/extractors/handlers.ts +234 -0
  214. package/src/engine/feature-ast/extractors/hooks.ts +243 -0
  215. package/src/engine/feature-ast/extractors/index.ts +34 -31
  216. package/src/engine/feature-ast/extractors/jobs-routes.ts +221 -0
  217. package/src/engine/feature-ast/extractors/projections-screens.ts +269 -0
  218. package/src/engine/feature-ast/extractors/round1.ts +3 -3
  219. package/src/engine/feature-ast/extractors/round5.ts +3 -3
  220. package/src/engine/feature-ast/extractors/round6.ts +2 -36
  221. package/src/engine/feature-ast/extractors/shared.ts +64 -6
  222. package/src/engine/feature-ast/index.ts +2 -4
  223. package/src/engine/feature-ast/parse.ts +130 -23
  224. package/src/engine/feature-ast/patch.ts +39 -50
  225. package/src/engine/feature-ast/patcher.ts +37 -38
  226. package/src/engine/feature-ast/patterns.ts +50 -62
  227. package/src/engine/feature-ast/render.ts +67 -44
  228. package/src/engine/feature-builder-state.ts +168 -0
  229. package/src/engine/feature-config-events-jobs.ts +403 -0
  230. package/src/engine/feature-entity-handlers.ts +208 -0
  231. package/src/engine/feature-manifest.ts +2 -1
  232. package/src/engine/feature-ui-extensions.ts +500 -0
  233. package/src/engine/field-access.ts +13 -2
  234. package/src/engine/field-helpers.ts +31 -0
  235. package/src/engine/handler-helpers.ts +26 -0
  236. package/src/engine/hook-helpers.ts +16 -0
  237. package/src/engine/index.ts +23 -7
  238. package/src/engine/membership-roles.ts +13 -0
  239. package/src/engine/object-form.ts +27 -0
  240. package/src/engine/ownership.ts +26 -79
  241. package/src/engine/pattern-library/__tests__/library.test.ts +7 -20
  242. package/src/engine/pattern-library/library.ts +44 -1152
  243. package/src/engine/pattern-library/mixed-schemas.ts +450 -0
  244. package/src/engine/pattern-library/opaque-schemas.ts +124 -0
  245. package/src/engine/pattern-library/shared-fields.ts +75 -0
  246. package/src/engine/pattern-library/static-schemas.ts +456 -0
  247. package/src/engine/pipeline.ts +6 -11
  248. package/src/engine/registry-facade.ts +362 -0
  249. package/src/engine/registry-ingest.ts +478 -0
  250. package/src/engine/registry-state.ts +388 -0
  251. package/src/engine/registry-validate.ts +642 -0
  252. package/src/engine/registry.ts +78 -1658
  253. package/src/engine/run-pipeline.ts +1 -1
  254. package/src/engine/schema-builder.ts +1 -0
  255. package/src/engine/screen-helpers.ts +54 -0
  256. package/src/engine/soft-delete-cleanup.ts +6 -2
  257. package/src/engine/steps/__tests__/duration-utils.test.ts +20 -0
  258. package/src/engine/steps/_duration-utils.ts +2 -0
  259. package/src/engine/steps/unsafe-projection-upsert.ts +1 -4
  260. package/src/engine/tier-resolver-extension.ts +3 -2
  261. package/src/engine/types/config.ts +2 -482
  262. package/src/engine/types/define-handler.ts +2 -0
  263. package/src/engine/types/entity-handlers.ts +2 -0
  264. package/src/engine/types/event-type-map.ts +1 -37
  265. package/src/engine/types/feature.ts +2 -972
  266. package/src/engine/types/fields.ts +2 -675
  267. package/src/engine/types/handlers.ts +2 -774
  268. package/src/engine/types/hooks.ts +2 -184
  269. package/src/engine/types/http-route.ts +1 -54
  270. package/src/engine/types/identifiers.ts +1 -47
  271. package/src/engine/types/index.ts +86 -40
  272. package/src/engine/types/nav.ts +2 -63
  273. package/src/engine/types/ownership.ts +2 -0
  274. package/src/engine/types/projection.ts +2 -138
  275. package/src/engine/types/relations.ts +1 -51
  276. package/src/engine/types/screen.ts +2 -574
  277. package/src/engine/types/step.ts +2 -334
  278. package/src/engine/types/target-ref.ts +1 -21
  279. package/src/engine/types/tree-node.ts +1 -132
  280. package/src/engine/types/workspace.ts +2 -49
  281. package/src/engine/validate-projection-allowlist.ts +6 -6
  282. package/src/entrypoint/__tests__/entrypoint-job-wiring.integration.test.ts +120 -1
  283. package/src/entrypoint/index.ts +49 -6
  284. package/src/errors/classes.ts +22 -1
  285. package/src/errors/field-issue.ts +0 -3
  286. package/src/errors/index.ts +1 -1
  287. package/src/errors/write-error-info.ts +10 -22
  288. package/src/es-ops/README.md +1 -1
  289. package/src/es-ops/__tests__/runner.integration.test.ts +74 -0
  290. package/src/es-ops/context.ts +4 -2
  291. package/src/es-ops/types.ts +8 -1
  292. package/src/event-store/__tests__/admin-api.integration.test.ts +27 -1
  293. package/src/event-store/__tests__/backfill-pii.integration.test.ts +279 -0
  294. package/src/event-store/__tests__/event-store.integration.test.ts +201 -0
  295. package/src/event-store/__tests__/row-to-stored-event.test.ts +2 -2
  296. package/src/event-store/__tests__/snapshot.integration.test.ts +86 -0
  297. package/src/event-store/__tests__/unscoped-stream-primitives.guard.test.ts +58 -0
  298. package/src/event-store/__tests__/upcaster.integration.test.ts +77 -45
  299. package/src/event-store/admin-api.ts +11 -4
  300. package/src/event-store/errors.ts +2 -35
  301. package/src/event-store/event-store.ts +64 -78
  302. package/src/event-store/events-schema.ts +11 -13
  303. package/src/event-store/index.ts +18 -3
  304. package/src/event-store/rebuild-dead-letter.ts +111 -0
  305. package/src/event-store/snapshot.ts +63 -40
  306. package/src/event-store/types.ts +2 -0
  307. package/src/files/__tests__/build-storage-key.test.ts +28 -0
  308. package/src/files/__tests__/file-ref-entity.test.ts +8 -0
  309. package/src/files/__tests__/files.integration.test.ts +24 -0
  310. package/src/files/__tests__/in-memory-provider.contract.test.ts +4 -0
  311. package/src/files/__tests__/local-provider.test.ts +31 -0
  312. package/src/files/__tests__/provider-resolver.test.ts +70 -0
  313. package/src/files/__tests__/write-stream.test.ts +13 -0
  314. package/src/files/file-handle.ts +2 -19
  315. package/src/files/file-ref-entity.ts +2 -2
  316. package/src/files/file-routes.ts +13 -0
  317. package/src/files/index.ts +1 -1
  318. package/src/files/local-provider.ts +27 -8
  319. package/src/files/provider-resolver.ts +31 -20
  320. package/src/files/types.ts +13 -55
  321. package/src/i18n/__tests__/required-surface-keys.test.ts +17 -0
  322. package/src/i18n/required-surface-keys.ts +120 -1
  323. package/src/jobs/__tests__/job-queue-depth.integration.test.ts +82 -0
  324. package/src/jobs/__tests__/jobs.integration.test.ts +329 -3
  325. package/src/jobs/job-runner.ts +82 -12
  326. package/src/logging/types.ts +1 -7
  327. package/src/migrations/pending-rebuilds.ts +1 -1
  328. package/src/observability/__tests__/observability.integration.test.ts +4 -1
  329. package/src/observability/__tests__/recording-tracer.test.ts +6 -4
  330. package/src/observability/index.ts +2 -0
  331. package/src/observability/noop-provider.ts +0 -9
  332. package/src/observability/recording-tracer.ts +0 -12
  333. package/src/observability/standard-metrics.ts +64 -2
  334. package/src/observability/types/index.ts +1 -29
  335. package/src/observability/types/metric.ts +1 -56
  336. package/src/observability/types/provider.ts +1 -32
  337. package/src/observability/types/span.ts +1 -64
  338. package/src/pipeline/__tests__/ctx-bridge.integration.test.ts +2 -2
  339. package/src/pipeline/__tests__/dispatcher.test.ts +366 -1
  340. package/src/pipeline/__tests__/event-consumer-state.integration.test.ts +31 -0
  341. package/src/pipeline/__tests__/event-dispatcher-delivery-max-attempts.test.ts +126 -0
  342. package/src/pipeline/__tests__/event-dispatcher-rearm.integration.test.ts +263 -0
  343. package/src/pipeline/__tests__/event-dispatcher.integration.test.ts +10 -0
  344. package/src/pipeline/__tests__/job-trigger-consumer.integration.test.ts +106 -0
  345. package/src/pipeline/__tests__/lifecycle-pipeline.test.ts +308 -77
  346. package/src/pipeline/__tests__/load-aggregate-query.integration.test.ts +16 -8
  347. package/src/pipeline/__tests__/post-query-hook.integration.test.ts +3 -3
  348. package/src/pipeline/__tests__/projection-rebuild.integration.test.ts +80 -2
  349. package/src/pipeline/__tests__/rebuild-poison-quarantine.integration.test.ts +274 -0
  350. package/src/pipeline/__tests__/try-append-event.integration.test.ts +166 -0
  351. package/src/pipeline/dispatch-batch.ts +187 -0
  352. package/src/pipeline/dispatch-query.ts +165 -0
  353. package/src/pipeline/dispatch-shared.ts +826 -0
  354. package/src/pipeline/dispatch-stream.ts +90 -0
  355. package/src/pipeline/dispatch-write.ts +451 -0
  356. package/src/pipeline/dispatcher-utils.ts +1 -1
  357. package/src/pipeline/dispatcher.ts +44 -1372
  358. package/src/pipeline/entity-cache.ts +2 -33
  359. package/src/pipeline/event-consumer-state.ts +30 -2
  360. package/src/pipeline/event-dispatcher-admin.ts +293 -0
  361. package/src/pipeline/event-dispatcher-delivery.ts +305 -0
  362. package/src/pipeline/event-dispatcher.ts +85 -542
  363. package/src/pipeline/index.ts +2 -0
  364. package/src/pipeline/msp-rebuild.ts +42 -3
  365. package/src/pipeline/multi-stream-apply-context.ts +4 -42
  366. package/src/pipeline/projection-rebuild.ts +105 -3
  367. package/src/pipeline/system-hooks.ts +144 -1
  368. package/src/rate-limit/__tests__/resolver.integration.test.ts +18 -0
  369. package/src/rate-limit/resolver.ts +16 -32
  370. package/src/schema-cli.ts +76 -16
  371. package/src/search/__tests__/meilisearch-adapter.integration.test.ts +207 -185
  372. package/src/search/__tests__/meilisearch-ids.test.ts +30 -0
  373. package/src/search/__tests__/reindex-entity.integration.test.ts +144 -0
  374. package/src/search/index.ts +6 -0
  375. package/src/search/meilisearch-adapter.ts +13 -12
  376. package/src/search/reindex-entity.ts +177 -0
  377. package/src/search/types.ts +1 -39
  378. package/src/secrets/__tests__/contains-secret.test.ts +34 -0
  379. package/src/secrets/__tests__/envelope-cipher.test.ts +59 -0
  380. package/src/secrets/__tests__/envelope.test.ts +1 -1
  381. package/src/secrets/dek-cache.ts +26 -5
  382. package/src/secrets/envelope-cipher.ts +53 -0
  383. package/src/secrets/envelope.ts +5 -3
  384. package/src/secrets/index.ts +13 -1
  385. package/src/secrets/stored-envelope.ts +46 -0
  386. package/src/secrets/types.ts +2 -162
  387. package/src/stack/__tests__/event-collector.test.ts +42 -0
  388. package/src/stack/__tests__/setup-test-stack-jobs.integration.test.ts +125 -0
  389. package/src/stack/db.ts +2 -1
  390. package/src/stack/push-entity-projection-tables.ts +4 -3
  391. package/src/stack/redis.ts +8 -0
  392. package/src/stack/request-helper.ts +38 -2
  393. package/src/stack/table-helpers.ts +6 -4
  394. package/src/stack/test-stack.ts +249 -131
  395. package/src/testing/__tests__/late-bound.test.ts +32 -0
  396. package/src/testing/__tests__/wait-for.test.ts +59 -0
  397. package/src/testing/boot-validator-fixture.ts +121 -0
  398. package/src/testing/e2e-generator.ts +8 -0
  399. package/src/testing/file-provider-contract.ts +104 -0
  400. package/src/testing/handler-context.ts +3 -1
  401. package/src/testing/index.ts +5 -0
  402. package/src/testing/late-bound.ts +5 -3
  403. package/src/testing/mutable-master-key-provider.ts +29 -3
  404. package/src/testing/wait-for.ts +3 -0
  405. package/src/testing/without-ambient-temporal.ts +14 -0
  406. package/src/time/__tests__/tz-dateline.test.ts +8 -10
  407. package/src/time/geo-tz.ts +1 -32
  408. package/src/time/index.ts +1 -0
  409. package/src/time/legacy-date.ts +18 -0
  410. package/src/time/polyfill.ts +21 -38
  411. package/src/time/tz-context.ts +44 -85
  412. package/src/ui-types/app-schema.ts +12 -0
  413. package/src/ui-types/index.ts +20 -7
  414. package/src/utils/__tests__/safe-json-temporal.test.ts +18 -0
  415. package/src/utils/safe-json.ts +13 -1
  416. package/src/__tests__/raw-table.integration.test.ts +0 -116
  417. package/src/bun-db/__tests__/bun-test-stack.ts +0 -6
  418. package/src/db/__tests__/encryption.test.ts +0 -39
  419. package/src/db/encryption.ts +0 -39
  420. package/src/db/row-helpers.ts +0 -4
  421. package/src/engine/__tests__/raw-table.test.ts +0 -150
  422. package/src/engine/__tests__/unmanaged-table.test.ts +0 -127
  423. package/src/engine/feature-ast/__tests__/visual-tree-parse.test.ts +0 -184
  424. package/src/engine/feature-ast/extractors/round4.ts +0 -1366
@@ -1,16 +1,26 @@
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 { isExtensionEditSection, normalizeEditField, normalizeListColumn } from "../screen-helpers";
11
+ import type { FeatureDefinition } from "../types";
6
12
  import type {
13
+ DashboardCustomPanel,
14
+ DashboardFilterDefinition,
15
+ DashboardPanelDefinition,
16
+ DashboardScreenDefinition,
17
+ DashboardStatGroupPanel,
7
18
  FieldCondition,
8
19
  RowAction,
9
20
  RowFieldExtractor,
10
21
  ScreenDefinition,
11
22
  ToolbarAction,
12
23
  } from "../types/screen";
13
- import { isExtensionEditSection, normalizeEditField, normalizeListColumn } from "../types/screen";
14
24
 
15
25
  // --- Screen validation ---
16
26
  //
@@ -141,6 +151,65 @@ export function validateScreens(
141
151
  continue;
142
152
  }
143
153
 
154
+ if (screen.type === "projectionList") {
155
+ // Query-getrieben, keine Entity → nur query + columns prüfen (die
156
+ // Column-Felder können nicht gegen eine Entity gecheckt werden; sie
157
+ // werden zur Render-Zeit gegen die Projection-Rows aufgelöst).
158
+ if (!screen.query || typeof screen.query !== "string") {
159
+ throw new Error(
160
+ `[Feature ${feature.name}] Screen "${screenId}" (projectionList) has empty or non-string query.`,
161
+ );
162
+ }
163
+ if (screen.columns.length === 0) {
164
+ throw new Error(
165
+ `[Feature ${feature.name}] Screen "${screenId}" (projectionList) has an empty columns list — ` +
166
+ `declare at least one column.`,
167
+ );
168
+ }
169
+ for (const col of screen.columns) {
170
+ validateColumnRendererForm(feature.name, screenId, normalizeListColumn(col));
171
+ }
172
+ continue;
173
+ }
174
+
175
+ if (screen.type === "projectionDetail") {
176
+ // Query-getrieben wie projectionList, aber Single-Row + Layout statt
177
+ // Columns. Kein Entity-Check möglich — Felder werden render-seitig
178
+ // gegen die Query-Response aufgelöst, nicht gegen eine Entity.
179
+ if (!screen.query || typeof screen.query !== "string") {
180
+ throw new Error(
181
+ `[Feature ${feature.name}] Screen "${screenId}" (projectionDetail) has empty or non-string query.`,
182
+ );
183
+ }
184
+ if (screen.layout.sections.length === 0) {
185
+ throw new Error(
186
+ `[Feature ${feature.name}] Screen "${screenId}" (projectionDetail) has an empty sections list — ` +
187
+ `declare at least one section.`,
188
+ );
189
+ }
190
+ for (const section of screen.layout.sections) {
191
+ if (isExtensionEditSection(section)) {
192
+ throw new Error(
193
+ `[Feature ${feature.name}] Screen "${screenId}" (projectionDetail) extension section ` +
194
+ `"${section.title}" is not supported — projectionDetail has no entity for an extension ` +
195
+ `section to persist against.`,
196
+ );
197
+ }
198
+ if (section.fields.length === 0) {
199
+ throw new Error(
200
+ `[Feature ${feature.name}] Screen "${screenId}" (projectionDetail) has a section "${section.title}" ` +
201
+ `with zero fields — drop the section or add fields to it.`,
202
+ );
203
+ }
204
+ }
205
+ continue;
206
+ }
207
+
208
+ if (screen.type === "dashboard") {
209
+ validateDashboardScreen(feature.name, screenId, screen);
210
+ continue;
211
+ }
212
+
144
213
  if (screen.type === "configEdit") {
145
214
  // configEdit: layout/fields wie actionForm validieren, plus
146
215
  // Cross-Check dass jeder qualifizierte Config-Key registriert
@@ -613,6 +682,148 @@ export function validateScreens(
613
682
  }
614
683
  }
615
684
 
685
+ // Panel-getrieben, keine Entity — Struktur-Checks (eindeutige Panel-Ids,
686
+ // non-empty Queries/Columns/valueField); die Query-Contracts (Stat-Record,
687
+ // Points-Envelope, Paged-Rows) werden zur Render-Zeit aufgelöst.
688
+ function validateDashboardScreen(
689
+ featureName: string,
690
+ screenId: string,
691
+ screen: DashboardScreenDefinition,
692
+ ): void {
693
+ if (screen.panels.length === 0) {
694
+ throw new Error(
695
+ `[Feature ${featureName}] Screen "${screenId}" (dashboard) has an empty panels list — ` +
696
+ `declare at least one panel.`,
697
+ );
698
+ }
699
+ const panelIds = new Set<string>();
700
+ const addPanelId = (id: string, context: string): void => {
701
+ if (panelIds.has(id)) {
702
+ throw new Error(
703
+ `[Feature ${featureName}] Screen "${screenId}" (dashboard) has duplicate panel id "${id}" (${context}).`,
704
+ );
705
+ }
706
+ panelIds.add(id);
707
+ };
708
+
709
+ for (const panel of screen.panels) {
710
+ addPanelId(panel.id, panel.kind);
711
+ if (panel.kind === "stat-group") {
712
+ validateDashboardStatGroupPanel(featureName, screenId, panel, addPanelId);
713
+ } else if (panel.kind === "custom") {
714
+ validateDashboardCustomPanel(featureName, screenId, panel);
715
+ } else {
716
+ validateDashboardQueryPanel(featureName, screenId, panel);
717
+ }
718
+ }
719
+
720
+ if (screen.filter !== undefined) {
721
+ validateDashboardFilterDefinition(featureName, screenId, screen.filter);
722
+ }
723
+ }
724
+
725
+ function validateDashboardStatGroupPanel(
726
+ featureName: string,
727
+ screenId: string,
728
+ panel: DashboardStatGroupPanel,
729
+ addPanelId: (id: string, context: string) => void,
730
+ ): void {
731
+ if (panel.stats.length === 0) {
732
+ throw new Error(
733
+ `[Feature ${featureName}] Screen "${screenId}" (dashboard) stat-group "${panel.id}" has an empty stats list.`,
734
+ );
735
+ }
736
+ for (const stat of panel.stats) {
737
+ addPanelId(stat.id, "stat-group child");
738
+ if (!stat.query || typeof stat.query !== "string") {
739
+ throw new Error(
740
+ `[Feature ${featureName}] Screen "${screenId}" (dashboard) stat-group "${panel.id}" child "${stat.id}" has empty or non-string query.`,
741
+ );
742
+ }
743
+ if (stat.valueField.length === 0) {
744
+ throw new Error(
745
+ `[Feature ${featureName}] Screen "${screenId}" (dashboard) stat-group "${panel.id}" child "${stat.id}" has empty valueField.`,
746
+ );
747
+ }
748
+ }
749
+ }
750
+
751
+ function validateDashboardCustomPanel(
752
+ featureName: string,
753
+ screenId: string,
754
+ panel: DashboardCustomPanel,
755
+ ): void {
756
+ if (panel.component.react === undefined && panel.component.native === undefined) {
757
+ throw new Error(
758
+ `[Feature ${featureName}] Screen "${screenId}" (dashboard) custom-panel "${panel.id}" has no component — ` +
759
+ `declare a react/native component marker.`,
760
+ );
761
+ }
762
+ }
763
+
764
+ function validateDashboardQueryPanel(
765
+ featureName: string,
766
+ screenId: string,
767
+ panel: Exclude<DashboardPanelDefinition, DashboardStatGroupPanel | DashboardCustomPanel>,
768
+ ): void {
769
+ if (!panel.query || typeof panel.query !== "string") {
770
+ throw new Error(
771
+ `[Feature ${featureName}] Screen "${screenId}" (dashboard) panel "${panel.id}" has empty or non-string query.`,
772
+ );
773
+ }
774
+ if (panel.kind === "stat" && panel.valueField.length === 0) {
775
+ throw new Error(
776
+ `[Feature ${featureName}] Screen "${screenId}" (dashboard) stat-panel "${panel.id}" has empty valueField.`,
777
+ );
778
+ }
779
+ if (panel.kind === "list") {
780
+ if (panel.columns.length === 0) {
781
+ throw new Error(
782
+ `[Feature ${featureName}] Screen "${screenId}" (dashboard) list-panel "${panel.id}" has an empty columns list.`,
783
+ );
784
+ }
785
+ for (const col of panel.columns) {
786
+ validateColumnRendererForm(featureName, screenId, normalizeListColumn(col));
787
+ }
788
+ }
789
+ }
790
+
791
+ function validateDashboardFilterDefinition(
792
+ featureName: string,
793
+ screenId: string,
794
+ filter: DashboardFilterDefinition,
795
+ ): void {
796
+ if (filter.id.length === 0) {
797
+ throw new Error(
798
+ `[Feature ${featureName}] Screen "${screenId}" (dashboard) filter has an empty id.`,
799
+ );
800
+ }
801
+ if (filter.label.length === 0) {
802
+ throw new Error(
803
+ `[Feature ${featureName}] Screen "${screenId}" (dashboard) filter has an empty label.`,
804
+ );
805
+ }
806
+ const hasOptions = filter.options !== undefined;
807
+ const hasOptionsQuery = filter.optionsQuery !== undefined;
808
+ if (hasOptions === hasOptionsQuery) {
809
+ throw new Error(
810
+ `[Feature ${featureName}] Screen "${screenId}" (dashboard) filter must set exactly one of ` +
811
+ `options/optionsQuery.`,
812
+ );
813
+ }
814
+ if (hasOptions && (filter.options?.length ?? 0) === 0) {
815
+ throw new Error(
816
+ `[Feature ${featureName}] Screen "${screenId}" (dashboard) filter.options is empty — ` +
817
+ `declare at least one option or use optionsQuery instead.`,
818
+ );
819
+ }
820
+ if (hasOptionsQuery && filter.optionsQuery?.length === 0) {
821
+ throw new Error(
822
+ `[Feature ${featureName}] Screen "${screenId}" (dashboard) filter.optionsQuery is empty.`,
823
+ );
824
+ }
825
+ }
826
+
616
827
  // Form-check für ListColumn-Renderer in der PlatformComponent-Form
617
828
  // (`{ react: { __component: "Name" } }`). Der Server kennt die client-
618
829
  // seitige columnRenderers-Map nicht — also nur prüfen ob die Struktur
@@ -678,13 +889,6 @@ export function buildUnknownFieldMessage(
678
889
  );
679
890
  }
680
891
 
681
- // --- Nav validation ---
682
- //
683
- // The boot-validator runs BEFORE createRegistry builds the final maps, so we
684
- // pre-build the qualified name sets for screens + navs here. `qualifyEntityName`
685
- // is the shared helper with the registry — changing the qualification rule
686
- // in one place flows through both ingest paths.
687
-
688
892
  export function collectScreenQns(features: readonly FeatureDefinition[]): Set<string> {
689
893
  const set = new Set<string>();
690
894
  for (const f of features) {
@@ -731,184 +935,3 @@ export function collectScreensByShortId(
731
935
  }
732
936
  return map;
733
937
  }
734
-
735
- // Sammelt alle qualifizierten Write-Handler-QNs (`<feature>:write:<short>`).
736
- // Wird vom actionForm-Screen-Validator genutzt um zu prüfen ob der
737
- // im Schema deklarierte handler tatsächlich registriert ist —
738
- // Tippfehler/umbenannte Handler fallen sonst erst zur Laufzeit auf.
739
- export function collectWriteHandlerQns(features: readonly FeatureDefinition[]): Set<string> {
740
- const set = new Set<string>();
741
- for (const f of features) {
742
- for (const handlerName of Object.keys(f.writeHandlers)) {
743
- set.add(qualifyEntityName(f.name, "write", handlerName));
744
- }
745
- }
746
- return set;
747
- }
748
-
749
- export function collectNavQns(
750
- features: readonly FeatureDefinition[],
751
- ): Map<string, NavDefinition & { readonly featureName: string }> {
752
- const map = new Map<string, NavDefinition & { readonly featureName: string }>();
753
- for (const f of features) {
754
- for (const [navId, navDef] of Object.entries(f.navs)) {
755
- const qualified = qualifyEntityName(f.name, "nav", navId);
756
- map.set(qualified, { ...navDef, featureName: f.name });
757
- }
758
- }
759
- return map;
760
- }
761
-
762
- // Per-feature ref validation: screen + parent refs point at real QNs. Cycle
763
- // detection runs once globally afterwards (it's cheaper to do a single DFS
764
- // over the merged graph than restart it per feature).
765
- export function validateNavs(
766
- feature: FeatureDefinition,
767
- allScreenQns: ReadonlySet<string>,
768
- allNavQns: ReadonlyMap<string, NavDefinition & { readonly featureName: string }>,
769
- allWorkspaceQns: ReadonlyMap<string, WorkspaceDefinition & { readonly featureName: string }>,
770
- ): void {
771
- for (const [navId, navDef] of Object.entries(feature.navs)) {
772
- if (navDef.screen !== undefined && !allScreenQns.has(navDef.screen)) {
773
- throw new Error(
774
- `[Feature ${feature.name}] Nav entry "${navId}" references screen "${navDef.screen}" ` +
775
- `which is not registered. Expected a qualified name of the form ` +
776
- `"<feature>:screen:<id>" pointing at an r.screen() declaration.`,
777
- );
778
- }
779
- if (navDef.parent !== undefined && !allNavQns.has(navDef.parent)) {
780
- throw new Error(
781
- `[Feature ${feature.name}] Nav entry "${navId}" references parent "${navDef.parent}" ` +
782
- `which is not a registered nav entry. Expected a qualified name of the form ` +
783
- `"<feature>:nav:<id>".`,
784
- );
785
- }
786
- if (navDef.workspaces !== undefined) {
787
- for (const wsQn of navDef.workspaces) {
788
- if (!allWorkspaceQns.has(wsQn)) {
789
- throw new Error(
790
- `[Feature ${feature.name}] Nav entry "${navId}" self-assigns to workspace "${wsQn}" ` +
791
- `which is not registered. Expected a qualified name of the form ` +
792
- `"<feature>:workspace:<id>" pointing at an r.workspace() declaration.`,
793
- );
794
- }
795
- }
796
- }
797
- }
798
- }
799
-
800
- // Walks parent-refs across ALL nav entries (cross-feature). A cycle here
801
- // would crash client-side tree assembly — easier to fail loud at boot than
802
- // to debug a React "Maximum update depth exceeded" stack trace.
803
- export function validateNavCycles(
804
- allNavQns: ReadonlyMap<string, NavDefinition & { readonly featureName: string }>,
805
- ): void {
806
- const visited = new Set<string>();
807
- const stack = new Set<string>();
808
-
809
- function visit(qualified: string, path: string[]): void {
810
- if (stack.has(qualified)) {
811
- throw new Error(
812
- `[Kumiko Nav] Nav entry parent cycle detected: ${[...path, qualified].join(" → ")}`,
813
- );
814
- }
815
- // skip: already visited — cycle-detection only needs to traverse each
816
- // node once, and the `stack` check above catches any actual cycles
817
- // reached via a different path.
818
- if (visited.has(qualified)) return;
819
- visited.add(qualified);
820
- stack.add(qualified);
821
- const navDef = allNavQns.get(qualified);
822
- if (navDef?.parent) {
823
- visit(navDef.parent, [...path, qualified]);
824
- }
825
- stack.delete(qualified);
826
- }
827
-
828
- for (const qualified of allNavQns.keys()) {
829
- visit(qualified, []);
830
- }
831
- }
832
-
833
- // Roles we recognise at boot time. The framework has no explicit
834
- // role-registry (r.defineRoles is a type helper only), so we synthesise
835
- // one from every handler-access rule plus the "all"/"system" built-ins.
836
- export function collectKnownRoles(features: readonly FeatureDefinition[]): Set<string> {
837
- const roles = new Set<string>(["all", "system"]);
838
- for (const f of features) {
839
- for (const def of Object.values(f.writeHandlers)) {
840
- if (def.access && "roles" in def.access) {
841
- for (const r of def.access.roles) roles.add(r);
842
- }
843
- }
844
- for (const def of Object.values(f.queryHandlers)) {
845
- if (def.access && "roles" in def.access) {
846
- for (const r of def.access.roles) roles.add(r);
847
- }
848
- }
849
- }
850
- return roles;
851
- }
852
-
853
- // --- Workspace validation ---
854
- //
855
- // Per-app workspace registry, built once up front. Carries `featureName`
856
- // alongside the definition so error messages can point at the offending
857
- // feature without a parallel reverse index.
858
-
859
- export function collectWorkspaceQns(
860
- features: readonly FeatureDefinition[],
861
- ): Map<string, WorkspaceDefinition & { readonly featureName: string }> {
862
- const map = new Map<string, WorkspaceDefinition & { readonly featureName: string }>();
863
- for (const f of features) {
864
- for (const [wsId, wsDef] of Object.entries(f.workspaces)) {
865
- const qualified = qualifyEntityName(f.name, "workspace", wsId);
866
- map.set(qualified, { ...wsDef, featureName: f.name });
867
- }
868
- }
869
- return map;
870
- }
871
-
872
- export function validateWorkspaces(
873
- feature: FeatureDefinition,
874
- allNavQns: ReadonlyMap<string, NavDefinition & { readonly featureName: string }>,
875
- ): void {
876
- for (const [wsId, wsDef] of Object.entries(feature.workspaces)) {
877
- if (wsDef.nav !== undefined) {
878
- for (const navQn of wsDef.nav) {
879
- // 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.
880
- if (SETTINGS_HUB_AUDIENCE_NAV_QN_SET.has(navQn)) continue;
881
- if (!allNavQns.has(navQn)) {
882
- throw new Error(
883
- `[Feature ${feature.name}] Workspace "${wsId}" references nav "${navQn}" ` +
884
- `which is not registered. Expected a qualified name of the form ` +
885
- `"<feature>:nav:<id>" pointing at an r.nav() declaration.`,
886
- );
887
- }
888
- }
889
- }
890
- }
891
- }
892
-
893
- const SETTINGS_HUB_AUDIENCE_NAV_QN_SET: ReadonlySet<string> = new Set(
894
- SETTINGS_HUB_AUDIENCE_NAV_QNS,
895
- );
896
-
897
- // Single-default rule across the entire app. Mirrors how createApp validates
898
- // roles up front — a second `default: true` is a configuration error, not a
899
- // runtime fallback. Apps without any default fall back to "first workspace
900
- // the user has access to" at render time (handled by shellWorkspaces).
901
- export function validateDefaultWorkspaceUniqueness(
902
- allWorkspaceQns: ReadonlyMap<string, WorkspaceDefinition & { readonly featureName: string }>,
903
- ): void {
904
- const defaults: string[] = [];
905
- for (const [qn, ws] of allWorkspaceQns) {
906
- if (ws.default === true) defaults.push(qn);
907
- }
908
- if (defaults.length > 1) {
909
- throw new Error(
910
- `[Kumiko Workspaces] Multiple workspaces declare default: true — ` +
911
- `${defaults.join(", ")}. At most one workspace per app may be the default.`,
912
- );
913
- }
914
- }
@@ -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
+ }
@@ -17,6 +17,8 @@
17
17
  // sind plain literals)
18
18
  // Nav: verbatim (NavDefinition ist nur strings + literals)
19
19
  // Workspace: verbatim definition + getWorkspaceNavs() von der Registry
20
+ // Translations: verbatim feature.translations (raw r.translations keys,
21
+ // unfiltered — see FeatureSchema.translations doc)
20
22
  //
21
23
  // Feature-Toggles: BISHER NICHT GEFILTERT. Wenn ein Feature über die
22
24
  // feature-toggles-bundled-feature global deaktiviert ist, erscheint es
@@ -49,6 +51,12 @@ export function buildAppSchema(registry: Registry, options: BuildAppSchemaOption
49
51
  entities: projectEntities(feature.entities ?? {}),
50
52
  screens: Object.values(feature.screens),
51
53
  ...(navs.length > 0 && { navs }),
54
+ // #1059: verbatim r.translations({keys}) — see FeatureSchema.translations
55
+ // doc for why this must NOT go through registry.getAllTranslations()
56
+ // (double-prefixes features that already qualify their own keys).
57
+ ...(Object.keys(feature.translations ?? {}).length > 0 && {
58
+ translations: feature.translations,
59
+ }),
52
60
  };
53
61
  features.push(featureSchema);
54
62
  }
@@ -340,9 +348,17 @@ function projectField(fieldDef: FieldDefinition): FieldDefinition {
340
348
  // filterable steuert die Faceted-Filter-Dropdowns im Renderer (select/
341
349
  // boolean) — muss daher ins Client-Schema.
342
350
  if (typeof def["filterable"] === "boolean") out["filterable"] = def["filterable"];
351
+ if (typeof def["searchable"] === "boolean") out["searchable"] = def["searchable"];
343
352
  if (isLiteral(def["default"])) out["default"] = def["default"];
344
353
  // Select: options-Liste ist plain JSON, durchschicken.
345
354
  if (Array.isArray(def["options"])) out["options"] = def["options"];
355
+ // Reference: entity-Target + labelField + multiple müssen zum Renderer.
356
+ // Der ReferenceInput baut die Options-Query aus refEntity/refFeature und
357
+ // resolved das Label über labelField — ohne diese Properties fällt das
358
+ // Dropdown leer aus (QN wird `<feature>:query::list` → 404).
359
+ if (typeof def["entity"] === "string") out["entity"] = def["entity"];
360
+ if (typeof def["labelField"] === "string") out["labelField"] = def["labelField"];
361
+ if (typeof def["multiple"] === "boolean") out["multiple"] = def["multiple"];
346
362
  return out as FieldDefinition; // @cast-boundary schema-walk
347
363
  }
348
364
 
@@ -23,6 +23,7 @@ import {
23
23
  createSelectField,
24
24
  createTextField,
25
25
  } from "./factories";
26
+ import { isKebabSegment } from "./qualified-name";
26
27
  import type { ConfigKeyDefinition } from "./types/config";
27
28
  import type { Registry } from "./types/feature";
28
29
  import type { FieldDefinition } from "./types/fields";
@@ -56,6 +57,11 @@ export type ConfigFeatureSchema = {
56
57
 
57
58
  // Audience-Reihenfolge im Sidebar: Plattform vor Tenant vor Benutzer.
58
59
  const SCOPE_ORDER: Record<ConfigScope, number> = { system: 10, tenant: 20, user: 30 };
60
+ const SCOPE_ICON: Record<ConfigScope, string> = {
61
+ system: "shield",
62
+ tenant: "building",
63
+ user: "user",
64
+ };
59
65
  const SCOPES_BROAD_TO_DEEP: readonly ConfigScope[] = ["system", "tenant", "user"];
60
66
 
61
67
  const audienceNavShortId = (scope: ConfigScope): string => `audience-${scope}`;
@@ -85,7 +91,14 @@ const MACHINE_WRITE_ROLE = "system" as const;
85
91
 
86
92
  type MaskedKey = {
87
93
  readonly qn: string;
94
+ // Effective group this key is bucketed under for the Settings-Hub —
95
+ // `def.group` if set, else `ownerFeature`. This is what screen/nav
96
+ // generation groups by.
88
97
  readonly feature: string;
98
+ // The feature that actually declared this key (always derived from `qn`).
99
+ // Needed to disambiguate field ids when `feature !== ownerFeature` (a
100
+ // cross-feature `group`) and for collision error messages.
101
+ readonly ownerFeature: string;
89
102
  readonly shortKey: string;
90
103
  readonly def: ConfigKeyDefinition;
91
104
  };
@@ -107,6 +120,7 @@ export function buildConfigFeatureSchema(registry: Registry): ConfigFeatureSchem
107
120
  navs.push({
108
121
  id: audienceNavShortId(scope),
109
122
  label: `config.settings.${scope}`,
123
+ icon: SCOPE_ICON[scope],
110
124
  order: SCOPE_ORDER[scope],
111
125
  access: rolesToAccess(visible.flatMap((v) => v.roles)),
112
126
  });
@@ -123,6 +137,7 @@ export function buildConfigFeatureSchema(registry: Registry): ConfigFeatureSchem
123
137
  label: `${feature}.settings`,
124
138
  parent: audienceNavShortId(scope),
125
139
  screen: shortId,
140
+ icon: ordered[0]?.def.mask?.icon ?? "settings",
126
141
  order: minMaskOrder(ordered),
127
142
  access,
128
143
  });
@@ -190,15 +205,30 @@ function buildScreen(
190
205
  const configKeys: Record<string, string> = {};
191
206
  const fields: Record<string, FieldDefinition> = {};
192
207
  const fieldLabels: Record<string, string> = {};
208
+ // Field id collapses to the plain shortKey when the key stays in its own
209
+ // feature's group (100% of today's apps — byte-identical output). Only a
210
+ // cross-feature `group` (feature !== ownerFeature) needs the owner prefix,
211
+ // to keep two features sharing a group from clobbering the same shortKey.
212
+ const fieldId = (k: MaskedKey): string =>
213
+ k.feature === k.ownerFeature ? k.shortKey : `${k.ownerFeature}-${k.shortKey}`;
214
+ const seenFieldIds = new Map<string, string>();
193
215
  for (const k of keys) {
194
- configKeys[k.shortKey] = k.qn;
195
- fields[k.shortKey] = deriveField(k.def);
216
+ const id = fieldId(k);
217
+ const prevQn = seenFieldIds.get(id);
218
+ if (prevQn !== undefined) {
219
+ throw new Error(
220
+ `[Settings-Hub] group "${feature}" scope "${scope}": config keys "${prevQn}" and "${k.qn}" both resolve to field id "${id}" — rename one key or its group.`,
221
+ );
222
+ }
223
+ seenFieldIds.set(id, k.qn);
224
+ configKeys[id] = k.qn;
225
+ fields[id] = deriveField(k.def);
196
226
  // mask is the visibility gate, so collectMaskedKeys guarantees it here.
197
- if (k.def.mask) fieldLabels[k.shortKey] = k.def.mask.title;
227
+ if (k.def.mask) fieldLabels[id] = k.def.mask.title;
198
228
  }
199
229
  const section: EditFieldsSection = {
200
230
  title: `${feature}.settings`,
201
- fields: keys.map((k) => k.shortKey),
231
+ fields: keys.map(fieldId),
202
232
  };
203
233
  return {
204
234
  id: shortId,
@@ -230,14 +260,21 @@ function deriveField(def: ConfigKeyDefinition): FieldDefinition {
230
260
  function collectMaskedKeys(registry: Registry): MaskedKey[] {
231
261
  const out: MaskedKey[] = [];
232
262
  for (const [qn, def] of registry.getAllConfigKeys()) {
263
+ const sep = qn.indexOf(":config:");
264
+ if (sep === -1) continue;
265
+ const ownerFeature = qn.slice(0, sep);
266
+ if (def.group !== undefined && !isKebabSegment(def.group)) {
267
+ throw new Error(
268
+ `[Feature ${ownerFeature}] config key "${qn.slice(sep + ":config:".length)}" has invalid group "${def.group}" — must be kebab-case.`,
269
+ );
270
+ }
233
271
  // computed keys derive their value — there is no row to set, so a
234
272
  // configEdit screen could not write them. Skip even when masked.
235
273
  if (def.mask === undefined || def.computed !== undefined) continue;
236
- const sep = qn.indexOf(":config:");
237
- if (sep === -1) continue;
238
274
  out.push({
239
275
  qn,
240
- feature: qn.slice(0, sep),
276
+ feature: def.group ?? ownerFeature,
277
+ ownerFeature,
241
278
  shortKey: qn.slice(sep + ":config:".length),
242
279
  def,
243
280
  });