@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,408 @@
1
+ import { describe, expect, test } from "vitest";
2
+ import { validateBoot } from "../boot-validator";
3
+ import { defineFeature } from "../define-feature";
4
+ import { createEntity, createTextField } from "../factories";
5
+ import { createRegistry } from "../registry";
6
+ import type { ScreenDefinition } from "../types/screen";
7
+
8
+ function productEntity() {
9
+ return createEntity({
10
+ table: "products",
11
+ fields: {
12
+ name: createTextField(),
13
+ sku: createTextField(),
14
+ },
15
+ });
16
+ }
17
+
18
+ describe("r.screen() — registration", () => {
19
+ test("stores an entityList screen on the FeatureDefinition", () => {
20
+ const feature = defineFeature("shop", (r) => {
21
+ r.entity("product", productEntity());
22
+ r.screen({
23
+ id: "product-list",
24
+ type: "entityList",
25
+ entity: "product",
26
+ columns: ["name", "sku"],
27
+ });
28
+ });
29
+ expect(feature.screens["product-list"]).toBeDefined();
30
+ expect(feature.screens["product-list"]?.type).toBe("entityList");
31
+ });
32
+
33
+ test("stores an entityEdit screen with sections + conditional fields", () => {
34
+ const feature = defineFeature("shop", (r) => {
35
+ r.entity("product", productEntity());
36
+ r.screen({
37
+ id: "product-edit",
38
+ type: "entityEdit",
39
+ entity: "product",
40
+ layout: {
41
+ sections: [
42
+ {
43
+ title: "shop:section.basics",
44
+ columns: 2,
45
+ fields: [
46
+ "name",
47
+ { field: "sku", readOnly: (data) => Boolean((data as { sku?: string }).sku) },
48
+ ],
49
+ },
50
+ ],
51
+ },
52
+ });
53
+ });
54
+ const screen = feature.screens["product-edit"];
55
+ expect(screen?.type).toBe("entityEdit");
56
+ if (screen?.type !== "entityEdit") throw new Error("type-narrow failed");
57
+ expect(screen.layout.sections).toHaveLength(1);
58
+ });
59
+
60
+ test("stores a custom screen with a renderer + routes", () => {
61
+ const feature = defineFeature("dashboard", (r) => {
62
+ r.screen({
63
+ id: "overview",
64
+ type: "custom",
65
+ renderer: { react: { __component: "dashboard-overview" } },
66
+ routes: [{ path: "/revenue", component: { react: { __component: "revenue" } } }],
67
+ });
68
+ });
69
+ const screen = feature.screens["overview"];
70
+ expect(screen?.type).toBe("custom");
71
+ if (screen?.type !== "custom") throw new Error("type-narrow failed");
72
+ expect(screen.routes).toHaveLength(1);
73
+ });
74
+
75
+ test("rejects duplicate screen ids within the same feature", () => {
76
+ expect(() =>
77
+ defineFeature("shop", (r) => {
78
+ r.entity("product", productEntity());
79
+ r.screen({
80
+ id: "product-list",
81
+ type: "entityList",
82
+ entity: "product",
83
+ columns: ["name"],
84
+ });
85
+ r.screen({
86
+ id: "product-list",
87
+ type: "entityEdit",
88
+ entity: "product",
89
+ layout: { sections: [] },
90
+ });
91
+ }),
92
+ ).toThrow(/already registered/);
93
+ });
94
+
95
+ test("rejects non-kebab-case screen ids", () => {
96
+ expect(() =>
97
+ defineFeature("shop", (r) => {
98
+ r.entity("product", productEntity());
99
+ r.screen({
100
+ id: "productList",
101
+ type: "entityList",
102
+ entity: "product",
103
+ columns: ["name"],
104
+ });
105
+ }),
106
+ ).toThrow(/kebab-case/);
107
+ });
108
+
109
+ test("accepts kebab-case screen ids", () => {
110
+ expect(() =>
111
+ defineFeature("shop", (r) => {
112
+ r.entity("product", productEntity());
113
+ r.screen({
114
+ id: "product-list",
115
+ type: "entityList",
116
+ entity: "product",
117
+ columns: ["name"],
118
+ });
119
+ }),
120
+ ).not.toThrow();
121
+ });
122
+ });
123
+
124
+ describe("createRegistry — screen indexing", () => {
125
+ test("indexes screens by qualified name", () => {
126
+ const feature = defineFeature("shop", (r) => {
127
+ r.entity("product", productEntity());
128
+ r.screen({
129
+ id: "product-list",
130
+ type: "entityList",
131
+ entity: "product",
132
+ columns: ["name"],
133
+ });
134
+ });
135
+ const registry = createRegistry([feature]);
136
+ expect(registry.getAllScreens().size).toBe(1);
137
+ expect(registry.getScreen("shop:screen:product-list")).toBeDefined();
138
+ });
139
+
140
+ test("returns undefined for unknown qualified names", () => {
141
+ const registry = createRegistry([]);
142
+ expect(registry.getScreen("ghost:screen:nope")).toBeUndefined();
143
+ });
144
+
145
+ test("getScreenFeature maps a screen back to its owning feature", () => {
146
+ const feature = defineFeature("shop", (r) => {
147
+ r.entity("product", productEntity());
148
+ r.screen({
149
+ id: "product-list",
150
+ type: "entityList",
151
+ entity: "product",
152
+ columns: ["name"],
153
+ });
154
+ });
155
+ const registry = createRegistry([feature]);
156
+ expect(registry.getScreenFeature("shop:screen:product-list")).toBe("shop");
157
+ expect(registry.getScreenFeature("shop:screen:does-not-exist")).toBeUndefined();
158
+ });
159
+
160
+ test("same screen id from two features qualifies to different names", () => {
161
+ const shop = defineFeature("shop", (r) => {
162
+ r.entity("product", productEntity());
163
+ r.screen({
164
+ id: "list",
165
+ type: "entityList",
166
+ entity: "product",
167
+ columns: ["name"],
168
+ });
169
+ });
170
+ const warehouse = defineFeature("warehouse", (r) => {
171
+ r.entity("item", productEntity());
172
+ r.screen({ id: "list", type: "entityList", entity: "item", columns: ["name"] });
173
+ });
174
+ const registry = createRegistry([shop, warehouse]);
175
+ expect(registry.getAllScreens().size).toBe(2);
176
+ expect(registry.getScreen("shop:screen:list")).toBeDefined();
177
+ expect(registry.getScreen("warehouse:screen:list")).toBeDefined();
178
+ });
179
+
180
+ test("getScreensByEntity groups entity-bound screens", () => {
181
+ const feature = defineFeature("shop", (r) => {
182
+ r.entity("product", productEntity());
183
+ r.screen({
184
+ id: "product-list",
185
+ type: "entityList",
186
+ entity: "product",
187
+ columns: ["name"],
188
+ });
189
+ r.screen({
190
+ id: "product-edit",
191
+ type: "entityEdit",
192
+ entity: "product",
193
+ layout: { sections: [{ title: "t", fields: ["name"] }] },
194
+ });
195
+ // custom screens have no entity; they must not show up in the index.
196
+ r.screen({ id: "overview", type: "custom", renderer: { react: { __c: true } } });
197
+ });
198
+ const registry = createRegistry([feature]);
199
+ const byProduct = registry.getScreensByEntity("product");
200
+ expect(byProduct).toHaveLength(2);
201
+ // Stored screens carry the qualified id — same contract as
202
+ // getScreen(qn).id / getAllScreens() values. Saves ui-core the reverse
203
+ // index when recursing through slots / field renderers by QN.
204
+ expect(byProduct.map((s) => s.id).sort()).toEqual([
205
+ "shop:screen:product-edit",
206
+ "shop:screen:product-list",
207
+ ]);
208
+ });
209
+
210
+ test("getScreen / getScreensByEntity return stored screens with qualified id", () => {
211
+ const feature = defineFeature("shop", (r) => {
212
+ r.entity("product", productEntity());
213
+ r.screen({
214
+ id: "product-list",
215
+ type: "entityList",
216
+ entity: "product",
217
+ columns: ["name"],
218
+ });
219
+ });
220
+ const registry = createRegistry([feature]);
221
+ // Input-side (unregistered FeatureDefinition) keeps the short form.
222
+ expect(feature.screens["product-list"]?.id).toBe("product-list");
223
+ // Registry-side always exposes the qualified form.
224
+ expect(registry.getScreen("shop:screen:product-list")?.id).toBe("shop:screen:product-list");
225
+ });
226
+
227
+ test("getScreensByEntity returns empty for unknown entities", () => {
228
+ const registry = createRegistry([]);
229
+ expect(registry.getScreensByEntity("ghost")).toHaveLength(0);
230
+ });
231
+ });
232
+
233
+ describe("validateBoot — screen validation", () => {
234
+ test("entityList with unknown entity fails boot", () => {
235
+ const feature = defineFeature("shop", (r) => {
236
+ r.entity("product", productEntity());
237
+ r.screen({
238
+ id: "list",
239
+ type: "entityList",
240
+ entity: "ghost",
241
+ columns: ["name"],
242
+ });
243
+ });
244
+ expect(() => validateBoot([feature])).toThrow(/references entity "ghost"/);
245
+ });
246
+
247
+ test("cross-feature entity-ref hint points at the real owner", () => {
248
+ // Entity exists, but in another feature. The hint helps the feature
249
+ // author realise cross-feature screen ownership isn't supported.
250
+ const shop = defineFeature("shop", (r) => {
251
+ r.entity("product", productEntity());
252
+ });
253
+ const ui = defineFeature("ui", (r) => {
254
+ r.screen({ id: "list", type: "entityList", entity: "product", columns: ["name"] });
255
+ });
256
+ expect(() => validateBoot([shop, ui])).toThrow(/owned by feature "shop"/);
257
+ });
258
+
259
+ test("entityList with unknown field (string form) fails boot", () => {
260
+ const feature = defineFeature("shop", (r) => {
261
+ r.entity("product", productEntity());
262
+ r.screen({
263
+ id: "list",
264
+ type: "entityList",
265
+ entity: "product",
266
+ columns: ["name", "mistake"],
267
+ });
268
+ });
269
+ expect(() => validateBoot([feature])).toThrow(/field "mistake"/);
270
+ });
271
+
272
+ test("entityList with unknown field (object form) fails boot", () => {
273
+ const feature = defineFeature("shop", (r) => {
274
+ r.entity("product", productEntity());
275
+ r.screen({
276
+ id: "list",
277
+ type: "entityList",
278
+ entity: "product",
279
+ columns: [{ field: "nonexistent" }],
280
+ });
281
+ });
282
+ expect(() => validateBoot([feature])).toThrow(/field "nonexistent"/);
283
+ });
284
+
285
+ test("entityEdit with unknown field (string form in sections) fails boot", () => {
286
+ const feature = defineFeature("shop", (r) => {
287
+ r.entity("product", productEntity());
288
+ r.screen({
289
+ id: "edit",
290
+ type: "entityEdit",
291
+ entity: "product",
292
+ layout: {
293
+ sections: [{ title: "s", fields: ["name", "oops"] }],
294
+ },
295
+ });
296
+ });
297
+ expect(() => validateBoot([feature])).toThrow(/field "oops"/);
298
+ });
299
+
300
+ test("entityEdit with unknown field (object form in sections) fails boot", () => {
301
+ const feature = defineFeature("shop", (r) => {
302
+ r.entity("product", productEntity());
303
+ r.screen({
304
+ id: "edit",
305
+ type: "entityEdit",
306
+ entity: "product",
307
+ layout: {
308
+ sections: [{ title: "s", fields: [{ field: "ghost" }] }],
309
+ },
310
+ });
311
+ });
312
+ expect(() => validateBoot([feature])).toThrow(/field "ghost"/);
313
+ });
314
+
315
+ test("custom screen without a renderer-component (neither react nor native) fails", () => {
316
+ const feature = defineFeature("dashboard", (r) => {
317
+ const screen: ScreenDefinition = {
318
+ id: "empty",
319
+ type: "custom",
320
+ renderer: {},
321
+ };
322
+ r.screen(screen);
323
+ });
324
+ expect(() => validateBoot([feature])).toThrow(/neither a react nor a native component/);
325
+ });
326
+
327
+ test("custom screen with just react passes", () => {
328
+ const feature = defineFeature("dashboard", (r) => {
329
+ r.screen({
330
+ id: "ok",
331
+ type: "custom",
332
+ renderer: { react: { __component: true } },
333
+ });
334
+ });
335
+ expect(() => validateBoot([feature])).not.toThrow();
336
+ });
337
+
338
+ test("custom screen with just native passes (mobile-only feature)", () => {
339
+ const feature = defineFeature("dashboard", (r) => {
340
+ r.screen({
341
+ id: "ok",
342
+ type: "custom",
343
+ renderer: { native: { __component: true } },
344
+ });
345
+ });
346
+ expect(() => validateBoot([feature])).not.toThrow();
347
+ });
348
+
349
+ test("fully-populated entityEdit with sections + slots + conditionals passes boot", () => {
350
+ const feature = defineFeature("shop", (r) => {
351
+ r.entity("product", productEntity());
352
+ r.screen({
353
+ id: "edit",
354
+ type: "entityEdit",
355
+ entity: "product",
356
+ layout: {
357
+ sections: [
358
+ {
359
+ title: "shop:section.basics",
360
+ columns: 2,
361
+ fields: ["name", { field: "sku", visible: () => true, required: () => true }],
362
+ },
363
+ ],
364
+ },
365
+ slots: { header: { react: { __component: "h" } } },
366
+ access: { roles: ["Admin"] },
367
+ });
368
+ });
369
+ expect(() => validateBoot([feature])).not.toThrow();
370
+ });
371
+
372
+ test("entityList with empty columns fails boot", () => {
373
+ // Blank columns list renders as a blank table — almost always an author
374
+ // oversight. Locked down at boot rather than silently producing an empty
375
+ // UI surface.
376
+ const feature = defineFeature("shop", (r) => {
377
+ r.entity("product", productEntity());
378
+ r.screen({ id: "list", type: "entityList", entity: "product", columns: [] });
379
+ });
380
+ expect(() => validateBoot([feature])).toThrow(/empty columns list/);
381
+ });
382
+
383
+ test("entityEdit with empty sections fails boot", () => {
384
+ const feature = defineFeature("shop", (r) => {
385
+ r.entity("product", productEntity());
386
+ r.screen({
387
+ id: "edit",
388
+ type: "entityEdit",
389
+ entity: "product",
390
+ layout: { sections: [] },
391
+ });
392
+ });
393
+ expect(() => validateBoot([feature])).toThrow(/empty sections list/);
394
+ });
395
+
396
+ test("entityEdit with a section that has zero fields fails boot", () => {
397
+ const feature = defineFeature("shop", (r) => {
398
+ r.entity("product", productEntity());
399
+ r.screen({
400
+ id: "edit",
401
+ type: "entityEdit",
402
+ entity: "product",
403
+ layout: { sections: [{ title: "shop:section.empty", fields: [] }] },
404
+ });
405
+ });
406
+ expect(() => validateBoot([feature])).toThrow(/zero fields/);
407
+ });
408
+ });
@@ -0,0 +1,148 @@
1
+ import { describe, expect, test } from "vitest";
2
+ import { UnprocessableError } from "../../errors";
3
+ import { defineTransitions, guardTransition } from "../state-machine";
4
+
5
+ describe("defineTransitions — TransitionGraph API", () => {
6
+ const transitions = defineTransitions({
7
+ draft: ["sent"],
8
+ sent: ["paid", "cancelled"],
9
+ paid: [],
10
+ cancelled: [],
11
+ });
12
+
13
+ test("canTransition: erlaubte Übergänge", () => {
14
+ expect(transitions.canTransition("draft", "sent")).toBe(true);
15
+ expect(transitions.canTransition("sent", "paid")).toBe(true);
16
+ expect(transitions.canTransition("sent", "cancelled")).toBe(true);
17
+ });
18
+
19
+ test("canTransition: verbotene Übergänge", () => {
20
+ expect(transitions.canTransition("draft", "paid")).toBe(false);
21
+ expect(transitions.canTransition("paid", "draft")).toBe(false);
22
+ });
23
+
24
+ test("canTransition: unbekannter from-State liefert false (kein Throw)", () => {
25
+ expect(transitions.canTransition("unknown" as "draft", "sent")).toBe(false);
26
+ });
27
+
28
+ test("allowedFrom: liefert die erlaubten Targets", () => {
29
+ expect(transitions.allowedFrom("sent")).toEqual(["paid", "cancelled"]);
30
+ expect(transitions.allowedFrom("draft")).toEqual(["sent"]);
31
+ });
32
+
33
+ test("allowedFrom: terminaler State → leeres Array", () => {
34
+ expect(transitions.allowedFrom("paid")).toEqual([]);
35
+ });
36
+
37
+ test("allowedFrom: unbekannter State → leeres Array", () => {
38
+ expect(transitions.allowedFrom("unknown" as "draft")).toEqual([]);
39
+ });
40
+
41
+ test("supports non-linear transitions (backtrack)", () => {
42
+ const t = defineTransitions({
43
+ draft: ["in_progress"],
44
+ in_progress: ["review"],
45
+ review: ["finalized", "in_progress"],
46
+ finalized: ["sent"],
47
+ sent: [],
48
+ });
49
+ expect(t.canTransition("review", "in_progress")).toBe(true);
50
+ expect(t.canTransition("review", "finalized")).toBe(true);
51
+ });
52
+ });
53
+
54
+ describe("assertTransition / guardTransition", () => {
55
+ const transitions = defineTransitions({
56
+ draft: ["sent"],
57
+ sent: ["paid", "cancelled"],
58
+ paid: [],
59
+ cancelled: [],
60
+ });
61
+
62
+ // assertTransition wirft UnprocessableError mit reason="invalid_transition"
63
+ // (HTTP 422). guardTransition ist Convenience-Wrapper mit derselben Logik —
64
+ // beide müssen identisch verhalten.
65
+
66
+ test("method-form: erlaubter Übergang läuft durch", () => {
67
+ expect(() => transitions.assertTransition("draft", "sent")).not.toThrow();
68
+ });
69
+
70
+ test("function-form (guardTransition): identisches Verhalten", () => {
71
+ expect(() => guardTransition(transitions, "draft", "sent")).not.toThrow();
72
+ expect(() => guardTransition(transitions, "draft", "paid")).toThrow(UnprocessableError);
73
+ });
74
+
75
+ test("rejects invalid transition mit details + i18nKey", () => {
76
+ try {
77
+ transitions.assertTransition("draft", "paid");
78
+ } catch (e) {
79
+ const err = e as UnprocessableError;
80
+ expect(err.code).toBe("unprocessable");
81
+ expect(err.httpStatus).toBe(422);
82
+ expect(err.details).toMatchObject({
83
+ reason: "invalid_transition",
84
+ from: "draft",
85
+ to: "paid",
86
+ allowed: ["sent"],
87
+ });
88
+ expect((err.details as { message: string }).message).toContain('"draft" → "paid"');
89
+ }
90
+ });
91
+
92
+ test("error details: allowed-array aus der State-Machine", () => {
93
+ try {
94
+ transitions.assertTransition("sent", "draft");
95
+ } catch (e) {
96
+ expect((e as UnprocessableError).details).toMatchObject({
97
+ allowed: ["paid", "cancelled"],
98
+ });
99
+ }
100
+ });
101
+
102
+ test("rejects transition from unknown state mit allowed=[] (leeres Array)", () => {
103
+ try {
104
+ transitions.assertTransition("unknown" as "draft", "sent");
105
+ } catch (e) {
106
+ const err = e as UnprocessableError;
107
+ expect(err.details).toMatchObject({ from: "unknown", allowed: [] });
108
+ }
109
+ });
110
+
111
+ test("identische details-shape zwischen assertTransition + failTransition", async () => {
112
+ // Beide Pfade müssen den gleichen Detail-Block bauen — Clients
113
+ // parsen den 422-Body uniform und dürfen kein "validTargets vs.
114
+ // allowed"-Branch fühlen.
115
+ const { failTransition } = await import("../../errors");
116
+ type TransitionDetails = {
117
+ from?: string;
118
+ to?: string;
119
+ allowed?: readonly string[];
120
+ message?: string;
121
+ reason?: string;
122
+ };
123
+ let assertDetails: TransitionDetails | undefined;
124
+ try {
125
+ transitions.assertTransition("draft", "paid");
126
+ } catch (e) {
127
+ assertDetails = (e as UnprocessableError).details as TransitionDetails;
128
+ }
129
+ // Wenn assertTransition nicht wirft (zukünftiger Bug), bleibt
130
+ // assertDetails undefined → Diagnose unklar. Frühzeitiger Check macht
131
+ // den Fehlerpfad eindeutig.
132
+ expect(assertDetails).toBeDefined();
133
+ const failResult = failTransition("draft", "paid", ["sent"]);
134
+ const failDetails = failResult.error.details as TransitionDetails;
135
+ // assertTransition wirft via UnprocessableError → details bekommt
136
+ // automatisch `reason` injiziert; failTransition geht denselben
137
+ // Pfad. Strukturelle Felder müssen 1:1 matchen.
138
+ expect(assertDetails?.from).toEqual(failDetails.from);
139
+ expect(assertDetails?.to).toEqual(failDetails.to);
140
+ expect(assertDetails?.allowed).toEqual(failDetails.allowed);
141
+ expect(assertDetails?.message).toEqual(failDetails.message);
142
+ expect(assertDetails?.reason).toEqual(failDetails.reason);
143
+ });
144
+
145
+ test("rejects transition from terminal state", () => {
146
+ expect(() => transitions.assertTransition("paid", "draft")).toThrow(UnprocessableError);
147
+ });
148
+ });
@@ -0,0 +1,57 @@
1
+ import { describe, expect, test } from "vitest";
2
+ import { hasAccess } from "../access";
3
+ import { createApp } from "../create-app";
4
+ import { defineFeature } from "../define-feature";
5
+ import { createSystemUser, SYSTEM_ROLE, SYSTEM_USER_ID } from "../system-user";
6
+
7
+ describe("SYSTEM_USER", () => {
8
+ test("createSystemUser returns user with system role", () => {
9
+ const user = createSystemUser("00000000-0000-4000-8000-000000000042");
10
+ expect(user.id).toBe(SYSTEM_USER_ID);
11
+ expect(user.tenantId).toBe("00000000-0000-4000-8000-000000000042");
12
+ expect(user.roles).toEqual([SYSTEM_ROLE]);
13
+ });
14
+
15
+ test("SYSTEM_USER has access to handlers with roles: ['system']", () => {
16
+ const user = createSystemUser("00000000-0000-4000-8000-000000000001");
17
+ expect(hasAccess(user, { roles: ["system"] })).toBe(true);
18
+ });
19
+
20
+ test("SYSTEM_USER does NOT have access to Admin-only handlers", () => {
21
+ const user = createSystemUser("00000000-0000-4000-8000-000000000001");
22
+ expect(hasAccess(user, { roles: ["Admin"] })).toBe(false);
23
+ });
24
+
25
+ test("normal user does NOT have access to system-only handlers", () => {
26
+ const admin = {
27
+ id: "11111111-0000-4000-8000-000000000001",
28
+ tenantId: "00000000-0000-4000-8000-000000000001",
29
+ roles: ["Admin"] as readonly string[],
30
+ };
31
+ expect(hasAccess(admin, { roles: ["system"] })).toBe(false);
32
+ });
33
+
34
+ test("createApp rejects 'system' as an app role", () => {
35
+ const feature = defineFeature("test", () => {});
36
+ expect(() => createApp({ roles: ["Admin", "system"] as const, features: [feature] })).toThrow(
37
+ /reserved.*SYSTEM_USER/i,
38
+ );
39
+ });
40
+
41
+ test("createApp allows features with write: ['system'] config keys", () => {
42
+ const feature = defineFeature("billing", (r) => {
43
+ r.config({
44
+ keys: {
45
+ monthlyTotal: {
46
+ type: "number",
47
+ default: 0,
48
+ scope: "tenant",
49
+ access: { write: ["system"], read: ["Admin"] },
50
+ },
51
+ },
52
+ });
53
+ });
54
+
55
+ expect(() => createApp({ roles: ["Admin"] as const, features: [feature] })).not.toThrow();
56
+ });
57
+ });
@@ -0,0 +1,71 @@
1
+ import { describe, expect, test } from "vitest";
2
+ import { createEntity, createRegistry, createTextField, defineFeature } from "../index";
3
+ import type { ValidationError } from "../validation";
4
+ import { runValidation } from "../validation";
5
+
6
+ describe("validation hooks", () => {
7
+ test("r.hook registers validation hook", () => {
8
+ const feature = defineFeature("test", (r) => {
9
+ r.entity("user", createEntity({ table: "Users", fields: { email: createTextField() } }));
10
+ r.hook("validation", "user:create", (data) => {
11
+ const errors: ValidationError[] = [];
12
+ if (!data["email"]) errors.push({ field: "email", error: "required" });
13
+ return errors.length > 0 ? errors : null;
14
+ });
15
+ });
16
+
17
+ expect(feature.hooks["validation"]).toBeDefined();
18
+ expect(feature.hooks["validation"]?.["user:create"]).toBeDefined();
19
+ });
20
+
21
+ test("runValidation returns null when valid", () => {
22
+ const feature = defineFeature("test", (r) => {
23
+ r.hook("validation", "user:create", () => null);
24
+ });
25
+
26
+ const registry = createRegistry([feature]);
27
+ const result = runValidation(registry, "test:write:user:create", { email: "a@b.de" });
28
+ expect(result).toBeNull();
29
+ });
30
+
31
+ test("runValidation returns errors when invalid", () => {
32
+ const feature = defineFeature("test", (r) => {
33
+ r.hook("validation", "user:create", (data) => {
34
+ if (!data["email"]) return [{ field: "email", error: "required" }];
35
+ return null;
36
+ });
37
+ });
38
+
39
+ const registry = createRegistry([feature]);
40
+ const result = runValidation(registry, "test:write:user:create", {});
41
+ expect(result).toEqual([{ field: "email", error: "required" }]);
42
+ });
43
+
44
+ test("runValidation scoped to feature", () => {
45
+ const f1 = defineFeature("a", (r) => {
46
+ r.hook("validation", "task:create", (data) => {
47
+ if (!data["name"]) return [{ field: "name", error: "required" }];
48
+ return null;
49
+ });
50
+ });
51
+ const f2 = defineFeature("b", (r) => {
52
+ r.hook("validation", "task:create", (data) => {
53
+ if (!data["age"]) return [{ field: "age", error: "required" }];
54
+ return null;
55
+ });
56
+ });
57
+
58
+ const registry = createRegistry([f1, f2]);
59
+ const resultA = runValidation(registry, "a:write:task:create", {});
60
+ expect(resultA).toEqual([{ field: "name", error: "required" }]);
61
+
62
+ const resultB = runValidation(registry, "b:write:task:create", {});
63
+ expect(resultB).toEqual([{ field: "age", error: "required" }]);
64
+ });
65
+
66
+ test("runValidation returns null for unknown hook", () => {
67
+ const feature = defineFeature("test", () => {});
68
+ const registry = createRegistry([feature]);
69
+ expect(runValidation(registry, "test:write:nonexistent", {})).toBeNull();
70
+ });
71
+ });