@cosmicdrift/kumiko-framework 0.1.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 (388) hide show
  1. package/README.md +159 -0
  2. package/package.json +91 -0
  3. package/src/__tests__/anonymous-access.integration.ts +325 -0
  4. package/src/__tests__/error-contract.integration.ts +435 -0
  5. package/src/__tests__/field-access.integration.ts +269 -0
  6. package/src/__tests__/full-stack.integration.ts +914 -0
  7. package/src/__tests__/ownership.integration.ts +449 -0
  8. package/src/__tests__/reference-data.integration.ts +198 -0
  9. package/src/__tests__/transition-guard.integration.ts +340 -0
  10. package/src/api/__tests__/api.test.ts +337 -0
  11. package/src/api/__tests__/auth-middleware-transport.test.ts +80 -0
  12. package/src/api/__tests__/auth-routes-cookie.test.ts +179 -0
  13. package/src/api/__tests__/batch.integration.ts +404 -0
  14. package/src/api/__tests__/body-limit.test.ts +88 -0
  15. package/src/api/__tests__/csrf-middleware.test.ts +97 -0
  16. package/src/api/__tests__/dispatcher-live.integration.ts +216 -0
  17. package/src/api/__tests__/metrics-endpoint.test.ts +126 -0
  18. package/src/api/__tests__/nested-write.integration.ts +213 -0
  19. package/src/api/__tests__/readiness.test.ts +76 -0
  20. package/src/api/__tests__/request-id-middleware.test.ts +72 -0
  21. package/src/api/__tests__/sse-broker.test.ts +58 -0
  22. package/src/api/__tests__/sse-route.test.ts +112 -0
  23. package/src/api/anonymous-cookie.ts +60 -0
  24. package/src/api/api-constants.ts +64 -0
  25. package/src/api/auth-middleware.ts +418 -0
  26. package/src/api/auth-routes.ts +982 -0
  27. package/src/api/csrf-middleware.ts +77 -0
  28. package/src/api/index.ts +31 -0
  29. package/src/api/jwt.ts +66 -0
  30. package/src/api/observability-middleware.ts +89 -0
  31. package/src/api/readiness.ts +132 -0
  32. package/src/api/request-context.ts +49 -0
  33. package/src/api/request-id-middleware.ts +50 -0
  34. package/src/api/route-registrars.ts +195 -0
  35. package/src/api/routes.ts +135 -0
  36. package/src/api/server.ts +640 -0
  37. package/src/api/sse-broker.ts +71 -0
  38. package/src/api/sse-route.ts +62 -0
  39. package/src/api/tokens.ts +16 -0
  40. package/src/db/__tests__/apply-entity-event-tenant.integration.ts +159 -0
  41. package/src/db/__tests__/compound-types.test.ts +114 -0
  42. package/src/db/__tests__/connection-options.test.ts +68 -0
  43. package/src/db/__tests__/cursor.test.ts +41 -0
  44. package/src/db/__tests__/db-helpers.test.ts +369 -0
  45. package/src/db/__tests__/dialect-instant.test.ts +50 -0
  46. package/src/db/__tests__/drizzle-helpers.integration.ts +186 -0
  47. package/src/db/__tests__/drizzle-table-types.test.ts +162 -0
  48. package/src/db/__tests__/encryption.test.ts +39 -0
  49. package/src/db/__tests__/event-store-executor-list.integration.ts +313 -0
  50. package/src/db/__tests__/event-store-executor.integration.ts +235 -0
  51. package/src/db/__tests__/implicit-projection-equivalence.integration.ts +304 -0
  52. package/src/db/__tests__/located-timestamp.test.ts +184 -0
  53. package/src/db/__tests__/money.test.ts +199 -0
  54. package/src/db/__tests__/multi-row-insert.integration.ts +76 -0
  55. package/src/db/__tests__/parse-auto-verb.test.ts +70 -0
  56. package/src/db/__tests__/required-not-null-migration-safety.integration.ts +105 -0
  57. package/src/db/__tests__/row-helpers.test.ts +59 -0
  58. package/src/db/__tests__/schema-migration.integration.ts +273 -0
  59. package/src/db/__tests__/table-builder-indexes.test.ts +153 -0
  60. package/src/db/__tests__/table-builder-required.test.ts +216 -0
  61. package/src/db/__tests__/tenant-db.integration.ts +606 -0
  62. package/src/db/__tests__/unique-violation-mapping.integration.ts +166 -0
  63. package/src/db/apply-entity-event.ts +188 -0
  64. package/src/db/assert-exists-in.ts +59 -0
  65. package/src/db/compound-types.ts +47 -0
  66. package/src/db/connection.ts +104 -0
  67. package/src/db/cursor.ts +83 -0
  68. package/src/db/dialect.ts +109 -0
  69. package/src/db/eagerload.ts +174 -0
  70. package/src/db/encryption.ts +39 -0
  71. package/src/db/event-store-executor.ts +906 -0
  72. package/src/db/index.ts +55 -0
  73. package/src/db/located-timestamp.ts +114 -0
  74. package/src/db/money.ts +120 -0
  75. package/src/db/pg-error.ts +46 -0
  76. package/src/db/reference-data.ts +77 -0
  77. package/src/db/row-helpers.ts +53 -0
  78. package/src/db/schema-inspection.ts +25 -0
  79. package/src/db/table-builder.ts +475 -0
  80. package/src/db/tenant-db.ts +434 -0
  81. package/src/engine/__tests__/auth-claims-registrar.test.ts +74 -0
  82. package/src/engine/__tests__/boot-validator-located-timestamps.test.ts +108 -0
  83. package/src/engine/__tests__/boot-validator.test.ts +1865 -0
  84. package/src/engine/__tests__/build-app-schema.test.ts +154 -0
  85. package/src/engine/__tests__/claim-keys.test.ts +274 -0
  86. package/src/engine/__tests__/config-helpers.test.ts +236 -0
  87. package/src/engine/__tests__/effective-features.test.ts +86 -0
  88. package/src/engine/__tests__/engine.test.ts +1461 -0
  89. package/src/engine/__tests__/entity-handlers.test.ts +274 -0
  90. package/src/engine/__tests__/event-helpers.test.ts +68 -0
  91. package/src/engine/__tests__/extends-registrar.test.ts +159 -0
  92. package/src/engine/__tests__/factories-long-text.test.ts +84 -0
  93. package/src/engine/__tests__/factories-time.test.ts +158 -0
  94. package/src/engine/__tests__/field-predicates.test.ts +48 -0
  95. package/src/engine/__tests__/hook-phases.test.ts +132 -0
  96. package/src/engine/__tests__/identifiers.test.ts +35 -0
  97. package/src/engine/__tests__/lifecycle-hooks.test.ts +237 -0
  98. package/src/engine/__tests__/nav.test.ts +267 -0
  99. package/src/engine/__tests__/ownership.test.ts +421 -0
  100. package/src/engine/__tests__/parse-ref-target.test.ts +43 -0
  101. package/src/engine/__tests__/projection-helpers.test.ts +62 -0
  102. package/src/engine/__tests__/projection.test.ts +191 -0
  103. package/src/engine/__tests__/qualified-name.test.ts +264 -0
  104. package/src/engine/__tests__/resolve-config-or-param.test.ts +315 -0
  105. package/src/engine/__tests__/run-in.test.ts +38 -0
  106. package/src/engine/__tests__/schema-builder.test.ts +380 -0
  107. package/src/engine/__tests__/screen.test.ts +408 -0
  108. package/src/engine/__tests__/state-machine.test.ts +148 -0
  109. package/src/engine/__tests__/system-user.test.ts +57 -0
  110. package/src/engine/__tests__/validation-hooks.test.ts +71 -0
  111. package/src/engine/access.ts +23 -0
  112. package/src/engine/boot-validator.ts +1528 -0
  113. package/src/engine/build-app-schema.ts +125 -0
  114. package/src/engine/config-helpers.ts +115 -0
  115. package/src/engine/constants.ts +85 -0
  116. package/src/engine/create-app.ts +98 -0
  117. package/src/engine/define-feature.ts +702 -0
  118. package/src/engine/define-handler.ts +78 -0
  119. package/src/engine/define-roles.ts +19 -0
  120. package/src/engine/effective-features.ts +87 -0
  121. package/src/engine/entity-handlers.ts +364 -0
  122. package/src/engine/event-helpers.ts +73 -0
  123. package/src/engine/factories.ts +328 -0
  124. package/src/engine/feature-ast/__tests__/canonical-form.test.ts +416 -0
  125. package/src/engine/feature-ast/__tests__/parse-happy-path.test.ts +197 -0
  126. package/src/engine/feature-ast/__tests__/parse-real-features.test.ts +128 -0
  127. package/src/engine/feature-ast/__tests__/parse.test.ts +888 -0
  128. package/src/engine/feature-ast/__tests__/patch.test.ts +360 -0
  129. package/src/engine/feature-ast/__tests__/patcher.test.ts +469 -0
  130. package/src/engine/feature-ast/__tests__/render-roundtrip.test.ts +287 -0
  131. package/src/engine/feature-ast/extractors.ts +2562 -0
  132. package/src/engine/feature-ast/index.ts +105 -0
  133. package/src/engine/feature-ast/parse.ts +369 -0
  134. package/src/engine/feature-ast/patch.ts +525 -0
  135. package/src/engine/feature-ast/patcher.ts +518 -0
  136. package/src/engine/feature-ast/patterns.ts +434 -0
  137. package/src/engine/feature-ast/render.ts +602 -0
  138. package/src/engine/feature-ast/source-location.ts +45 -0
  139. package/src/engine/field-access.ts +120 -0
  140. package/src/engine/index.ts +254 -0
  141. package/src/engine/ownership.ts +337 -0
  142. package/src/engine/parse-ref-target.ts +22 -0
  143. package/src/engine/pattern-library/__tests__/library.test.ts +351 -0
  144. package/src/engine/pattern-library/index.ts +24 -0
  145. package/src/engine/pattern-library/library.ts +1117 -0
  146. package/src/engine/pattern-library/types.ts +255 -0
  147. package/src/engine/projection-helpers.ts +85 -0
  148. package/src/engine/qualified-name.ts +122 -0
  149. package/src/engine/read-claim.ts +31 -0
  150. package/src/engine/registry.ts +1325 -0
  151. package/src/engine/resolve-config-or-param.ts +153 -0
  152. package/src/engine/run-in.ts +29 -0
  153. package/src/engine/schema-builder.ts +175 -0
  154. package/src/engine/screen-filter-ops.ts +51 -0
  155. package/src/engine/state-machine.ts +70 -0
  156. package/src/engine/system-user.ts +32 -0
  157. package/src/engine/types/config.ts +306 -0
  158. package/src/engine/types/event-type-map.ts +37 -0
  159. package/src/engine/types/feature.ts +574 -0
  160. package/src/engine/types/fields.ts +422 -0
  161. package/src/engine/types/handlers.ts +742 -0
  162. package/src/engine/types/hooks.ts +142 -0
  163. package/src/engine/types/http-route.ts +54 -0
  164. package/src/engine/types/identifiers.ts +47 -0
  165. package/src/engine/types/index.ts +208 -0
  166. package/src/engine/types/nav.ts +46 -0
  167. package/src/engine/types/projection.ts +132 -0
  168. package/src/engine/types/relations.ts +51 -0
  169. package/src/engine/types/screen.ts +452 -0
  170. package/src/engine/types/workspace.ts +42 -0
  171. package/src/engine/validation.ts +33 -0
  172. package/src/entrypoint/__tests__/entrypoint-job-wiring.integration.ts +173 -0
  173. package/src/entrypoint/__tests__/split-deploy.integration.ts +297 -0
  174. package/src/entrypoint/index.ts +442 -0
  175. package/src/errors/__tests__/classes.test.ts +371 -0
  176. package/src/errors/__tests__/write-failures.test.ts +109 -0
  177. package/src/errors/classes.ts +249 -0
  178. package/src/errors/i18n/de.yaml +83 -0
  179. package/src/errors/i18n/en.yaml +80 -0
  180. package/src/errors/index.ts +41 -0
  181. package/src/errors/kumiko-error.ts +67 -0
  182. package/src/errors/reasons.ts +36 -0
  183. package/src/errors/serialize.ts +136 -0
  184. package/src/errors/transition-details.ts +30 -0
  185. package/src/errors/write-error-info.ts +123 -0
  186. package/src/errors/zod-bridge.ts +49 -0
  187. package/src/event-store/__tests__/admin-api.integration.ts +361 -0
  188. package/src/event-store/__tests__/event-store.integration.ts +584 -0
  189. package/src/event-store/__tests__/get-stream-version-perf.integration.ts +83 -0
  190. package/src/event-store/__tests__/perf.integration.ts +255 -0
  191. package/src/event-store/__tests__/snapshot.integration.ts +267 -0
  192. package/src/event-store/__tests__/upcaster-dead-letter.integration.ts +204 -0
  193. package/src/event-store/__tests__/upcaster.integration.ts +460 -0
  194. package/src/event-store/admin-api.ts +257 -0
  195. package/src/event-store/archive.ts +106 -0
  196. package/src/event-store/errors.ts +35 -0
  197. package/src/event-store/event-store.ts +405 -0
  198. package/src/event-store/events-schema.ts +90 -0
  199. package/src/event-store/index.ts +50 -0
  200. package/src/event-store/snapshot.ts +210 -0
  201. package/src/event-store/upcaster-dead-letter.ts +119 -0
  202. package/src/event-store/upcaster.ts +147 -0
  203. package/src/files/__tests__/content-disposition.test.ts +123 -0
  204. package/src/files/__tests__/file-field-column.integration.ts +103 -0
  205. package/src/files/__tests__/file-field-pipeline.integration.ts +211 -0
  206. package/src/files/__tests__/file-handle.test.ts +122 -0
  207. package/src/files/__tests__/files.integration.ts +830 -0
  208. package/src/files/__tests__/storage-tracking.integration.ts +153 -0
  209. package/src/files/content-disposition.ts +55 -0
  210. package/src/files/file-handle.ts +63 -0
  211. package/src/files/file-ref-table.ts +22 -0
  212. package/src/files/file-routes.ts +353 -0
  213. package/src/files/in-memory-provider.ts +62 -0
  214. package/src/files/index.ts +29 -0
  215. package/src/files/local-provider.ts +35 -0
  216. package/src/files/storage-tracking.ts +60 -0
  217. package/src/files/types.ts +118 -0
  218. package/src/i18n/__tests__/i18n.test.ts +72 -0
  219. package/src/i18n/index.ts +29 -0
  220. package/src/jobs/__tests__/job-event-trigger.integration.ts +172 -0
  221. package/src/jobs/__tests__/job-multi-trigger.integration.ts +144 -0
  222. package/src/jobs/__tests__/jobs.integration.ts +566 -0
  223. package/src/jobs/index.ts +2 -0
  224. package/src/jobs/job-runner.ts +574 -0
  225. package/src/lifecycle/__tests__/create-test-lifecycle.ts +19 -0
  226. package/src/lifecycle/__tests__/lifecycle-server.integration.ts +108 -0
  227. package/src/lifecycle/__tests__/lifecycle.test.ts +212 -0
  228. package/src/lifecycle/__tests__/signal-handlers.test.ts +106 -0
  229. package/src/lifecycle/index.ts +13 -0
  230. package/src/lifecycle/lifecycle.ts +160 -0
  231. package/src/lifecycle/signal-handlers.ts +62 -0
  232. package/src/logging/__tests__/pino-trace-bridge.test.ts +50 -0
  233. package/src/logging/index.ts +3 -0
  234. package/src/logging/pino-logger.ts +64 -0
  235. package/src/logging/types.ts +7 -0
  236. package/src/migrations/__tests__/compare-snapshots.test.ts +150 -0
  237. package/src/migrations/__tests__/detect-drift.integration.ts +320 -0
  238. package/src/migrations/__tests__/detect-projections-to-rebuild.integration.ts +134 -0
  239. package/src/migrations/__tests__/rebuild-marker.test.ts +79 -0
  240. package/src/migrations/index.ts +28 -0
  241. package/src/migrations/projection-detection.ts +149 -0
  242. package/src/migrations/rebuild-marker.ts +64 -0
  243. package/src/migrations/schema-drift.ts +395 -0
  244. package/src/observability/__tests__/console-provider.test.ts +67 -0
  245. package/src/observability/__tests__/metric-validator.test.ts +87 -0
  246. package/src/observability/__tests__/noop-provider.test.ts +82 -0
  247. package/src/observability/__tests__/observability.integration.ts +559 -0
  248. package/src/observability/__tests__/prometheus-meter.test.ts +144 -0
  249. package/src/observability/__tests__/recording-meter.test.ts +101 -0
  250. package/src/observability/__tests__/recording-tracer.test.ts +110 -0
  251. package/src/observability/__tests__/sensitive-filter.test.ts +98 -0
  252. package/src/observability/console-provider.ts +130 -0
  253. package/src/observability/context.ts +26 -0
  254. package/src/observability/fallback.ts +34 -0
  255. package/src/observability/ids.ts +25 -0
  256. package/src/observability/index.ts +79 -0
  257. package/src/observability/metric-validator.ts +86 -0
  258. package/src/observability/metrics-handle.ts +56 -0
  259. package/src/observability/noop-provider.ts +146 -0
  260. package/src/observability/prometheus-meter.ts +284 -0
  261. package/src/observability/recording-meter.ts +156 -0
  262. package/src/observability/recording-tracer.ts +198 -0
  263. package/src/observability/redis-wrapper.ts +132 -0
  264. package/src/observability/sensitive-filter.ts +108 -0
  265. package/src/observability/standard-metrics.ts +213 -0
  266. package/src/observability/types/index.ts +29 -0
  267. package/src/observability/types/metric.ts +56 -0
  268. package/src/observability/types/provider.ts +32 -0
  269. package/src/observability/types/span.ts +64 -0
  270. package/src/pipeline/__tests__/archive-stream.integration.ts +220 -0
  271. package/src/pipeline/__tests__/auth-claims-resolver.test.ts +279 -0
  272. package/src/pipeline/__tests__/cascade-handler.integration.ts +419 -0
  273. package/src/pipeline/__tests__/cascade-handler.test.ts +52 -0
  274. package/src/pipeline/__tests__/causation-chain.integration.ts +206 -0
  275. package/src/pipeline/__tests__/ctx-bridge.integration.ts +234 -0
  276. package/src/pipeline/__tests__/dispatcher.test.ts +379 -0
  277. package/src/pipeline/__tests__/distributed-lock.integration.ts +67 -0
  278. package/src/pipeline/__tests__/domain-events-projections.integration.ts +323 -0
  279. package/src/pipeline/__tests__/event-dedup.integration.ts +153 -0
  280. package/src/pipeline/__tests__/event-define-event-strict.integration.ts +202 -0
  281. package/src/pipeline/__tests__/event-dispatcher-lifecycle.integration.ts +220 -0
  282. package/src/pipeline/__tests__/event-dispatcher-multi-instance.integration.ts +423 -0
  283. package/src/pipeline/__tests__/event-dispatcher-pg-listen.integration.ts +123 -0
  284. package/src/pipeline/__tests__/event-dispatcher-recovery.integration.ts +202 -0
  285. package/src/pipeline/__tests__/event-dispatcher-second-audit.integration.ts +290 -0
  286. package/src/pipeline/__tests__/event-dispatcher-strict.test.ts +65 -0
  287. package/src/pipeline/__tests__/event-dispatcher.integration.ts +287 -0
  288. package/src/pipeline/__tests__/event-retention.integration.ts +239 -0
  289. package/src/pipeline/__tests__/fetch-for-writing.integration.ts +281 -0
  290. package/src/pipeline/__tests__/lifecycle-pipeline.test.ts +430 -0
  291. package/src/pipeline/__tests__/load-aggregate-query.integration.ts +266 -0
  292. package/src/pipeline/__tests__/msp-error-mode.integration.ts +149 -0
  293. package/src/pipeline/__tests__/msp-multi-hop.integration.ts +228 -0
  294. package/src/pipeline/__tests__/msp-rebuild.integration.ts +368 -0
  295. package/src/pipeline/__tests__/multi-stream-projection.integration.ts +341 -0
  296. package/src/pipeline/__tests__/perf-rebuild.integration.ts +147 -0
  297. package/src/pipeline/__tests__/projection-rebuild.integration.ts +551 -0
  298. package/src/pipeline/__tests__/query-projection.integration.ts +201 -0
  299. package/src/pipeline/__tests__/redis-pipeline.integration.ts +306 -0
  300. package/src/pipeline/append-event-core.ts +117 -0
  301. package/src/pipeline/auth-claims-resolver.ts +103 -0
  302. package/src/pipeline/cascade-handler.ts +113 -0
  303. package/src/pipeline/dispatcher.ts +1585 -0
  304. package/src/pipeline/distributed-lock.ts +37 -0
  305. package/src/pipeline/entity-cache.ts +113 -0
  306. package/src/pipeline/event-consumer-state.ts +108 -0
  307. package/src/pipeline/event-dedup.ts +23 -0
  308. package/src/pipeline/event-dispatcher.ts +1016 -0
  309. package/src/pipeline/event-retention.ts +154 -0
  310. package/src/pipeline/idempotency.ts +76 -0
  311. package/src/pipeline/index.ts +66 -0
  312. package/src/pipeline/lifecycle-pipeline.ts +409 -0
  313. package/src/pipeline/msp-rebuild.ts +242 -0
  314. package/src/pipeline/multi-stream-apply-context.ts +115 -0
  315. package/src/pipeline/projection-rebuild.ts +334 -0
  316. package/src/pipeline/projection-state.ts +72 -0
  317. package/src/pipeline/projections-runner.ts +56 -0
  318. package/src/pipeline/redis-keys.ts +11 -0
  319. package/src/pipeline/system-hooks.ts +190 -0
  320. package/src/random/__tests__/generate.test.ts +149 -0
  321. package/src/random/generate.ts +141 -0
  322. package/src/random/index.ts +8 -0
  323. package/src/random/words.ts +392 -0
  324. package/src/rate-limit/__tests__/dispatcher-l3.integration.ts +111 -0
  325. package/src/rate-limit/__tests__/middleware.integration.ts +189 -0
  326. package/src/rate-limit/__tests__/resolver.integration.ts +189 -0
  327. package/src/rate-limit/bucket.ts +36 -0
  328. package/src/rate-limit/index.ts +14 -0
  329. package/src/rate-limit/middleware.ts +152 -0
  330. package/src/rate-limit/resolver.ts +267 -0
  331. package/src/redis/__tests__/redis-options.test.ts +54 -0
  332. package/src/redis/index.ts +74 -0
  333. package/src/search/__tests__/meilisearch-adapter.integration.ts +236 -0
  334. package/src/search/__tests__/search-adapter.test.ts +256 -0
  335. package/src/search/in-memory-adapter.ts +123 -0
  336. package/src/search/index.ts +12 -0
  337. package/src/search/meilisearch-adapter.ts +106 -0
  338. package/src/search/types.ts +39 -0
  339. package/src/secrets/__tests__/dek-cache.test.ts +213 -0
  340. package/src/secrets/__tests__/env-master-key-provider.test.ts +119 -0
  341. package/src/secrets/__tests__/envelope.test.ts +74 -0
  342. package/src/secrets/__tests__/leak-guard.test.ts +92 -0
  343. package/src/secrets/__tests__/rotation.test.ts +149 -0
  344. package/src/secrets/dek-cache.ts +116 -0
  345. package/src/secrets/env-master-key-provider.ts +162 -0
  346. package/src/secrets/envelope.ts +55 -0
  347. package/src/secrets/index.ts +19 -0
  348. package/src/secrets/leak-guard.ts +87 -0
  349. package/src/secrets/rotation.ts +34 -0
  350. package/src/secrets/types.ts +107 -0
  351. package/src/stack/db.ts +104 -0
  352. package/src/stack/event-collector.ts +23 -0
  353. package/src/stack/index.ts +32 -0
  354. package/src/stack/redis.ts +44 -0
  355. package/src/stack/request-helper.ts +168 -0
  356. package/src/stack/table-helpers.ts +104 -0
  357. package/src/stack/test-stack.ts +357 -0
  358. package/src/stack/test-users.ts +37 -0
  359. package/src/testing/__tests__/e2e-generator.test.ts +230 -0
  360. package/src/testing/__tests__/ensure-entity-table.integration.ts +54 -0
  361. package/src/testing/access-assertions.ts +15 -0
  362. package/src/testing/assertions.ts +35 -0
  363. package/src/testing/e2e-generator.ts +465 -0
  364. package/src/testing/expect-error.ts +25 -0
  365. package/src/testing/handler-context.ts +125 -0
  366. package/src/testing/http-cookies.ts +52 -0
  367. package/src/testing/index.ts +41 -0
  368. package/src/testing/late-bound.ts +39 -0
  369. package/src/testing/mutable-master-key-provider.ts +31 -0
  370. package/src/testing/observability-recorder.ts +54 -0
  371. package/src/testing/shared-entities.ts +49 -0
  372. package/src/testing/utils.ts +1 -0
  373. package/src/testing/wait-for.ts +31 -0
  374. package/src/time/__tests__/polyfill.test.ts +73 -0
  375. package/src/time/__tests__/tz-context.test.ts +121 -0
  376. package/src/time/index.ts +21 -0
  377. package/src/time/polyfill.ts +70 -0
  378. package/src/time/tz-context.ts +107 -0
  379. package/src/ui-types/app-schema.ts +57 -0
  380. package/src/ui-types/index.ts +65 -0
  381. package/src/utils/__tests__/assert.test.ts +17 -0
  382. package/src/utils/__tests__/env-parse.test.ts +54 -0
  383. package/src/utils/assert.ts +18 -0
  384. package/src/utils/env-parse.ts +16 -0
  385. package/src/utils/ids.ts +16 -0
  386. package/src/utils/index.ts +5 -0
  387. package/src/utils/safe-json.ts +30 -0
  388. package/src/utils/serialization.ts +7 -0
@@ -0,0 +1,1865 @@
1
+ import { pgTable, text } from "drizzle-orm/pg-core";
2
+ import { describe, expect, test } from "vitest";
3
+ import { z } from "zod";
4
+ import { validateBoot } from "../boot-validator";
5
+ import { createSystemConfig, createTenantConfig } from "../config-helpers";
6
+ import {
7
+ createEntity,
8
+ createMultiSelectField,
9
+ createTextField,
10
+ defineFeature,
11
+ from,
12
+ } from "../index";
13
+
14
+ describe("boot-validator", () => {
15
+ test("passes for valid features with no issues", () => {
16
+ const features = [
17
+ defineFeature("a", (r) => {
18
+ r.entity("user", createEntity({ table: "Users", fields: { name: createTextField() } }));
19
+ }),
20
+ ];
21
+ expect(() => validateBoot(features)).not.toThrow();
22
+ });
23
+
24
+ // --- Circular dependencies ---
25
+
26
+ test("detects circular requires: A → B → A", () => {
27
+ const features = [
28
+ defineFeature("a", (r) => {
29
+ r.requires("b");
30
+ }),
31
+ defineFeature("b", (r) => {
32
+ r.requires("a");
33
+ }),
34
+ ];
35
+ expect(() => validateBoot(features)).toThrow(/circular dependency.*a.*b/i);
36
+ });
37
+
38
+ test("detects circular requires: A → B → C → A", () => {
39
+ const features = [
40
+ defineFeature("a", (r) => {
41
+ r.requires("b");
42
+ }),
43
+ defineFeature("b", (r) => {
44
+ r.requires("c");
45
+ }),
46
+ defineFeature("c", (r) => {
47
+ r.requires("a");
48
+ }),
49
+ ];
50
+ expect(() => validateBoot(features)).toThrow(/circular dependency/i);
51
+ });
52
+
53
+ test("no circular dependency for diamond shape: A → B, A → C, B → D, C → D", () => {
54
+ const features = [
55
+ defineFeature("d", () => {}),
56
+ defineFeature("b", (r) => {
57
+ r.requires("d");
58
+ }),
59
+ defineFeature("c", (r) => {
60
+ r.requires("d");
61
+ }),
62
+ defineFeature("a", (r) => {
63
+ r.requires("b", "c");
64
+ }),
65
+ ];
66
+ expect(() => validateBoot(features)).not.toThrow();
67
+ });
68
+
69
+ // --- encrypted + searchable ---
70
+
71
+ test("rejects encrypted + searchable field", () => {
72
+ const features = [
73
+ defineFeature("a", (r) => {
74
+ r.entity(
75
+ "secret",
76
+ createEntity({
77
+ table: "Secrets",
78
+ fields: {
79
+ apiKey: { type: "text", encrypted: true, searchable: true },
80
+ },
81
+ }),
82
+ );
83
+ }),
84
+ ];
85
+ expect(() => validateBoot(features)).toThrow(
86
+ /apiKey.*cannot be both encrypted and searchable/i,
87
+ );
88
+ });
89
+
90
+ test("rejects encrypted + sortable field", () => {
91
+ const features = [
92
+ defineFeature("a", (r) => {
93
+ r.entity(
94
+ "secret",
95
+ createEntity({
96
+ table: "Secrets",
97
+ fields: {
98
+ token: { type: "text", encrypted: true, sortable: true },
99
+ },
100
+ }),
101
+ );
102
+ }),
103
+ ];
104
+ expect(() => validateBoot(features)).toThrow(/token.*cannot be both encrypted and sortable/i);
105
+ });
106
+
107
+ test("allows encrypted field when ENCRYPTION_KEY is set", () => {
108
+ process.env["ENCRYPTION_KEY"] = "test-key";
109
+ try {
110
+ const features = [
111
+ defineFeature("a", (r) => {
112
+ r.entity(
113
+ "secret",
114
+ createEntity({
115
+ table: "Secrets",
116
+ fields: {
117
+ apiKey: { type: "text", encrypted: true },
118
+ },
119
+ }),
120
+ );
121
+ }),
122
+ ];
123
+ expect(() => validateBoot(features)).not.toThrow();
124
+ } finally {
125
+ delete process.env["ENCRYPTION_KEY"];
126
+ }
127
+ });
128
+
129
+ test("throws when encrypted fields exist but ENCRYPTION_KEY not set", () => {
130
+ delete process.env["ENCRYPTION_KEY"];
131
+ const features = [
132
+ defineFeature("a", (r) => {
133
+ r.entity(
134
+ "secret",
135
+ createEntity({
136
+ table: "Secrets",
137
+ fields: {
138
+ apiKey: { type: "text", encrypted: true },
139
+ },
140
+ }),
141
+ );
142
+ }),
143
+ ];
144
+ expect(() => validateBoot(features)).toThrow(/ENCRYPTION_KEY.*required/i);
145
+ });
146
+
147
+ test("throws when longText encrypted field exists but ENCRYPTION_KEY not set", () => {
148
+ // Drift-pin Sprint-5b-vorab-Audit Issue 1: validateEncryptedFields
149
+ // hatte `if (field.type !== "text") continue;` und ignorierte
150
+ // longText-encrypted-fields silently — ENCRYPTION_KEY-check wurde
151
+ // nie getriggert, encryption silent broken. Jetzt: beide string-
152
+ // typed fields werden gechecked.
153
+ delete process.env["ENCRYPTION_KEY"];
154
+ const features = [
155
+ defineFeature("a", (r) => {
156
+ r.entity(
157
+ "doc",
158
+ createEntity({
159
+ table: "Docs",
160
+ fields: {
161
+ body: { type: "longText", encrypted: true },
162
+ },
163
+ }),
164
+ );
165
+ }),
166
+ ];
167
+ expect(() => validateBoot(features)).toThrow(/ENCRYPTION_KEY.*required/i);
168
+ });
169
+
170
+ // --- index-validator longText block ---
171
+
172
+ test("rejects longText field in entity.indexes (longText is not indexable)", () => {
173
+ // Drift-pin Sprint-5b-vorab-Audit Issue 2: ohne den Check würde
174
+ // ein BTREE-Index auf einer 1-MB-text-Spalte gebaut werden
175
+ // (Performance-Disaster mit TOAST-page-Dereferenzierung). longText
176
+ // ist semantisch non-indexierbar, konsistent zum type-level
177
+ // sortable=false.
178
+ const features = [
179
+ defineFeature("a", (r) => {
180
+ r.entity(
181
+ "doc",
182
+ createEntity({
183
+ table: "Docs",
184
+ fields: {
185
+ body: { type: "longText" },
186
+ title: { type: "text" },
187
+ },
188
+ indexes: [{ columns: ["title", "body"] }],
189
+ }),
190
+ );
191
+ }),
192
+ ];
193
+ expect(() => validateBoot(features)).toThrow(/body.*longText.*cannot be indexed/i);
194
+ });
195
+
196
+ // --- Extension usage without requires ---
197
+
198
+ test("warns when extension used without requires", () => {
199
+ const ext = defineFeature("tags", (r) => {
200
+ r.extendsRegistrar("tags", { onRegister: () => {} });
201
+ });
202
+ const consumer = defineFeature("fleet", (r) => {
203
+ r.entity("vehicle", createEntity({ table: "Vehicles", fields: {} }));
204
+ r.useExtension("tags", "vehicle");
205
+ // Missing: r.requires("tags")
206
+ });
207
+
208
+ expect(() => validateBoot([ext, consumer])).toThrow(/fleet.*uses extension "tags".*requires/i);
209
+ });
210
+
211
+ test("passes when extension used with requires", () => {
212
+ const ext = defineFeature("tags", (r) => {
213
+ r.extendsRegistrar("tags", { onRegister: () => {} });
214
+ });
215
+ const consumer = defineFeature("fleet", (r) => {
216
+ r.requires("tags");
217
+ r.entity("vehicle", createEntity({ table: "Vehicles", fields: {} }));
218
+ r.useExtension("tags", "vehicle");
219
+ });
220
+
221
+ expect(() => validateBoot([ext, consumer])).not.toThrow();
222
+ });
223
+
224
+ test("passes when extension used with optionalRequires", () => {
225
+ const ext = defineFeature("tags", (r) => {
226
+ r.extendsRegistrar("tags", { onRegister: () => {} });
227
+ });
228
+ const consumer = defineFeature("fleet", (r) => {
229
+ r.optionalRequires("tags");
230
+ r.entity("vehicle", createEntity({ table: "Vehicles", fields: {} }));
231
+ r.useExtension("tags", "vehicle");
232
+ });
233
+
234
+ expect(() => validateBoot([ext, consumer])).not.toThrow();
235
+ });
236
+
237
+ // --- FILE_STORAGE_PROVIDER ---
238
+
239
+ test("throws when file fields exist but FILE_STORAGE_PROVIDER not set", () => {
240
+ delete process.env["FILE_STORAGE_PROVIDER"];
241
+ const features = [
242
+ defineFeature("a", (r) => {
243
+ r.entity(
244
+ "doc",
245
+ createEntity({
246
+ table: "Docs",
247
+ fields: { contract: { type: "file" } },
248
+ }),
249
+ );
250
+ }),
251
+ ];
252
+ expect(() => validateBoot(features)).toThrow(/FILE_STORAGE_PROVIDER.*required/i);
253
+ });
254
+
255
+ test("passes when file fields exist and FILE_STORAGE_PROVIDER is set", () => {
256
+ process.env["FILE_STORAGE_PROVIDER"] = "local";
257
+ try {
258
+ const features = [
259
+ defineFeature("a", (r) => {
260
+ r.entity(
261
+ "doc",
262
+ createEntity({
263
+ table: "Docs",
264
+ fields: { photo: { type: "image" } },
265
+ }),
266
+ );
267
+ }),
268
+ ];
269
+ expect(() => validateBoot(features)).not.toThrow();
270
+ } finally {
271
+ delete process.env["FILE_STORAGE_PROVIDER"];
272
+ }
273
+ });
274
+
275
+ // --- extendSchema column collision ---
276
+
277
+ test("throws when extendSchema column conflicts with existing field", () => {
278
+ const features = [
279
+ defineFeature("a", (r) => {
280
+ r.entity(
281
+ "item",
282
+ createEntity({
283
+ table: "Items",
284
+ fields: { name: createTextField() },
285
+ }),
286
+ );
287
+ r.extendsRegistrar("custom", {
288
+ extendSchema: () => ({ name: { type: "text" as const } }),
289
+ });
290
+ }),
291
+ ];
292
+ expect(() => validateBoot(features)).toThrow(
293
+ /extendSchema column "name" conflicts.*entity "item"/i,
294
+ );
295
+ });
296
+
297
+ // --- Config key cross-feature references ---
298
+
299
+ test("throws when readsConfig references non-existent key", () => {
300
+ const features = [
301
+ defineFeature("invoicing", (r) => {
302
+ r.readsConfig("payments.gateway");
303
+ }),
304
+ ];
305
+ expect(() => validateBoot(features)).toThrow(
306
+ /invoicing.*reads config "payments.gateway".*no feature defines/i,
307
+ );
308
+ });
309
+
310
+ test("passes when readsConfig references existing key", () => {
311
+ const features = [
312
+ defineFeature("payments", (r) => {
313
+ r.config({
314
+ keys: {
315
+ gateway: {
316
+ type: "text",
317
+ scope: "tenant",
318
+ access: { read: ["all"], write: ["Admin"] },
319
+ },
320
+ },
321
+ });
322
+ }),
323
+ defineFeature("invoicing", (r) => {
324
+ r.requires("payments");
325
+ r.readsConfig("payments.gateway");
326
+ }),
327
+ ];
328
+ expect(() => validateBoot(features)).not.toThrow();
329
+ });
330
+
331
+ test("passes when extendSchema adds non-conflicting column", () => {
332
+ const features = [
333
+ defineFeature("a", (r) => {
334
+ r.entity(
335
+ "item",
336
+ createEntity({
337
+ table: "Items",
338
+ fields: { name: createTextField() },
339
+ }),
340
+ );
341
+ r.extendsRegistrar("custom", {
342
+ extendSchema: () => ({ extra: { type: "text" as const } }),
343
+ });
344
+ }),
345
+ ];
346
+ expect(() => validateBoot(features)).not.toThrow();
347
+ });
348
+
349
+ // --- Handler access validation (default-deny) ---
350
+
351
+ test("throws when a write handler has no access rule", () => {
352
+ const features = [
353
+ defineFeature("a", (r) => {
354
+ r.writeHandler("createThing", z.object({ name: z.string() }), async () => ({
355
+ isSuccess: true as const,
356
+ data: {},
357
+ }));
358
+ }),
359
+ ];
360
+ expect(() => validateBoot(features)).toThrow(/a:write:createThing.*missing an access rule/i);
361
+ });
362
+
363
+ test("throws when a query handler has no access rule", () => {
364
+ const features = [
365
+ defineFeature("a", (r) => {
366
+ r.queryHandler("list", z.object({}), async () => []);
367
+ }),
368
+ ];
369
+ expect(() => validateBoot(features)).toThrow(/a:query:list.*missing an access rule/i);
370
+ });
371
+
372
+ test("accepts role-based access rule", () => {
373
+ const features = [
374
+ defineFeature("a", (r) => {
375
+ r.queryHandler("list", z.object({}), async () => [], {
376
+ access: { roles: ["Admin"] },
377
+ });
378
+ }),
379
+ ];
380
+ expect(() => validateBoot(features)).not.toThrow();
381
+ });
382
+
383
+ test("accepts openToAll access rule", () => {
384
+ const features = [
385
+ defineFeature("a", (r) => {
386
+ r.queryHandler("list", z.object({}), async () => [], {
387
+ access: { openToAll: true },
388
+ });
389
+ }),
390
+ ];
391
+ expect(() => validateBoot(features)).not.toThrow();
392
+ });
393
+
394
+ describe("config key bounds consistency", () => {
395
+ test("accepts number key with consistent bounds + default", () => {
396
+ const features = [
397
+ defineFeature("files", (r) => {
398
+ r.config({
399
+ keys: {
400
+ maxUploadMB: createTenantConfig("number", {
401
+ default: 10,
402
+ bounds: { min: 1, max: 100 },
403
+ }),
404
+ },
405
+ });
406
+ }),
407
+ ];
408
+ expect(() => validateBoot(features)).not.toThrow();
409
+ });
410
+
411
+ test("rejects min > max", () => {
412
+ const features = [
413
+ defineFeature("files", (r) => {
414
+ r.config({
415
+ keys: {
416
+ weird: createTenantConfig("number", { bounds: { min: 100, max: 10 } }),
417
+ },
418
+ });
419
+ }),
420
+ ];
421
+ expect(() => validateBoot(features)).toThrow(/bounds\.min.*>.*bounds\.max/i);
422
+ });
423
+
424
+ test("rejects default below min", () => {
425
+ const features = [
426
+ defineFeature("files", (r) => {
427
+ r.config({
428
+ keys: {
429
+ tooLow: createTenantConfig("number", {
430
+ default: 0,
431
+ bounds: { min: 1, max: 100 },
432
+ }),
433
+ },
434
+ });
435
+ }),
436
+ ];
437
+ expect(() => validateBoot(features)).toThrow(/default.*below bounds\.min/i);
438
+ });
439
+
440
+ test("rejects default above max", () => {
441
+ const features = [
442
+ defineFeature("files", (r) => {
443
+ r.config({
444
+ keys: {
445
+ tooHigh: createSystemConfig("number", {
446
+ default: 200,
447
+ bounds: { max: 100 },
448
+ }),
449
+ },
450
+ });
451
+ }),
452
+ ];
453
+ expect(() => validateBoot(features)).toThrow(/default.*above bounds\.max/i);
454
+ });
455
+
456
+ test("accepts partial bounds (only min)", () => {
457
+ const features = [
458
+ defineFeature("files", (r) => {
459
+ r.config({
460
+ keys: {
461
+ lowerOnly: createTenantConfig("number", {
462
+ default: 5,
463
+ bounds: { min: 1 },
464
+ }),
465
+ },
466
+ });
467
+ }),
468
+ ];
469
+ expect(() => validateBoot(features)).not.toThrow();
470
+ });
471
+
472
+ test("accepts bounds without default (bound-only key)", () => {
473
+ const features = [
474
+ defineFeature("files", (r) => {
475
+ r.config({
476
+ keys: {
477
+ bounded: createTenantConfig("number", { bounds: { min: 1, max: 100 } }),
478
+ },
479
+ });
480
+ }),
481
+ ];
482
+ expect(() => validateBoot(features)).not.toThrow();
483
+ });
484
+
485
+ test("rejects bounds on non-number key (defence in depth against hand-rolled definitions)", () => {
486
+ const features = [
487
+ defineFeature("files", (r) => {
488
+ // Cast needed because type-level guard rejects this at the call site.
489
+ // Boot validator catches the same class of bug when someone bypasses
490
+ // the helper (e.g. importing a plain ConfigKeyDefinition object).
491
+ r.config({
492
+ keys: {
493
+ textKey: {
494
+ type: "text",
495
+ scope: "tenant",
496
+ access: { read: ["all"], write: ["all"] },
497
+ bounds: { min: 1 },
498
+ // biome-ignore lint/suspicious/noExplicitAny: intentional type bypass for defence-in-depth test
499
+ } as any,
500
+ },
501
+ });
502
+ }),
503
+ ];
504
+ expect(() => validateBoot(features)).toThrow(/bounds.*only valid for type="number"/i);
505
+ });
506
+ });
507
+
508
+ describe("config key computed + encrypted exclusivity", () => {
509
+ test("rejects encrypted + computed combination", () => {
510
+ const features = [
511
+ defineFeature("files", (r) => {
512
+ r.config({
513
+ keys: {
514
+ mixed: {
515
+ type: "text",
516
+ scope: "tenant",
517
+ access: { read: ["all"], write: ["all"] },
518
+ encrypted: true,
519
+ computed: async () => "x",
520
+ // biome-ignore lint/suspicious/noExplicitAny: hand-rolled definition bypasses helper-level type narrowing
521
+ } as any,
522
+ },
523
+ });
524
+ }),
525
+ ];
526
+ expect(() => validateBoot(features)).toThrow(/encrypted.*computed.*mutually exclusive/i);
527
+ });
528
+
529
+ test("accepts computed without encrypted (normal plan-based use-case)", () => {
530
+ const features = [
531
+ defineFeature("files", (r) => {
532
+ r.config({
533
+ keys: {
534
+ planBased: createTenantConfig("number", {
535
+ default: 10,
536
+ computed: async () => 100,
537
+ }),
538
+ },
539
+ });
540
+ }),
541
+ ];
542
+ expect(() => validateBoot(features)).not.toThrow();
543
+ });
544
+ });
545
+
546
+ describe("config key allowPerRequest compatibility", () => {
547
+ test("accepts allowPerRequest on number keys", () => {
548
+ const features = [
549
+ defineFeature("files", (r) => {
550
+ r.config({
551
+ keys: {
552
+ maxSize: createTenantConfig("number", {
553
+ default: 10,
554
+ bounds: { min: 1, max: 1000 },
555
+ allowPerRequest: true,
556
+ }),
557
+ },
558
+ });
559
+ }),
560
+ ];
561
+ expect(() => validateBoot(features)).not.toThrow();
562
+ });
563
+
564
+ test("rejects allowPerRequest on text keys (hand-rolled bypass)", () => {
565
+ const features = [
566
+ defineFeature("files", (r) => {
567
+ r.config({
568
+ keys: {
569
+ hacked: {
570
+ type: "text",
571
+ scope: "tenant",
572
+ access: { read: ["all"], write: ["all"] },
573
+ allowPerRequest: true,
574
+ // biome-ignore lint/suspicious/noExplicitAny: defence-in-depth test for hand-rolled definitions
575
+ } as any,
576
+ },
577
+ });
578
+ }),
579
+ ];
580
+ expect(() => validateBoot(features)).toThrow(/allowPerRequest.*type="text".*ineligible/i);
581
+ });
582
+
583
+ test("rejects allowPerRequest on encrypted keys (secret-value protection)", () => {
584
+ const features = [
585
+ defineFeature("secrets", (r) => {
586
+ r.config({
587
+ keys: {
588
+ apiKey: {
589
+ type: "number",
590
+ scope: "tenant",
591
+ access: { read: ["Admin"], write: ["Admin"] },
592
+ encrypted: true,
593
+ allowPerRequest: true,
594
+ // biome-ignore lint/suspicious/noExplicitAny: defence-in-depth test for hand-rolled definitions
595
+ } as any,
596
+ },
597
+ });
598
+ }),
599
+ ];
600
+ expect(() => validateBoot(features)).toThrow(
601
+ /allowPerRequest.*encrypted.*secret values may not be set via query-params/i,
602
+ );
603
+ });
604
+ });
605
+
606
+ // --- H.2 Ownership-Rule validation ---
607
+
608
+ describe("ownership rules (H.2)", () => {
609
+ test("passes for entity.access.read with claim-rule whose QN exists", () => {
610
+ const features = [
611
+ defineFeature("teams", (r) => {
612
+ r.claimKey("teamId", { type: "string" });
613
+ }),
614
+ defineFeature("orders", (r) => {
615
+ r.entity(
616
+ "order",
617
+ createEntity({
618
+ table: "orders",
619
+ fields: { teamId: createTextField({ required: true }) },
620
+ access: {
621
+ read: { Admin: "all", TeamMember: from("claim:teams:teamId") },
622
+ },
623
+ }),
624
+ );
625
+ }),
626
+ ];
627
+ expect(() => validateBoot(features)).not.toThrow();
628
+ });
629
+
630
+ test("detects claim-QN that no feature declared", () => {
631
+ const features = [
632
+ defineFeature("orders", (r) => {
633
+ r.entity(
634
+ "order",
635
+ createEntity({
636
+ table: "orders",
637
+ fields: { teamId: createTextField({ required: true }) },
638
+ access: {
639
+ // No "teams" feature registered — claim doesn't exist.
640
+ read: { TeamMember: from("claim:teams:teamId") },
641
+ },
642
+ }),
643
+ );
644
+ }),
645
+ ];
646
+ expect(() => validateBoot(features)).toThrow(
647
+ /entity "order"\.access\.read.*references unknown claim "teams:teamId"/,
648
+ );
649
+ });
650
+
651
+ test("detects column name that doesn't exist on the entity", () => {
652
+ const features = [
653
+ defineFeature("teams", (r) => {
654
+ r.claimKey("teamId", { type: "string" });
655
+ }),
656
+ defineFeature("orders", (r) => {
657
+ r.entity(
658
+ "order",
659
+ createEntity({
660
+ table: "orders",
661
+ fields: { teamId: createTextField({ required: true }) },
662
+ access: {
663
+ read: {
664
+ // column "nonExistentColumn" not on entity
665
+ TeamMember: from("claim:teams:teamId", "nonExistentColumn"),
666
+ },
667
+ },
668
+ }),
669
+ );
670
+ }),
671
+ ];
672
+ expect(() => validateBoot(features)).toThrow(
673
+ /references column "nonExistentColumn" which does not exist/,
674
+ );
675
+ });
676
+
677
+ test("passes for field-level ownership rule with existing claim + column", () => {
678
+ const features = [
679
+ defineFeature("teams", (r) => {
680
+ r.claimKey("teamId", { type: "string" });
681
+ }),
682
+ defineFeature("contracts", (r) => {
683
+ r.entity(
684
+ "contract",
685
+ createEntity({
686
+ table: "contracts",
687
+ fields: {
688
+ teamId: createTextField({ required: true }),
689
+ propC: createTextField({
690
+ access: {
691
+ read: { Admin: "all", TeamMember: from("claim:teams:teamId") },
692
+ write: { Admin: "all", TeamMember: from("claim:teams:teamId") },
693
+ },
694
+ }),
695
+ },
696
+ }),
697
+ );
698
+ }),
699
+ ];
700
+ expect(() => validateBoot(features)).not.toThrow();
701
+ });
702
+
703
+ test("detects unknown claim on field-level rule", () => {
704
+ const features = [
705
+ defineFeature("contracts", (r) => {
706
+ r.entity(
707
+ "contract",
708
+ createEntity({
709
+ table: "contracts",
710
+ fields: {
711
+ teamId: createTextField({ required: true }),
712
+ propC: createTextField({
713
+ access: {
714
+ // claim not declared anywhere
715
+ read: { TeamMember: from("claim:nowhere:teamId") },
716
+ },
717
+ }),
718
+ },
719
+ }),
720
+ );
721
+ }),
722
+ ];
723
+ expect(() => validateBoot(features)).toThrow(
724
+ /contract\.propC\.access\.read.*references unknown claim "nowhere:teamId"/,
725
+ );
726
+ });
727
+
728
+ test("user-ref rule with valid column passes", () => {
729
+ const features = [
730
+ defineFeature("orders", (r) => {
731
+ r.entity(
732
+ "order",
733
+ createEntity({
734
+ table: "orders",
735
+ fields: { assigneeId: createTextField() },
736
+ access: {
737
+ read: { Driver: from("user:id", "assigneeId") },
738
+ },
739
+ }),
740
+ );
741
+ }),
742
+ ];
743
+ expect(() => validateBoot(features)).not.toThrow();
744
+ });
745
+
746
+ test("'all' rule and { where } rule bypass validation (no ref to check)", () => {
747
+ const features = [
748
+ defineFeature("orders", (r) => {
749
+ r.entity(
750
+ "order",
751
+ createEntity({
752
+ table: "orders",
753
+ fields: { assigneeId: createTextField() },
754
+ access: {
755
+ read: {
756
+ Admin: "all",
757
+ Auditor: {
758
+ kind: "where",
759
+ where: () => ({ queryChunks: [] }) as never,
760
+ },
761
+ },
762
+ },
763
+ }),
764
+ );
765
+ }),
766
+ ];
767
+ expect(() => validateBoot(features)).not.toThrow();
768
+ });
769
+
770
+ test("framework columns (id, tenantId, version, ...) are acceptable targets", () => {
771
+ const features = [
772
+ defineFeature("teams", (r) => {
773
+ r.claimKey("teamId", { type: "string" });
774
+ }),
775
+ defineFeature("orders", (r) => {
776
+ r.entity(
777
+ "order",
778
+ createEntity({
779
+ table: "orders",
780
+ fields: {},
781
+ access: {
782
+ read: {
783
+ // tenantId is framework-managed — boot-validator should not reject
784
+ TeamMember: from("claim:teams:teamId", "tenantId"),
785
+ },
786
+ },
787
+ }),
788
+ );
789
+ }),
790
+ ];
791
+ expect(() => validateBoot(features)).not.toThrow();
792
+ });
793
+
794
+ // --- Role-name validation ---
795
+
796
+ test("detects role-name typo in OwnershipMap when other handlers declare the real role", () => {
797
+ // One feature runs a handler that declares the real role "Admin"; a
798
+ // second feature has a typo "Admi" in its OwnershipMap. Validator
799
+ // sees "Admin" in the known-role corpus (from handler.access.roles)
800
+ // and flags "Admi" as unknown.
801
+ const features = [
802
+ defineFeature("accounts", (r) => {
803
+ r.writeHandler({
804
+ name: "accounts:create",
805
+ schema: z.object({}),
806
+ handler: async () => ({ isSuccess: true as const, data: null }),
807
+ access: { roles: ["Admin"] },
808
+ });
809
+ }),
810
+ defineFeature("orders", (r) => {
811
+ r.entity(
812
+ "order",
813
+ createEntity({
814
+ table: "orders",
815
+ fields: { teamId: createTextField({ required: true }) },
816
+ access: {
817
+ read: { Admi: "all" },
818
+ },
819
+ }),
820
+ );
821
+ }),
822
+ ];
823
+ expect(() => validateBoot(features)).toThrow(
824
+ /unknown role "Admi".*Known roles: Admin, all, system/,
825
+ );
826
+ });
827
+
828
+ test("detects role-name typo in legacy string[] field-access", () => {
829
+ const features = [
830
+ defineFeature("accounts", (r) => {
831
+ r.writeHandler({
832
+ name: "accounts:create",
833
+ schema: z.object({}),
834
+ handler: async () => ({ isSuccess: true as const, data: null }),
835
+ access: { roles: ["Admin"] },
836
+ });
837
+ }),
838
+ defineFeature("orders", (r) => {
839
+ r.entity(
840
+ "order",
841
+ createEntity({
842
+ table: "orders",
843
+ fields: {
844
+ secret: createTextField({ access: { read: ["Admni"] } }),
845
+ },
846
+ }),
847
+ );
848
+ }),
849
+ ];
850
+ expect(() => validateBoot(features)).toThrow(
851
+ /order\.secret\.access\.read.*unknown role "Admni"/,
852
+ );
853
+ });
854
+
855
+ test("passes when all OwnershipMap roles are referenced by handler access rules too", () => {
856
+ const features = [
857
+ defineFeature("accounts", (r) => {
858
+ r.writeHandler({
859
+ name: "accounts:create",
860
+ schema: z.object({}),
861
+ handler: async () => ({ isSuccess: true as const, data: null }),
862
+ access: { roles: ["Admin", "TeamMember"] },
863
+ });
864
+ }),
865
+ defineFeature("orders", (r) => {
866
+ r.entity(
867
+ "order",
868
+ createEntity({
869
+ table: "orders",
870
+ fields: { teamId: createTextField({ required: true }) },
871
+ access: {
872
+ read: { Admin: "all", TeamMember: "all" },
873
+ },
874
+ }),
875
+ );
876
+ }),
877
+ ];
878
+ expect(() => validateBoot(features)).not.toThrow();
879
+ });
880
+
881
+ test("skips role validation entirely when no handlers declare non-builtin roles", () => {
882
+ // Apps running only on openToAll / system handlers have no corpus
883
+ // of known roles beyond "all"/"system" — validator must not flag
884
+ // their OwnershipMap roles as unknown. This is the regression test
885
+ // for the shouldValidateRoles gate.
886
+ const features = [
887
+ defineFeature("orders", (r) => {
888
+ r.entity(
889
+ "order",
890
+ createEntity({
891
+ table: "orders",
892
+ fields: { teamId: createTextField({ required: true }) },
893
+ access: {
894
+ read: { AnyRole: "all" },
895
+ },
896
+ }),
897
+ );
898
+ }),
899
+ ];
900
+ expect(() => validateBoot(features)).not.toThrow();
901
+ });
902
+ });
903
+
904
+ // --- MultiStreamProjection delivery invariant (Welle 2.7) ---
905
+
906
+ describe("MultiStreamProjection delivery", () => {
907
+ const sinkTable = pgTable("sink", { id: text("id").primaryKey() });
908
+
909
+ test("rejects delivery='per-instance' combined with a backing table", () => {
910
+ const features = [
911
+ defineFeature("sse", (r) => {
912
+ r.multiStreamProjection({
913
+ name: "broadcast",
914
+ table: sinkTable,
915
+ delivery: "per-instance",
916
+ apply: { "some:event": async () => {} },
917
+ });
918
+ }),
919
+ ];
920
+ expect(() => validateBoot(features)).toThrow(
921
+ /per-instance.+table.+duplicate INSERTs|cursor divergence/i,
922
+ );
923
+ });
924
+
925
+ test("accepts delivery='per-instance' without a table (side-effect-only)", () => {
926
+ const features = [
927
+ defineFeature("sse", (r) => {
928
+ r.multiStreamProjection({
929
+ name: "broadcast",
930
+ delivery: "per-instance",
931
+ apply: { "some:event": async () => {} },
932
+ });
933
+ }),
934
+ ];
935
+ expect(() => validateBoot(features)).not.toThrow();
936
+ });
937
+
938
+ test("accepts delivery='shared' with a table (default, materialized read-model)", () => {
939
+ const features = [
940
+ defineFeature("reports", (r) => {
941
+ r.multiStreamProjection({
942
+ name: "rollup",
943
+ table: sinkTable,
944
+ delivery: "shared",
945
+ apply: { "some:event": async () => {} },
946
+ });
947
+ }),
948
+ ];
949
+ expect(() => validateBoot(features)).not.toThrow();
950
+ });
951
+ });
952
+
953
+ // --- MultiSelect-Field-Validation ---
954
+
955
+ describe("multiSelect fields", () => {
956
+ test("accepts multiSelect with non-empty options", () => {
957
+ const features = [
958
+ defineFeature("driver", (r) => {
959
+ r.entity(
960
+ "profile",
961
+ createEntity({
962
+ fields: {
963
+ tags: createMultiSelectField({ options: ["a", "b", "c"] as const }),
964
+ },
965
+ }),
966
+ );
967
+ }),
968
+ ];
969
+ expect(() => validateBoot(features)).not.toThrow();
970
+ });
971
+
972
+ test("rejects multiSelect with empty options", () => {
973
+ const features = [
974
+ defineFeature("driver", (r) => {
975
+ r.entity(
976
+ "profile",
977
+ createEntity({
978
+ fields: {
979
+ // Cast over the empty-array hole — the factory's generic
980
+ // `as const` widens to `readonly never[]` for `[]`, which
981
+ // is what we want to test against. The validator catches
982
+ // it at boot.
983
+ tags: createMultiSelectField({ options: [] as readonly string[] }),
984
+ },
985
+ }),
986
+ );
987
+ }),
988
+ ];
989
+ expect(() => validateBoot(features)).toThrow(/empty options/);
990
+ });
991
+
992
+ test("rejects default value not in options", () => {
993
+ const features = [
994
+ defineFeature("driver", (r) => {
995
+ r.entity(
996
+ "profile",
997
+ createEntity({
998
+ fields: {
999
+ tags: createMultiSelectField({
1000
+ options: ["a", "b"] as const,
1001
+ // biome-ignore lint/suspicious/noExplicitAny: testing runtime guard
1002
+ default: ["c"] as any,
1003
+ }),
1004
+ },
1005
+ }),
1006
+ );
1007
+ }),
1008
+ ];
1009
+ expect(() => validateBoot(features)).toThrow(/not a valid option/);
1010
+ });
1011
+
1012
+ test("accepts default that is a subset of options", () => {
1013
+ const features = [
1014
+ defineFeature("driver", (r) => {
1015
+ r.entity(
1016
+ "profile",
1017
+ createEntity({
1018
+ fields: {
1019
+ tags: createMultiSelectField({
1020
+ options: ["a", "b", "c"] as const,
1021
+ default: ["a", "c"],
1022
+ }),
1023
+ },
1024
+ }),
1025
+ );
1026
+ }),
1027
+ ];
1028
+ expect(() => validateBoot(features)).not.toThrow();
1029
+ });
1030
+ });
1031
+
1032
+ // --- entityList column-renderer form-check ---
1033
+ // Validator akzeptiert die `{ react: { __component: "Name" } }`-Form
1034
+ // (PlatformComponent → client-side Registry-Lookup) und prüft sie
1035
+ // strukturell. String-Funktionen, null/undefined, native-only und
1036
+ // andere Formen bleiben opak.
1037
+ describe("entityList column renderer form", () => {
1038
+ function shopFeature(renderer: unknown) {
1039
+ return defineFeature("shop", (r) => {
1040
+ r.entity("product", createEntity({ fields: { name: createTextField() } }));
1041
+ r.screen({
1042
+ id: "product-list",
1043
+ type: "entityList",
1044
+ entity: "product",
1045
+ // Renderer ist absichtlich unknown — die Validator-Tests pinnen
1046
+ // auch Formen die der TS-Compiler bei sauberer Hand-Schreibe
1047
+ // niemals zulassen würde (leerer __component, number etc.).
1048
+ // kumiko-lint-ignore as-cast renderer ist Test-Fixture für invalid forms
1049
+ columns: [{ field: "name", renderer: renderer as never }],
1050
+ });
1051
+ });
1052
+ }
1053
+
1054
+ test("function-renderer → kein Throw (Bestand)", () => {
1055
+ expect(() => validateBoot([shopFeature((v: unknown) => String(v))])).not.toThrow();
1056
+ });
1057
+
1058
+ test("undefined renderer → kein Throw (Spalte ohne Renderer)", () => {
1059
+ expect(() => validateBoot([shopFeature(undefined)])).not.toThrow();
1060
+ });
1061
+
1062
+ test("null renderer → kein Throw (skip)", () => {
1063
+ expect(() => validateBoot([shopFeature(null)])).not.toThrow();
1064
+ });
1065
+
1066
+ test("object ohne react-Branch → kein Throw (z.B. native-only)", () => {
1067
+ expect(() => validateBoot([shopFeature({ native: { __component: "X" } })])).not.toThrow();
1068
+ });
1069
+
1070
+ test("react-Branch ist non-object → Throw mit klarer Message", () => {
1071
+ expect(() => validateBoot([shopFeature({ react: 42 })])).toThrow(/non-object `react` branch/);
1072
+ });
1073
+
1074
+ test("react-Branch ohne __component-Schlüssel → kein Throw (skip)", () => {
1075
+ // {} oder { __component: undefined } sind nicht unsere String-Key-Form
1076
+ expect(() => validateBoot([shopFeature({ react: {} })])).not.toThrow();
1077
+ });
1078
+
1079
+ test("react.__component leerer String → Throw", () => {
1080
+ expect(() => validateBoot([shopFeature({ react: { __component: "" } })])).toThrow(
1081
+ /expected a non-empty string/,
1082
+ );
1083
+ });
1084
+
1085
+ test("react.__component non-String (number) → Throw", () => {
1086
+ expect(() => validateBoot([shopFeature({ react: { __component: 42 } })])).toThrow(
1087
+ /expected a non-empty string/,
1088
+ );
1089
+ });
1090
+
1091
+ test("react.__component nicht-leerer String → kein Throw (gültige Form)", () => {
1092
+ expect(() =>
1093
+ validateBoot([shopFeature({ react: { __component: "ColorSwatch" } })]),
1094
+ ).not.toThrow();
1095
+ });
1096
+ });
1097
+
1098
+ // --- entityList: pagination + sort validation ---
1099
+ // Author-Fehler vor Production fangen, damit "Screen lädt nichts /
1100
+ // sortiert falsch / crasht beim Pager-Klick" nicht erst zur Laufzeit
1101
+ // bemerkt wird. Die Tests pinnen nur server-side Validierungen —
1102
+ // UI-Verhalten (Pager-Rendering) ist Renderer-Sache.
1103
+ describe("entityList pagination + sort", () => {
1104
+ function makeFeature(
1105
+ override: Partial<{
1106
+ readonly pageSize: number;
1107
+ readonly defaultSort: { readonly field: string; readonly dir: "asc" | "desc" };
1108
+ }>,
1109
+ ) {
1110
+ return defineFeature("shop", (r) => {
1111
+ r.entity(
1112
+ "product",
1113
+ createEntity({
1114
+ fields: {
1115
+ name: createTextField({ sortable: true }),
1116
+ // Bewusst NICHT sortable: bestätigt dass Validator das
1117
+ // unterscheidet und nur sortable-Felder als defaultSort
1118
+ // akzeptiert.
1119
+ description: createTextField(),
1120
+ },
1121
+ }),
1122
+ );
1123
+ r.screen({
1124
+ id: "product-list",
1125
+ type: "entityList",
1126
+ entity: "product",
1127
+ columns: [{ field: "name" }],
1128
+ ...override,
1129
+ });
1130
+ });
1131
+ }
1132
+
1133
+ test("pageSize: positiv → kein Throw", () => {
1134
+ expect(() => validateBoot([makeFeature({ pageSize: 100 })])).not.toThrow();
1135
+ });
1136
+
1137
+ test("pageSize: 0 → Throw mit klarer Message", () => {
1138
+ expect(() => validateBoot([makeFeature({ pageSize: 0 })])).toThrow(
1139
+ /pageSize=0 — must be a positive integer/,
1140
+ );
1141
+ });
1142
+
1143
+ test("pageSize: negativ → Throw", () => {
1144
+ expect(() => validateBoot([makeFeature({ pageSize: -10 })])).toThrow(
1145
+ /pageSize=-10 — must be a positive integer/,
1146
+ );
1147
+ });
1148
+
1149
+ test("defaultSort.field: existiert + sortable=true → kein Throw", () => {
1150
+ expect(() =>
1151
+ validateBoot([makeFeature({ defaultSort: { field: "name", dir: "asc" } })]),
1152
+ ).not.toThrow();
1153
+ });
1154
+
1155
+ test("defaultSort.field: existiert NICHT → Throw", () => {
1156
+ expect(() =>
1157
+ validateBoot([makeFeature({ defaultSort: { field: "ghost", dir: "asc" } })]),
1158
+ ).toThrow(/defaultSort references unknown field "ghost"/);
1159
+ });
1160
+
1161
+ test("defaultSort.field: existiert aber sortable=false → Throw", () => {
1162
+ expect(() =>
1163
+ validateBoot([makeFeature({ defaultSort: { field: "description", dir: "asc" } })]),
1164
+ ).toThrow(/defaultSort\.field "description" is not sortable/);
1165
+ });
1166
+ });
1167
+
1168
+ // --- Tier 2.7c: Screen-Filter ---
1169
+ // Drei Layer Author-Code-Validation: field-existiert, filterable: true
1170
+ // gesetzt, op passt zum Field-Type. Boot-Fail ist deutlich besser als
1171
+ // silent-leerer Bucket / Drizzle-Crash zur Laufzeit.
1172
+ describe("entityList screen-filter (Tier 2.7c)", () => {
1173
+ function makeFeature(
1174
+ filter: {
1175
+ readonly field: string;
1176
+ readonly op: "eq" | "ne" | "lt" | "gt" | "in";
1177
+ readonly value: unknown;
1178
+ },
1179
+ fields: Record<string, unknown> = {
1180
+ name: { type: "text", sortable: true, filterable: true },
1181
+ status: { type: "text", filterable: true },
1182
+ secret: { type: "text" },
1183
+ },
1184
+ ) {
1185
+ return defineFeature("shop", (r) => {
1186
+ r.entity("product", createEntity({ fields: fields as never }));
1187
+ r.screen({
1188
+ id: "product-list",
1189
+ type: "entityList",
1190
+ entity: "product",
1191
+ columns: [{ field: "name" }],
1192
+ filter,
1193
+ });
1194
+ });
1195
+ }
1196
+
1197
+ test("filter.field existiert + filterable → kein Throw", () => {
1198
+ expect(() =>
1199
+ validateBoot([makeFeature({ field: "status", op: "eq", value: "active" })]),
1200
+ ).not.toThrow();
1201
+ });
1202
+
1203
+ test("filter.field existiert NICHT → Throw mit klarer Message", () => {
1204
+ expect(() => validateBoot([makeFeature({ field: "ghost", op: "eq", value: "x" })])).toThrow(
1205
+ /filter references unknown field "ghost"/,
1206
+ );
1207
+ });
1208
+
1209
+ test("filter.field existiert aber filterable=false → Throw", () => {
1210
+ expect(() => validateBoot([makeFeature({ field: "secret", op: "eq", value: "x" })])).toThrow(
1211
+ /filter references field "secret" which is not filterable/,
1212
+ );
1213
+ });
1214
+
1215
+ test("filter.op=lt auf text-Feld → Throw (op-vs-Type-Compat)", () => {
1216
+ expect(() => validateBoot([makeFeature({ field: "status", op: "lt", value: "x" })])).toThrow(
1217
+ /filter\.op "lt" is not allowed on field "status" \(type "text"\)/,
1218
+ );
1219
+ });
1220
+
1221
+ test("filter.op=gt auf number-Feld → kein Throw (vergleichbar)", () => {
1222
+ expect(() =>
1223
+ validateBoot([
1224
+ makeFeature(
1225
+ { field: "rank", op: "gt", value: 5 },
1226
+ {
1227
+ name: { type: "text", filterable: true },
1228
+ rank: { type: "number", filterable: true },
1229
+ },
1230
+ ),
1231
+ ]),
1232
+ ).not.toThrow();
1233
+ });
1234
+
1235
+ test('filter.op="in" mit non-array value → Throw', () => {
1236
+ expect(() =>
1237
+ validateBoot([makeFeature({ field: "status", op: "in", value: "active" })]),
1238
+ ).toThrow(/filter\.op "in" requires filter\.value to be a readonly array/);
1239
+ });
1240
+
1241
+ test('filter.op="in" mit array → kein Throw', () => {
1242
+ expect(() =>
1243
+ validateBoot([makeFeature({ field: "status", op: "in", value: ["active", "pending"] })]),
1244
+ ).not.toThrow();
1245
+ });
1246
+
1247
+ test("filter.op=ne auf boolean → kein Throw, lt auf boolean → Throw", () => {
1248
+ const fields = {
1249
+ name: { type: "text", filterable: true },
1250
+ flag: { type: "boolean", filterable: true },
1251
+ };
1252
+ expect(() =>
1253
+ validateBoot([makeFeature({ field: "flag", op: "ne", value: true }, fields)]),
1254
+ ).not.toThrow();
1255
+ expect(() =>
1256
+ validateBoot([makeFeature({ field: "flag", op: "lt", value: true }, fields)]),
1257
+ ).toThrow(/filter\.op "lt" is not allowed on field "flag" \(type "boolean"\)/);
1258
+ });
1259
+ });
1260
+
1261
+ // --- Tier 2.7d: actionForm-Screen ---
1262
+ // Non-CRUD Write-Handler-driven Form. Sechs Author-Code-Checks am
1263
+ // Boot: handler ist non-empty + registriert, fields non-empty +
1264
+ // jeder mit type, layout konsistent, redirect (wenn gesetzt) zeigt
1265
+ // auf einen registrierten Screen.
1266
+ describe("actionForm screen (Tier 2.7d)", () => {
1267
+ type ActionFormOverride = {
1268
+ readonly handler?: string | undefined;
1269
+ readonly fields?: Record<string, unknown>;
1270
+ readonly sections?: ReadonlyArray<{
1271
+ readonly title: string;
1272
+ readonly fields: readonly string[];
1273
+ }>;
1274
+ readonly redirect?: string;
1275
+ readonly extraScreens?: readonly string[];
1276
+ };
1277
+
1278
+ // Hilfs-Schema-Setup: stamps eine Test-Entity + write-handler
1279
+ // damit `r.writeHandler(defineEntityWriteHandler("invoice:approve",...))`
1280
+ // beim Boot ohne Custom-Code registriert werden kann. Plus optional
1281
+ // weitere Screens zum redirect-Test.
1282
+ function makeFeature(override: ActionFormOverride = {}) {
1283
+ const handler = override.handler ?? "shop:write:invoice:approve";
1284
+ const fields = override.fields ?? {
1285
+ note: { type: "text" },
1286
+ priority: { type: "number" },
1287
+ };
1288
+ const sections = override.sections ?? [{ title: "Approval", fields: ["note", "priority"] }];
1289
+ return defineFeature("shop", (r) => {
1290
+ // Registrierter Write-Handler den die actionForm referenzieren
1291
+ // kann. Nicht über defineEntityWriteHandler — das verlangt eine
1292
+ // existente Entity. Direkter Stub reicht für Boot-Validierung.
1293
+ r.writeHandler({
1294
+ name: "invoice:approve",
1295
+ schema: { _type: "stub" } as never,
1296
+ handler: async () => ({ isSuccess: true, data: {} }) as never,
1297
+ access: { openToAll: true },
1298
+ });
1299
+ r.screen({
1300
+ id: "approve-invoice",
1301
+ type: "actionForm",
1302
+ handler,
1303
+ fields: fields as never,
1304
+ layout: { sections: sections as never },
1305
+ ...(override.redirect !== undefined && { redirect: override.redirect }),
1306
+ });
1307
+ for (const extra of override.extraScreens ?? []) {
1308
+ r.screen({
1309
+ id: extra,
1310
+ type: "custom",
1311
+ renderer: { react: "stub" },
1312
+ });
1313
+ }
1314
+ });
1315
+ }
1316
+
1317
+ test("happy path: handler + fields + layout konsistent → kein Throw", () => {
1318
+ expect(() => validateBoot([makeFeature()])).not.toThrow();
1319
+ });
1320
+
1321
+ test("handler nicht als write-handler registriert → Throw mit Hinweis", () => {
1322
+ expect(() => validateBoot([makeFeature({ handler: "shop:query:invoice:list" })])).toThrow(
1323
+ /handler "shop:query:invoice:list" is not a registered write-handler/,
1324
+ );
1325
+ });
1326
+
1327
+ test("handler leer → Throw", () => {
1328
+ expect(() => validateBoot([makeFeature({ handler: "" })])).toThrow(
1329
+ /has empty or non-string handler/,
1330
+ );
1331
+ });
1332
+
1333
+ test("fields empty-Map → Throw", () => {
1334
+ expect(() => validateBoot([makeFeature({ fields: {} })])).toThrow(
1335
+ /has empty fields map — declare at least one field/,
1336
+ );
1337
+ });
1338
+
1339
+ test("field ohne type-Discriminator → Throw", () => {
1340
+ expect(() => validateBoot([makeFeature({ fields: { note: { required: true } } })])).toThrow(
1341
+ /field "note" has no `type` set/,
1342
+ );
1343
+ });
1344
+
1345
+ test("layout.sections leer → Throw", () => {
1346
+ expect(() => validateBoot([makeFeature({ sections: [] })])).toThrow(
1347
+ /has an empty sections list/,
1348
+ );
1349
+ });
1350
+
1351
+ test("section.fields leer → Throw", () => {
1352
+ expect(() =>
1353
+ validateBoot([makeFeature({ sections: [{ title: "Empty", fields: [] }] })]),
1354
+ ).toThrow(/section "Empty" with zero fields/);
1355
+ });
1356
+
1357
+ test("layout referenziert unknown field → Throw", () => {
1358
+ expect(() =>
1359
+ validateBoot([makeFeature({ sections: [{ title: "x", fields: ["ghost"] }] })]),
1360
+ ).toThrow(/layout references unknown field "ghost"/);
1361
+ });
1362
+
1363
+ test("redirect → existing screen-id im selben feature → kein Throw", () => {
1364
+ expect(() =>
1365
+ validateBoot([makeFeature({ redirect: "after-form", extraScreens: ["after-form"] })]),
1366
+ ).not.toThrow();
1367
+ });
1368
+
1369
+ test("redirect → unknown screen-id → Throw", () => {
1370
+ expect(() => validateBoot([makeFeature({ redirect: "ghost-screen" })])).toThrow(
1371
+ /redirect "ghost-screen" does not resolve to a registered screen/,
1372
+ );
1373
+ });
1374
+ });
1375
+
1376
+ // --- configEdit-Screen ---
1377
+ // Form gegen das bundled config-feature. Boot-Validator prüft:
1378
+ // 1) fields non-empty + jeder mit type-Discriminator
1379
+ // 2) layout konsistent (Sections non-empty, Field-Refs existieren)
1380
+ // 3) jedes Field hat einen Eintrag in configKeys
1381
+ // 4) jeder qualifizierte Config-Key in configKeys ist tatsächlich
1382
+ // via r.config(...) registriert
1383
+ describe("configEdit screen", () => {
1384
+ type ConfigEditOverride = {
1385
+ readonly fields?: Record<string, unknown>;
1386
+ readonly sections?: ReadonlyArray<{
1387
+ readonly title: string;
1388
+ readonly fields: readonly string[];
1389
+ }>;
1390
+ readonly configKeys?: Readonly<Record<string, string>>;
1391
+ };
1392
+
1393
+ function makeFeature(override: ConfigEditOverride = {}) {
1394
+ const fields = override.fields ?? {
1395
+ siteName: { type: "text" },
1396
+ maxUploadMb: { type: "number" },
1397
+ };
1398
+ const sections = override.sections ?? [
1399
+ { title: "Basics", fields: ["siteName", "maxUploadMb"] },
1400
+ ];
1401
+ const configKeys = override.configKeys ?? {
1402
+ siteName: "shop:config:site-name",
1403
+ maxUploadMb: "shop:config:max-upload-mb",
1404
+ };
1405
+ return defineFeature("shop", (r) => {
1406
+ r.config({
1407
+ keys: {
1408
+ "site-name": createTenantConfig("text", { default: "" }),
1409
+ "max-upload-mb": createTenantConfig("number", { default: 10 }),
1410
+ },
1411
+ });
1412
+ r.screen({
1413
+ id: "settings",
1414
+ type: "configEdit",
1415
+ scope: "tenant",
1416
+ configKeys,
1417
+ fields: fields as never,
1418
+ layout: { sections: sections as never },
1419
+ });
1420
+ });
1421
+ }
1422
+
1423
+ test("happy path: alle 4 Checks bestanden → kein Throw", () => {
1424
+ expect(() => validateBoot([makeFeature()])).not.toThrow();
1425
+ });
1426
+
1427
+ test("fields empty-Map → Throw", () => {
1428
+ expect(() => validateBoot([makeFeature({ fields: {} })])).toThrow(
1429
+ /has empty fields map — declare at least one field/,
1430
+ );
1431
+ });
1432
+
1433
+ test("field ohne type-Discriminator → Throw", () => {
1434
+ expect(() =>
1435
+ validateBoot([makeFeature({ fields: { siteName: { required: true } } })]),
1436
+ ).toThrow(/field "siteName" has no `type` set/);
1437
+ });
1438
+
1439
+ test("layout.sections leer → Throw", () => {
1440
+ expect(() => validateBoot([makeFeature({ sections: [] })])).toThrow(
1441
+ /has an empty sections list/,
1442
+ );
1443
+ });
1444
+
1445
+ test("layout referenziert unknown field → Throw", () => {
1446
+ expect(() =>
1447
+ validateBoot([makeFeature({ sections: [{ title: "x", fields: ["ghost"] }] })]),
1448
+ ).toThrow(/layout references unknown field "ghost"/);
1449
+ });
1450
+
1451
+ test("Field ohne configKeys-Eintrag → Throw mit Hinweis auf Mapping", () => {
1452
+ // siteName ist im fields-Map, aber configKeys mappt es nicht.
1453
+ // Boot soll fehlschlagen weil zur Laufzeit kein Wert geladen
1454
+ // werden könnte.
1455
+ expect(() =>
1456
+ validateBoot([
1457
+ makeFeature({
1458
+ configKeys: { maxUploadMb: "shop:config:max-upload-mb" },
1459
+ }),
1460
+ ]),
1461
+ ).toThrow(/field "siteName" hat keinen Eintrag in configKeys-Map/);
1462
+ });
1463
+
1464
+ test("configKeys referenziert unbekannten qualifizierten Key → Throw", () => {
1465
+ expect(() =>
1466
+ validateBoot([
1467
+ makeFeature({
1468
+ configKeys: {
1469
+ siteName: "shop:config:typo-here",
1470
+ maxUploadMb: "shop:config:max-upload-mb",
1471
+ },
1472
+ }),
1473
+ ]),
1474
+ ).toThrow(/Config-Key "shop:config:typo-here" ist in keiner Feature-Registry deklariert/);
1475
+ });
1476
+ });
1477
+
1478
+ // --- Tier 2.7e-3: ReferenceFieldDef ---
1479
+ describe("reference field (Tier 2.7e-3)", () => {
1480
+ // Helper: registriert einen Stub-Query-Handler `<entity>:list`
1481
+ // damit der Boot-Validator den Audit-Fix-#2-Check (Handler-
1482
+ // Existenz auf der target-Entity) durch lässt.
1483
+ function stubListHandler(
1484
+ // biome-ignore lint/suspicious/noExplicitAny: Registrar-Typ ist generisch, hier reicht das.
1485
+ r: any,
1486
+ entityName: string,
1487
+ ): void {
1488
+ r.queryHandler({
1489
+ name: `${entityName}:list`,
1490
+ schema: z.object({}),
1491
+ handler: async () => ({ rows: [], nextCursor: null }) as never,
1492
+ access: { openToAll: true },
1493
+ });
1494
+ }
1495
+
1496
+ test("reference auf bestehende Entity → kein Throw", () => {
1497
+ const features = [
1498
+ defineFeature("shop", (r) => {
1499
+ r.entity("customer", createEntity({ fields: { name: createTextField() } }));
1500
+ stubListHandler(r, "customer");
1501
+ r.entity(
1502
+ "order",
1503
+ createEntity({
1504
+ fields: {
1505
+ customerId: { type: "reference", entity: "customer", labelField: "name" },
1506
+ },
1507
+ }),
1508
+ );
1509
+ }),
1510
+ ];
1511
+ expect(() => validateBoot(features)).not.toThrow();
1512
+ });
1513
+
1514
+ test("reference auf unknown Entity → Throw", () => {
1515
+ const features = [
1516
+ defineFeature("shop", (r) => {
1517
+ r.entity(
1518
+ "order",
1519
+ createEntity({
1520
+ fields: {
1521
+ customerId: { type: "reference", entity: "ghost-entity" },
1522
+ },
1523
+ }),
1524
+ );
1525
+ }),
1526
+ ];
1527
+ expect(() => validateBoot(features)).toThrow(
1528
+ /Reference field "customerId" on entity "order" targets unknown entity "ghost-entity"/,
1529
+ );
1530
+ });
1531
+
1532
+ test("reference labelField auf unknown Field → Throw", () => {
1533
+ const features = [
1534
+ defineFeature("shop", (r) => {
1535
+ r.entity("customer", createEntity({ fields: { name: createTextField() } }));
1536
+ r.entity(
1537
+ "order",
1538
+ createEntity({
1539
+ fields: {
1540
+ customerId: { type: "reference", entity: "customer", labelField: "ghost-field" },
1541
+ },
1542
+ }),
1543
+ );
1544
+ }),
1545
+ ];
1546
+ expect(() => validateBoot(features)).toThrow(
1547
+ /references labelField "ghost-field" which does not exist on entity "customer"/,
1548
+ );
1549
+ });
1550
+
1551
+ test("reference labelField=id ist immer ok (PK)", () => {
1552
+ const features = [
1553
+ defineFeature("shop", (r) => {
1554
+ r.entity("customer", createEntity({ fields: { name: createTextField() } }));
1555
+ stubListHandler(r, "customer");
1556
+ r.entity(
1557
+ "order",
1558
+ createEntity({
1559
+ fields: {
1560
+ customerId: { type: "reference", entity: "customer", labelField: "id" },
1561
+ },
1562
+ }),
1563
+ );
1564
+ }),
1565
+ ];
1566
+ expect(() => validateBoot(features)).not.toThrow();
1567
+ });
1568
+
1569
+ test("self-reference (entity → entity) → kein Throw", () => {
1570
+ const features = [
1571
+ defineFeature("shop", (r) => {
1572
+ r.entity(
1573
+ "category",
1574
+ createEntity({
1575
+ fields: {
1576
+ name: createTextField(),
1577
+ parentId: { type: "reference", entity: "category", labelField: "name" },
1578
+ },
1579
+ }),
1580
+ );
1581
+ stubListHandler(r, "category");
1582
+ }),
1583
+ ];
1584
+ expect(() => validateBoot(features)).not.toThrow();
1585
+ });
1586
+
1587
+ test("reference mit multiple: true → kein Throw (Tier 2.7e-Multi)", () => {
1588
+ const features = [
1589
+ defineFeature("shop", (r) => {
1590
+ r.entity("tag", createEntity({ fields: { name: createTextField() } }));
1591
+ stubListHandler(r, "tag");
1592
+ r.entity(
1593
+ "post",
1594
+ createEntity({
1595
+ fields: {
1596
+ title: createTextField(),
1597
+ tagIds: {
1598
+ type: "reference",
1599
+ entity: "tag",
1600
+ labelField: "name",
1601
+ multiple: true,
1602
+ },
1603
+ },
1604
+ }),
1605
+ );
1606
+ }),
1607
+ ];
1608
+ expect(() => validateBoot(features)).not.toThrow();
1609
+ });
1610
+
1611
+ // --- Tier 2.7e Cross-Feature: "feature:entity"-Form ---
1612
+ test("cross-feature reference (feature:entity) → kein Throw", () => {
1613
+ const features = [
1614
+ defineFeature("users", (r) => {
1615
+ r.entity("user", createEntity({ fields: { email: createTextField() } }));
1616
+ stubListHandler(r, "user");
1617
+ }),
1618
+ defineFeature("shop", (r) => {
1619
+ r.entity(
1620
+ "order",
1621
+ createEntity({
1622
+ fields: {
1623
+ customerId: { type: "reference", entity: "users:user", labelField: "email" },
1624
+ },
1625
+ }),
1626
+ );
1627
+ }),
1628
+ ];
1629
+ expect(() => validateBoot(features)).not.toThrow();
1630
+ });
1631
+
1632
+ test("Audit-Fix #2: cross-feature reference ohne list-handler → Throw", () => {
1633
+ const features = [
1634
+ defineFeature("users", (r) => {
1635
+ r.entity("user", createEntity({ fields: { email: createTextField() } }));
1636
+ // KEINE stubListHandler — das ist der Punkt des Tests
1637
+ }),
1638
+ defineFeature("shop", (r) => {
1639
+ r.entity(
1640
+ "order",
1641
+ createEntity({
1642
+ fields: {
1643
+ customerId: { type: "reference", entity: "users:user" },
1644
+ },
1645
+ }),
1646
+ );
1647
+ }),
1648
+ ];
1649
+ expect(() => validateBoot(features)).toThrow(
1650
+ /no list-query-handler is registered there\. Add r\.queryHandler\(defineEntityListHandler\("user"/,
1651
+ );
1652
+ });
1653
+
1654
+ test("cross-feature reference auf unknown feature → Throw mit klarer Message", () => {
1655
+ const features = [
1656
+ defineFeature("shop", (r) => {
1657
+ r.entity(
1658
+ "order",
1659
+ createEntity({
1660
+ fields: {
1661
+ customerId: { type: "reference", entity: "ghost-feature:user" },
1662
+ },
1663
+ }),
1664
+ );
1665
+ }),
1666
+ ];
1667
+ expect(() => validateBoot(features)).toThrow(
1668
+ /targets unknown feature "ghost-feature" via "ghost-feature:user"/,
1669
+ );
1670
+ });
1671
+
1672
+ test("cross-feature reference auf unknown entity → Throw mit feature-context", () => {
1673
+ const features = [
1674
+ defineFeature("users", (r) => {
1675
+ r.entity("user", createEntity({ fields: { email: createTextField() } }));
1676
+ }),
1677
+ defineFeature("shop", (r) => {
1678
+ r.entity(
1679
+ "order",
1680
+ createEntity({
1681
+ fields: {
1682
+ customerId: { type: "reference", entity: "users:ghost-entity" },
1683
+ },
1684
+ }),
1685
+ );
1686
+ }),
1687
+ ];
1688
+ expect(() => validateBoot(features)).toThrow(
1689
+ /targets unknown entity "ghost-entity" in feature "users"/,
1690
+ );
1691
+ });
1692
+
1693
+ test("cross-feature labelField auf unknown Field → Throw", () => {
1694
+ const features = [
1695
+ defineFeature("users", (r) => {
1696
+ r.entity("user", createEntity({ fields: { email: createTextField() } }));
1697
+ }),
1698
+ defineFeature("shop", (r) => {
1699
+ r.entity(
1700
+ "order",
1701
+ createEntity({
1702
+ fields: {
1703
+ customerId: {
1704
+ type: "reference",
1705
+ entity: "users:user",
1706
+ labelField: "ghost-field",
1707
+ },
1708
+ },
1709
+ }),
1710
+ );
1711
+ }),
1712
+ ];
1713
+ expect(() => validateBoot(features)).toThrow(
1714
+ /references labelField "ghost-field" which does not exist on entity "user"/,
1715
+ );
1716
+ });
1717
+ });
1718
+
1719
+ // --- Tier 2.7e-1: rowAction kind="navigate" target-existenz ---
1720
+ describe("entityList rowAction kind=navigate (Tier 2.7e-1)", () => {
1721
+ function makeFeature(targetScreen: string, withTarget: boolean) {
1722
+ return defineFeature("shop", (r) => {
1723
+ r.entity("product", createEntity({ fields: { name: createTextField() } }));
1724
+ r.screen({
1725
+ id: "product-list",
1726
+ type: "entityList",
1727
+ entity: "product",
1728
+ columns: ["name"],
1729
+ rowActions: [
1730
+ {
1731
+ kind: "navigate",
1732
+ id: "edit",
1733
+ label: "actions.edit",
1734
+ screen: targetScreen,
1735
+ },
1736
+ ],
1737
+ });
1738
+ if (withTarget) {
1739
+ r.screen({
1740
+ id: targetScreen,
1741
+ type: "custom",
1742
+ renderer: { react: "stub" },
1743
+ });
1744
+ }
1745
+ });
1746
+ }
1747
+
1748
+ test("navigate-target → registered screen → kein Throw", () => {
1749
+ expect(() => validateBoot([makeFeature("product-edit", true)])).not.toThrow();
1750
+ });
1751
+
1752
+ test("navigate-target → unknown screen → Throw mit klarer Message", () => {
1753
+ expect(() => validateBoot([makeFeature("ghost-screen", false)])).toThrow(
1754
+ /rowAction "edit" navigate-target "ghost-screen" does not resolve/,
1755
+ );
1756
+ });
1757
+ });
1758
+
1759
+ // --- defaultSort funktioniert für ALLE Field-Types die sortable
1760
+ // unterstützen (Tier 2.6b Field-Erweiterung) ---
1761
+ // Vor Tier 2.6b war `sortable` nur auf TextFieldDef. Erweitert auf
1762
+ // Number/Money/Date/Timestamp/Boolean/Select/LocatedTimestamp; der
1763
+ // Validator erkennt das via "sortable" in fieldDef. Diese Tests pinnen
1764
+ // dass der per-Field-Type-Roundtrip wirklich greift.
1765
+ describe("entityList defaultSort: alle sortable-Field-Types", () => {
1766
+ function buildFeature(fieldName: string, fields: Record<string, unknown>) {
1767
+ return defineFeature("shop", (r) => {
1768
+ r.entity("product", createEntity({ fields: fields as never }));
1769
+ r.screen({
1770
+ id: "product-list",
1771
+ type: "entityList",
1772
+ entity: "product",
1773
+ columns: [{ field: fieldName }],
1774
+ defaultSort: { field: fieldName, dir: "asc" },
1775
+ });
1776
+ });
1777
+ }
1778
+
1779
+ test("number-Field mit sortable: true → kein Throw", () => {
1780
+ expect(() =>
1781
+ validateBoot([buildFeature("rank", { rank: { type: "number", sortable: true } })]),
1782
+ ).not.toThrow();
1783
+ });
1784
+
1785
+ test("money-Field mit sortable: true → kein Throw", () => {
1786
+ expect(() =>
1787
+ validateBoot([buildFeature("price", { price: { type: "money", sortable: true } })]),
1788
+ ).not.toThrow();
1789
+ });
1790
+
1791
+ test("date-Field mit sortable: true → kein Throw", () => {
1792
+ expect(() =>
1793
+ validateBoot([buildFeature("dueDate", { dueDate: { type: "date", sortable: true } })]),
1794
+ ).not.toThrow();
1795
+ });
1796
+
1797
+ test("timestamp-Field mit sortable: true → kein Throw", () => {
1798
+ expect(() =>
1799
+ validateBoot([
1800
+ buildFeature("createdAt", { createdAt: { type: "timestamp", sortable: true } }),
1801
+ ]),
1802
+ ).not.toThrow();
1803
+ });
1804
+
1805
+ test("boolean-Field mit sortable: true → kein Throw", () => {
1806
+ expect(() =>
1807
+ validateBoot([buildFeature("isActive", { isActive: { type: "boolean", sortable: true } })]),
1808
+ ).not.toThrow();
1809
+ });
1810
+
1811
+ test("select-Field mit sortable: true → kein Throw", () => {
1812
+ expect(() =>
1813
+ validateBoot([
1814
+ buildFeature("status", {
1815
+ status: { type: "select", options: ["a", "b"], sortable: true },
1816
+ }),
1817
+ ]),
1818
+ ).not.toThrow();
1819
+ });
1820
+
1821
+ test("locatedTimestamp-Field mit sortable: true → kein Throw", () => {
1822
+ expect(() =>
1823
+ validateBoot([
1824
+ buildFeature("pickup", { pickup: { type: "locatedTimestamp", sortable: true } }),
1825
+ ]),
1826
+ ).not.toThrow();
1827
+ });
1828
+
1829
+ test("number-Field OHNE sortable → Throw (sortable: true ist Pflicht)", () => {
1830
+ expect(() => validateBoot([buildFeature("rank", { rank: { type: "number" } })])).toThrow(
1831
+ /is not sortable/,
1832
+ );
1833
+ });
1834
+ });
1835
+
1836
+ // --- screen.id ohne Punkt ---
1837
+ // Renderer nutzt screen.id als URL-Param-Namespace (`<id>.sort=…`).
1838
+ // defineFeature() rejected screen-ids mit '.' bereits über den
1839
+ // kebab-case-Check (define-feature.ts) — bevor der Boot-Validator
1840
+ // dran kommt. Wir pinnen hier nur dass der Reject am Author-API-
1841
+ // Eingangstor passiert, mit klarer Message.
1842
+ describe("screen.id constraints", () => {
1843
+ test('screen.id mit "." → defineFeature throws (kebab-case)', () => {
1844
+ expect(() =>
1845
+ defineFeature("shop", (r) => {
1846
+ r.entity("product", createEntity({ fields: { name: createTextField() } }));
1847
+ r.screen({
1848
+ id: "product.list",
1849
+ type: "entityList",
1850
+ entity: "product",
1851
+ columns: ["name"],
1852
+ });
1853
+ }),
1854
+ ).toThrow(/kebab-case/);
1855
+ });
1856
+
1857
+ test("screen.id im kebab-case → kein Throw", () => {
1858
+ const feature = defineFeature("shop", (r) => {
1859
+ r.entity("product", createEntity({ fields: { name: createTextField() } }));
1860
+ r.screen({ id: "product-list", type: "entityList", entity: "product", columns: ["name"] });
1861
+ });
1862
+ expect(() => validateBoot([feature])).not.toThrow();
1863
+ });
1864
+ });
1865
+ });