@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,357 @@
1
+ import type { Hono } from "hono";
2
+ import type { AuthRoutesConfig } from "../api/auth-routes";
3
+ import type { JwtHelper } from "../api/jwt";
4
+ import { buildServer } from "../api/server";
5
+ import { createSseBroker } from "../api/sse-broker";
6
+ import type { DbConnection } from "../db/connection";
7
+ import { createRegistry } from "../engine/registry";
8
+ import type { FeatureDefinition, Registry, TenantId } from "../engine/types";
9
+ import { createArchivedStreamsTable, createEventsTable } from "../event-store";
10
+ import type { Lifecycle } from "../lifecycle";
11
+ import type { ObservabilityProvider } from "../observability";
12
+ import type { EventDispatcher } from "../pipeline";
13
+ import { createEntityCache, createEventDedup, createIdempotencyGuard } from "../pipeline";
14
+ import { createInMemorySearchAdapter } from "../search";
15
+ import type { SearchAdapter } from "../search/types";
16
+ import { createTestDb } from "./db";
17
+ import { createEventCollector, type EventCollector } from "./event-collector";
18
+ import { createTestRedis, type TestRedis } from "./redis";
19
+ import { createRequestHelper, type RequestHelper } from "./request-helper";
20
+ import { pushTables } from "./table-helpers";
21
+
22
+ export type TestStack = {
23
+ app: Hono;
24
+ jwt: JwtHelper;
25
+ registry: Registry;
26
+ /** Drizzle connection — the test DB's lifecycle (name, raw pg client,
27
+ * drop) lives inside setupTestStack and is released via stack.cleanup(). */
28
+ db: DbConnection;
29
+ redis: TestRedis;
30
+ search: SearchAdapter;
31
+ events: EventCollector;
32
+ http: RequestHelper;
33
+ observability: ObservabilityProvider;
34
+ // Present whenever a system consumer (SSE, Search) or
35
+ // r.multiStreamProjection is wired. Tests drain it via runOnce() for
36
+ // deterministic assertion — no timer-induced flakiness.
37
+ eventDispatcher?: EventDispatcher;
38
+ // Only set when the caller passed `lifecycle` via options. Tests that
39
+ // exercise drain() / /health/ready wire one in; ordinary suites ignore it.
40
+ lifecycle?: Lifecycle;
41
+ cleanup: () => Promise<void>;
42
+ };
43
+
44
+ export type TestStackOptions = {
45
+ features: readonly FeatureDefinition[];
46
+ /** System hooks to wire up. Default: all (sse, search) */
47
+ systemHooks?: ("sse" | "search")[];
48
+ /** Search config per tenant — defaults to tenant 1 with all text fields */
49
+ searchConfig?: {
50
+ tenantId: TenantId;
51
+ searchableFields: string[];
52
+ rankingFields: string[];
53
+ };
54
+ jwtSecret?: string;
55
+ /** Extra fields merged into the AppContext (e.g. _notifyFactory, configResolver).
56
+ * Can be a function receiving (registry, db, sseBroker) for late binding. */
57
+ extraContext?:
58
+ | Record<string, unknown>
59
+ | ((deps: {
60
+ registry: Registry;
61
+ db: import("../db/connection").DbConnection;
62
+ sseBroker: import("../api/sse-broker").SseBroker;
63
+ redis: import("ioredis").default;
64
+ }) => Record<string, unknown>);
65
+ /** Wire up auth routes (login, tenant-switch). Leave undefined to skip. */
66
+ authConfig?: AuthRoutesConfig;
67
+ /** Register a file storage provider so uploads via POST /api/files work and
68
+ * `ctx.files.ref(key)` is available to hooks/MSPs. Omit to skip — tests
69
+ * without file handling don't need it. */
70
+ files?: { storageProvider: import("../files").FileStorageProvider };
71
+ /** Observability provider — omit for NoopProvider (no spans/metrics).
72
+ * Pass a ConsoleProvider to see the span tree in stdout, or a custom
73
+ * provider (e.g. a recording provider for assertions in tests). */
74
+ observability?: ObservabilityProvider;
75
+ /** Inject a process lifecycle so tests can drain() and observe
76
+ * /health/ready flipping to 503. Omit if the suite doesn't care. */
77
+ lifecycle?: Lifecycle;
78
+ /** Wire L1 (global-IP) and/or L2 (auth-endpoint) rate-limit middleware.
79
+ * The resolver is auto-built from the test Redis. Mirrors
80
+ * buildServer's `rateLimit` option 1:1 — see there for shape. */
81
+ rateLimit?: import("../api/server").ServerOptions["rateLimit"];
82
+ /** Inject a MasterKeyProvider for secrets-backed tests. Lands typed in
83
+ * AppContext — set/delete/get + rotation job pick it up. Omit for
84
+ * suites that don't touch secrets. */
85
+ masterKeyProvider?: import("../secrets").MasterKeyProvider;
86
+ /** Feature-toggle resolver. When present the dispatcher's feature-gate,
87
+ * hook-filter, and MSP-filter all consult it; absent = every feature
88
+ * treated as always-on. Pass the callback from
89
+ * GlobalFeatureToggleRuntime.effectiveFeatures for real DB-backed
90
+ * toggles, or a plain `() => new Set<string>(registry.features.keys())`
91
+ * to force a specific snapshot in a unit-style setup. */
92
+ effectiveFeatures?: () => ReadonlySet<string>;
93
+ /** Pin the underlying Postgres DB name instead of the default
94
+ * `kumiko_test_<8chars>`. Forwarded to createTestDb. Primary use
95
+ * case: dev servers that want persistent storage across restarts —
96
+ * combine with `persistentDb: true`. */
97
+ dbName?: string;
98
+ /** When true, cleanup() keeps the Postgres DB around — the caller
99
+ * owns its lifecycle. Default false (test contract). Used by
100
+ * dev-server wiring to survive hot-reloads. */
101
+ persistentDb?: boolean;
102
+ /** Forwarded to buildServer — when set, requests without a JWT pass
103
+ * through as anonymous instead of 401. See AnonymousAccessConfig.
104
+ * Akzeptiert entweder einen statischen Config-Object ODER eine Factory
105
+ * `({registry, db, sseBroker, redis}) => Config` — gleiches Pattern wie
106
+ * `extraContext`. Die Factory wird einmal beim Boot aufgerufen, der
107
+ * TenantResolver darin closure'd typischerweise `db` für Subdomain-
108
+ * Lookups. */
109
+ anonymousAccess?:
110
+ | import("../api/server").ServerOptions["anonymousAccess"]
111
+ | ((deps: {
112
+ registry: Registry;
113
+ db: import("../db/connection").DbConnection;
114
+ sseBroker: import("../api/sse-broker").SseBroker;
115
+ redis: import("ioredis").default;
116
+ }) => import("../api/server").ServerOptions["anonymousAccess"]);
117
+ };
118
+
119
+ const DEFAULT_JWT_SECRET = "test-stack-secret-minimum-32-characters!!";
120
+
121
+ export async function setupTestStack(options: TestStackOptions): Promise<TestStack> {
122
+ const jwtSecret = options.jwtSecret ?? DEFAULT_JWT_SECRET;
123
+ const enabledHooks = options.systemHooks ?? ["sse", "search"];
124
+
125
+ // Temporal-Polyfill installieren bevor Feature-Code läuft. Idempotent —
126
+ // Production-Server-Boot ruft das gleich. Auf Runtimes mit nativem
127
+ // Temporal ein No-Op.
128
+ const { ensureTemporalPolyfill } = await import("../time/polyfill");
129
+ await ensureTemporalPolyfill();
130
+
131
+ // Forward db-name/persistent-flag through to createTestDb. The
132
+ // defaults (undefined dbName, persistent:false) keep the legacy
133
+ // test contract: fresh kumiko_test_<random> DB per setup, dropped
134
+ // on cleanup.
135
+ const [testDb, testRedis] = await Promise.all([
136
+ createTestDb({
137
+ ...(options.dbName !== undefined && { dbName: options.dbName }),
138
+ ...(options.persistentDb !== undefined && { persistent: options.persistentDb }),
139
+ }),
140
+ createTestRedis(),
141
+ ]);
142
+
143
+ // Every ES-entity writes events via createEventStoreExecutor in the
144
+ // feature's write handlers. Auto-create the events table so every
145
+ // setupTestStack call is ready for writes without needing a manual
146
+ // createEventsTable().
147
+ await createEventsTable(testDb.db);
148
+ // Archive-stream metadata — needed by ctx.appendEvent's archive guard and
149
+ // loadAggregate's default-skip. Idempotent, so production boot running
150
+ // the same call is fine.
151
+ await createArchivedStreamsTable(testDb.db);
152
+
153
+ // Framework state for projection rebuild/status + event-consumer cursors.
154
+ // Idempotent — production boot flows run the same calls.
155
+ const { createProjectionStateTable, createEventConsumerStateTable } = await import("../pipeline");
156
+ await createProjectionStateTable(testDb.db);
157
+ await createEventConsumerStateTable(testDb.db);
158
+
159
+ // Files support: when a provider is registered, the fileRefs table must
160
+ // exist before the first upload. Skipped when no provider — the table
161
+ // stays off tenant test DBs that never touch files.
162
+ if (options.files) {
163
+ const { fileRefsTable } = await import("../files");
164
+ await pushTables(testDb.db, { fileRefsTable });
165
+ }
166
+
167
+ // Projection tables: the executor writes into them in the same TX as the
168
+ // event-append, so they have to exist before the first write. Auto-push
169
+ // everything registered via r.projection() — keeps tests from having to
170
+ // know which projections a feature happens to declare. Two projections
171
+ // backed by the same physical table (e.g. an alternative apply-shape for
172
+ // the same read-model in a test feature) are deduped by Drizzle-table
173
+ // reference so drizzle-kit doesn't emit duplicate CREATE TABLE statements.
174
+ const projectionTables: Record<string, unknown> = {};
175
+ const seenTables = new Set<unknown>();
176
+ for (const feature of options.features) {
177
+ for (const [projName, proj] of Object.entries(feature.projections)) {
178
+ if (seenTables.has(proj.table)) continue;
179
+ seenTables.add(proj.table);
180
+ projectionTables[projName] = proj.table;
181
+ }
182
+ // Multi-stream projection tables follow the same auto-push rule — the
183
+ // async dispatcher writes to them as soon as the first matching event
184
+ // flows through, so the DDL must exist before setupTestStack returns.
185
+ // skip: MSPs without a table are pure side-effect consumers.
186
+ for (const [mspName, msp] of Object.entries(feature.multiStreamProjections)) {
187
+ if (!msp.table) continue;
188
+ if (seenTables.has(msp.table)) continue;
189
+ seenTables.add(msp.table);
190
+ projectionTables[`msp_${mspName}`] = msp.table;
191
+ }
192
+ }
193
+ if (Object.keys(projectionTables).length > 0) {
194
+ // pushTables emits raw CREATE TABLE — fine for ephemeral test DBs but
195
+ // collides on re-boot against a persistent DB whose projection tables
196
+ // were created during a previous run. Filter out the ones that already
197
+ // exist; drizzle-kit's diff machinery would otherwise emit CREATE for
198
+ // them again.
199
+ const { tableExists } = await import("../db/schema-inspection");
200
+ const { getTableName } = await import("drizzle-orm");
201
+ const missing: Record<string, unknown> = {};
202
+ for (const [key, tbl] of Object.entries(projectionTables)) {
203
+ const physical = getTableName(tbl as Parameters<typeof getTableName>[0]);
204
+ if (await tableExists(testDb.db, `public.${physical}`)) continue;
205
+ missing[key] = tbl;
206
+ }
207
+ if (Object.keys(missing).length > 0) {
208
+ await pushTables(testDb.db, missing);
209
+ }
210
+ }
211
+
212
+ const searchAdapter = createInMemorySearchAdapter();
213
+ const events = createEventCollector();
214
+ const registry = createRegistry([...options.features]);
215
+
216
+ // Auto-configure search for tenant 1 based on registry
217
+ if (enabledHooks.includes("search")) {
218
+ const searchableFields: string[] = [];
219
+ for (const feature of options.features) {
220
+ for (const [, entity] of Object.entries(feature.entities)) {
221
+ for (const [fieldName, field] of Object.entries(entity.fields)) {
222
+ if (field.type === "text" && field.searchable) {
223
+ searchableFields.push(fieldName);
224
+ }
225
+ if (field.type === "embedded") {
226
+ for (const [subName, subField] of Object.entries(field.schema)) {
227
+ if (subField.searchable) {
228
+ searchableFields.push(`${fieldName}_${subName}`);
229
+ }
230
+ }
231
+ }
232
+ }
233
+ }
234
+ }
235
+
236
+ if (options.searchConfig) {
237
+ await searchAdapter.configure(options.searchConfig.tenantId, {
238
+ searchableFields: options.searchConfig.searchableFields,
239
+ rankingFields: options.searchConfig.rankingFields,
240
+ });
241
+ } else if (searchableFields.length > 0) {
242
+ await searchAdapter.configure("00000000-0000-4000-8000-000000000001", {
243
+ searchableFields,
244
+ rankingFields: searchableFields,
245
+ });
246
+ }
247
+ }
248
+
249
+ // Wire SSE broker with event collector
250
+ const sseBroker = createSseBroker();
251
+ sseBroker.addClient(
252
+ "tenant:00000000-0000-4000-8000-000000000001",
253
+ (event) => events.sse.push(event),
254
+ () => {},
255
+ );
256
+
257
+ const idempotency = createIdempotencyGuard(testRedis.redis, { ttlSeconds: 60 });
258
+ const eventDedup = createEventDedup(testRedis.redis, { ttlSeconds: 60 });
259
+ const entityCache = createEntityCache(testRedis.redis, { ttlSeconds: 60 });
260
+
261
+ const server = buildServer({
262
+ registry,
263
+ context: {
264
+ db: testDb.db,
265
+ redis: testRedis.redis,
266
+ searchAdapter,
267
+ entityCache,
268
+ registry,
269
+ ...(options.masterKeyProvider ? { masterKeyProvider: options.masterKeyProvider } : {}),
270
+ ...(typeof options.extraContext === "function"
271
+ ? options.extraContext({ registry, db: testDb.db, sseBroker, redis: testRedis.redis })
272
+ : options.extraContext),
273
+ },
274
+ jwtSecret,
275
+ dispatcherOptions: {
276
+ idempotency,
277
+ ...(options.effectiveFeatures && { effectiveFeatures: options.effectiveFeatures }),
278
+ },
279
+ eventDedup,
280
+ sseBroker,
281
+ // Tests drive the dispatcher via stack.eventDispatcher.runOnce() for
282
+ // deterministic drains — no timer-induced flakiness. pollIntervalMs
283
+ // stays short anyway in case a test opts into `.start()`. pgClient
284
+ // plumbs through the LISTEN wake-up for tests that want to measure
285
+ // post-commit latency (Sprint E.4).
286
+ eventDispatcher: {
287
+ pollIntervalMs: 50,
288
+ pgClient: testDb.client,
289
+ systemConsumers: {
290
+ sse: enabledHooks.includes("sse"),
291
+ search: enabledHooks.includes("search"),
292
+ },
293
+ },
294
+ // Default tests to no login rate-limiter so existing suites that loop
295
+ // over logins don't hit a 429 after 10 attempts. Suites specifically
296
+ // testing the limiter can override via authConfig.loginRateLimit.
297
+ ...(options.authConfig
298
+ ? {
299
+ auth: {
300
+ ...options.authConfig,
301
+ ...(options.authConfig.loginRateLimit === undefined ? { loginRateLimit: null } : {}),
302
+ },
303
+ }
304
+ : {}),
305
+ ...(options.observability ? { observability: options.observability } : {}),
306
+ ...(options.lifecycle ? { lifecycle: options.lifecycle } : {}),
307
+ ...(options.rateLimit ? { rateLimit: options.rateLimit } : {}),
308
+ ...(options.anonymousAccess
309
+ ? {
310
+ anonymousAccess:
311
+ typeof options.anonymousAccess === "function"
312
+ ? options.anonymousAccess({
313
+ registry,
314
+ db: testDb.db,
315
+ sseBroker,
316
+ redis: testRedis.redis,
317
+ })
318
+ : options.anonymousAccess,
319
+ }
320
+ : {}),
321
+ // Wire the upload routes + ctx.files only when the caller registered a
322
+ // provider. Tests that don't touch files skip both without extra setup.
323
+ ...(options.files
324
+ ? { files: { db: testDb.db, storageProvider: options.files.storageProvider } }
325
+ : {}),
326
+ });
327
+
328
+ const eventDispatcher: EventDispatcher | undefined = server.eventDispatcher;
329
+
330
+ // Pre-register consumer state rows so tests can call runOnce() directly
331
+ // without a preceding explicit start(). Timer fires at pollIntervalMs=50
332
+ // but passInFlight serialises concurrent passes — tests that drain via
333
+ // runOnce() remain deterministic. Tests that specifically exercise the
334
+ // timer loop call start() again (idempotent) after setup.
335
+ if (eventDispatcher) await eventDispatcher.ensureRegistered();
336
+
337
+ const http = createRequestHelper(server.app, server.jwt);
338
+
339
+ return {
340
+ app: server.app,
341
+ jwt: server.jwt,
342
+ registry,
343
+ db: testDb.db,
344
+ redis: testRedis,
345
+ search: searchAdapter,
346
+ events,
347
+ http,
348
+ observability: server.observability,
349
+ ...(eventDispatcher ? { eventDispatcher } : {}),
350
+ ...(server.lifecycle ? { lifecycle: server.lifecycle } : {}),
351
+ cleanup: async () => {
352
+ if (eventDispatcher) await eventDispatcher.stop();
353
+ await server.observability.shutdown();
354
+ await Promise.all([testDb.cleanup(), testRedis.cleanup()]);
355
+ },
356
+ };
357
+ }
@@ -0,0 +1,37 @@
1
+ import type { SessionUser, TenantId } from "../engine/types";
2
+
3
+ // Zero-padded UUIDs used across the test suite. `testTenantId(1)` /
4
+ // `testUserId(1)` read cleaner in assertions than the full UUID literals,
5
+ // and keep all tests on a single shape — if the UUID layout ever changes,
6
+ // it changes here.
7
+ export function testTenantId(n: number): TenantId {
8
+ return `00000000-0000-4000-8000-${n.toString().padStart(12, "0")}`;
9
+ }
10
+
11
+ // Distinct prefix from tenantId so debug output visibly differentiates the
12
+ // two when a user-id accidentally lands in a tenant-id slot.
13
+ export function testUserId(n: number): string {
14
+ return `11111111-0000-4000-8000-${n.toString().padStart(12, "0")}`;
15
+ }
16
+
17
+ export const TestUsers = {
18
+ admin: { id: testUserId(1), tenantId: testTenantId(1), roles: ["Admin"] },
19
+ systemAdmin: { id: testUserId(1), tenantId: testTenantId(1), roles: ["SystemAdmin"] },
20
+ user: { id: testUserId(2), tenantId: testTenantId(1), roles: ["User"] },
21
+ driver: { id: testUserId(3), tenantId: testTenantId(1), roles: ["Driver"] },
22
+ otherTenant: { id: testUserId(10), tenantId: testTenantId(2), roles: ["Admin"] },
23
+ } as const satisfies Record<string, SessionUser>;
24
+
25
+ // Accept numeric shortcuts for legacy call sites — stringify to a UUID so the
26
+ // SessionUser type stays aligned. `createTestUser({ id: 42 })` gives you
27
+ // `testUserId(42)`. Explicit strings pass through untouched.
28
+ export function createTestUser(
29
+ overrides?: Partial<Omit<SessionUser, "id">> & { id?: string | number },
30
+ ): SessionUser {
31
+ const normalizedId =
32
+ typeof overrides?.id === "number"
33
+ ? testUserId(overrides.id)
34
+ : (overrides?.id ?? TestUsers.admin.id);
35
+ const { id: _id, ...rest } = overrides ?? {};
36
+ return { ...TestUsers.admin, ...rest, id: normalizedId };
37
+ }
@@ -0,0 +1,230 @@
1
+ import { describe, expect, test } from "vitest";
2
+ import { z } from "zod";
3
+ import {
4
+ createBooleanField,
5
+ createEntity,
6
+ createRegistry,
7
+ createSelectField,
8
+ createTextField,
9
+ defineEntityCreateHandler,
10
+ defineFeature,
11
+ } from "../../engine";
12
+ import { generateE2ESpec, generateZodFixture } from "../e2e-generator";
13
+
14
+ const taskEntity = createEntity({
15
+ table: "tasks",
16
+ fields: {
17
+ title: createTextField({ required: true, maxLength: 200 }),
18
+ done: createBooleanField({ default: false }),
19
+ status: createSelectField({ options: ["todo", "doing", "done"] as const }),
20
+ },
21
+ });
22
+
23
+ // Minimal-Feature mit beiden Screen-Typen UND einem Create-Handler —
24
+ // abhängig davon was im Screen steht, emittiert der Generator andere
25
+ // Kind-Kombinationen, siehe buildListSpecs/buildEditSpecs.
26
+ function createTasksFeature() {
27
+ return defineFeature("tasks", (r) => {
28
+ r.systemScope();
29
+ r.entity("task", taskEntity);
30
+ r.writeHandler(defineEntityCreateHandler("task", taskEntity));
31
+ r.screen({
32
+ id: "task-list",
33
+ type: "entityList",
34
+ entity: "task",
35
+ columns: ["title", "status", "done"],
36
+ });
37
+ r.screen({
38
+ id: "task-edit",
39
+ type: "entityEdit",
40
+ entity: "task",
41
+ layout: {
42
+ sections: [{ title: "tasks:section.basics", fields: ["title", "status", "done"] }],
43
+ },
44
+ });
45
+ });
46
+ }
47
+
48
+ describe("generateE2ESpec", () => {
49
+ test("emits list-renders + list-has-fixture-row for entityList screens", () => {
50
+ const registry = createRegistry([createTasksFeature()]);
51
+ const specs = generateE2ESpec(registry);
52
+
53
+ const listSpecs = specs.filter((s) => s.screenQn === "tasks:screen:task-list");
54
+ expect(listSpecs.map((s) => s.kind)).toEqual(["list-renders", "list-has-fixture-row"]);
55
+
56
+ const fixtureSpec = listSpecs.find((s) => s.kind === "list-has-fixture-row");
57
+ if (fixtureSpec?.kind !== "list-has-fixture-row") throw new Error("unreachable");
58
+ expect(fixtureSpec.writeHandlerQn).toBe("tasks:write:task:create");
59
+ expect(fixtureSpec.urlPath).toBe("/t/{tenant}/tasks/task-list");
60
+ expect(fixtureSpec.fixture["title"]).toBe("e2e title");
61
+ expect(fixtureSpec.identifyingValue).toBe("e2e title");
62
+ });
63
+
64
+ test("emits edit-validates-required + edit-save-persists for entityEdit screens", () => {
65
+ const registry = createRegistry([createTasksFeature()]);
66
+ const specs = generateE2ESpec(registry);
67
+
68
+ const editSpecs = specs.filter((s) => s.screenQn === "tasks:screen:task-edit");
69
+ expect(editSpecs.map((s) => s.kind)).toEqual(["edit-validates-required", "edit-save-persists"]);
70
+
71
+ const validates = editSpecs.find((s) => s.kind === "edit-validates-required");
72
+ if (validates?.kind !== "edit-validates-required") throw new Error("unreachable");
73
+ expect(validates.requiredFields).toEqual(["title"]);
74
+
75
+ const persists = editSpecs.find((s) => s.kind === "edit-save-persists");
76
+ if (persists?.kind !== "edit-save-persists") throw new Error("unreachable");
77
+ expect(persists.listUrlPath).toBe("/t/{tenant}/tasks/task-list");
78
+ expect(persists.identifyingField).toBe("title");
79
+ // Select-Field muss "select" bekommen, Boolean "check", Text "fill" —
80
+ // sonst emittiert der Renderer .fill() für ein Dropdown und Playwright
81
+ // zerschellt am ersten Sample mit Select-Feld.
82
+ expect(persists.fills).toEqual([
83
+ { kind: "fill", field: "title", value: "e2e title" },
84
+ { kind: "select", field: "status", value: "todo" },
85
+ { kind: "check", field: "done", value: true },
86
+ ]);
87
+ });
88
+
89
+ test("accepts tenant-slug override", () => {
90
+ const registry = createRegistry([createTasksFeature()]);
91
+ const specs = generateE2ESpec(registry, { tenantPlaceholder: "acme" });
92
+ expect(specs[0]?.urlPath).toMatch(/^\/t\/acme\//);
93
+ });
94
+
95
+ test("skips custom screens", () => {
96
+ const feature = defineFeature("audit", (r) => {
97
+ r.systemScope();
98
+ r.screen({
99
+ id: "log",
100
+ type: "custom",
101
+ renderer: { react: { __component: "X" } },
102
+ });
103
+ });
104
+ const specs = generateE2ESpec(createRegistry([feature]));
105
+ expect(specs).toEqual([]);
106
+ });
107
+
108
+ test("skips list-has-fixture-row when no create-handler is registered", () => {
109
+ // Feature hat Screen + Entity aber keinen Create-Handler (z.B. weil
110
+ // Writes noch in einer anderen Feature-Variante landen). Ohne Handler
111
+ // kann der Generator nicht seeden — list-renders bleibt, der Fixture-
112
+ // Test wird gespart statt falsch generiert.
113
+ const readOnly = defineFeature("read-only", (r) => {
114
+ r.systemScope();
115
+ r.entity("task", taskEntity);
116
+ r.screen({ id: "list", type: "entityList", entity: "task", columns: ["title"] });
117
+ });
118
+ const specs = generateE2ESpec(createRegistry([readOnly]));
119
+ expect(specs.map((s) => s.kind)).toEqual(["list-renders"]);
120
+ });
121
+
122
+ test("edit-save-persists has undefined listUrlPath when no matching list-screen exists", () => {
123
+ // entityEdit ohne entityList — z.B. Detail-Seite die über SSE Refresh
124
+ // statt Navigation validiert wird. Der Generator muss trotzdem eine
125
+ // edit-save-persists-Spec emittieren, nur ohne listUrlPath (Renderer
126
+ // verifiziert dann im Edit-View selbst).
127
+ const editOnly = defineFeature("edit-only", (r) => {
128
+ r.systemScope();
129
+ r.entity("task", taskEntity);
130
+ r.writeHandler(defineEntityCreateHandler("task", taskEntity));
131
+ r.screen({
132
+ id: "edit",
133
+ type: "entityEdit",
134
+ entity: "task",
135
+ layout: { sections: [{ title: "s", fields: ["title"] }] },
136
+ });
137
+ });
138
+ const specs = generateE2ESpec(createRegistry([editOnly]));
139
+ const persists = specs.find((s) => s.kind === "edit-save-persists");
140
+ if (persists?.kind !== "edit-save-persists") throw new Error("unreachable");
141
+ expect(persists.listUrlPath).toBeUndefined();
142
+ });
143
+
144
+ test("text-field formats (email/url) produce format-specific fixtures", () => {
145
+ // createTextField({ format: "email" }) muss einen Mail-artigen Fixture
146
+ // liefern — sonst schlägt die Zod-Validation am Server fehl, sobald
147
+ // der Generator-Output gegen eine echte API läuft.
148
+ const contactEntity = createEntity({
149
+ table: "contacts",
150
+ fields: {
151
+ name: createTextField({ required: true }),
152
+ email: createTextField({ required: true, format: "email" }),
153
+ homepage: createTextField({ format: "url" }),
154
+ },
155
+ });
156
+ const feature = defineFeature("contacts", (r) => {
157
+ r.systemScope();
158
+ r.entity("contact", contactEntity);
159
+ r.writeHandler(defineEntityCreateHandler("contact", contactEntity));
160
+ r.screen({
161
+ id: "list",
162
+ type: "entityList",
163
+ entity: "contact",
164
+ columns: ["name", "email", "homepage"],
165
+ });
166
+ });
167
+ const specs = generateE2ESpec(createRegistry([feature]));
168
+ const fixtureSpec = specs.find((s) => s.kind === "list-has-fixture-row");
169
+ if (fixtureSpec?.kind !== "list-has-fixture-row") throw new Error("unreachable");
170
+ expect(fixtureSpec.fixture["email"]).toMatch(/^e2e-email@/);
171
+ expect(fixtureSpec.fixture["homepage"]).toBe("https://example.com");
172
+ });
173
+
174
+ test("mixed feature (list + edit + custom) — generates for list/edit, skips custom", () => {
175
+ // Deckt das Shape ab das ein echtes Sample hat: ein Feature mit allen
176
+ // drei Screen-Typen. Custom wird übersprungen, List + Edit liefern
177
+ // ihre jeweiligen Spec-Kinds.
178
+ const mixed = defineFeature("mixed", (r) => {
179
+ r.systemScope();
180
+ r.entity("task", taskEntity);
181
+ r.writeHandler(defineEntityCreateHandler("task", taskEntity));
182
+ r.screen({ id: "list", type: "entityList", entity: "task", columns: ["title"] });
183
+ r.screen({
184
+ id: "edit",
185
+ type: "entityEdit",
186
+ entity: "task",
187
+ layout: { sections: [{ title: "mixed:s", fields: ["title"] }] },
188
+ });
189
+ r.screen({
190
+ id: "dashboard",
191
+ type: "custom",
192
+ renderer: { react: { __component: "X" } },
193
+ });
194
+ });
195
+ const specs = generateE2ESpec(createRegistry([mixed]));
196
+ const screens = new Set(specs.map((s) => s.screenQn));
197
+ expect(screens).toEqual(new Set(["mixed:screen:list", "mixed:screen:edit"]));
198
+ expect(specs.map((s) => s.kind).sort()).toEqual([
199
+ "edit-save-persists",
200
+ "edit-validates-required",
201
+ "list-has-fixture-row",
202
+ "list-renders",
203
+ ]);
204
+ });
205
+ });
206
+
207
+ describe("generateZodFixture", () => {
208
+ test("primitives", () => {
209
+ expect(generateZodFixture(z.string())).toBe("e2e-fixture");
210
+ expect(generateZodFixture(z.number())).toBe(1);
211
+ expect(generateZodFixture(z.boolean())).toBe(true);
212
+ expect(generateZodFixture(z.enum(["a", "b"]))).toBe("a");
213
+ });
214
+
215
+ test("string formats", () => {
216
+ expect(generateZodFixture(z.email())).toBe("e2e@example.com");
217
+ expect(generateZodFixture(z.url())).toBe("https://example.com");
218
+ expect(generateZodFixture(z.uuid())).toBe("00000000-0000-4000-8000-000000000000");
219
+ });
220
+
221
+ test("optional + default unwrap", () => {
222
+ expect(generateZodFixture(z.string().optional())).toBe("e2e-fixture");
223
+ expect(generateZodFixture(z.number().default(42))).toBe(1);
224
+ });
225
+
226
+ test("unsupported types throw", () => {
227
+ expect(() => generateZodFixture(z.object({}))).toThrow(/not supported yet/);
228
+ expect(() => generateZodFixture(z.array(z.string()))).toThrow(/not supported yet/);
229
+ });
230
+ });
@@ -0,0 +1,54 @@
1
+ import { sql } from "drizzle-orm";
2
+ import { afterAll, beforeAll, describe, expect, test } from "vitest";
3
+ import type { EntityDefinition } from "../../engine/types";
4
+ import { createEntityTable, createTestDb, ensureEntityTable, type TestDb } from "../../stack";
5
+
6
+ // ensureEntityTable ist die idempotente Variante von createEntityTable —
7
+ // existiert wegen des dev-server-Boot-Pfads (persistente DB, Table von
8
+ // letztem Run). createEntityTable bleibt strict, damit Tests ein
9
+ // falsches Schema nicht stillschweigend akzeptieren.
10
+
11
+ const tenantEntity: EntityDefinition = {
12
+ fields: {
13
+ title: { type: "text", required: true },
14
+ },
15
+ table: "ensure_entity_table_probe",
16
+ } as unknown as EntityDefinition;
17
+
18
+ let db: TestDb;
19
+
20
+ beforeAll(async () => {
21
+ db = await createTestDb();
22
+ });
23
+
24
+ afterAll(async () => {
25
+ await db.cleanup();
26
+ });
27
+
28
+ describe("ensureEntityTable", () => {
29
+ test("legt die Tabelle beim ersten Aufruf an (returnt true)", async () => {
30
+ const created = await ensureEntityTable(db.db, tenantEntity, "probe");
31
+ expect(created).toBe(true);
32
+ const rows = await db.db.execute<{ exists: boolean }>(
33
+ sql`SELECT to_regclass('public.ensure_entity_table_probe') IS NOT NULL AS exists`,
34
+ );
35
+ expect(rows[0]?.exists).toBe(true);
36
+ });
37
+
38
+ test("ist beim zweiten Aufruf ein No-Op (returnt false, kein Fehler)", async () => {
39
+ const created = await ensureEntityTable(db.db, tenantEntity, "probe");
40
+ expect(created).toBe(false);
41
+ });
42
+
43
+ test("createEntityTable bleibt strict — wirft bei existierender Tabelle", async () => {
44
+ // Gleiche Entity zweimal via createEntityTable → postgres 42P07
45
+ // (relation already exists). Drizzle wrappt den PG-Error in
46
+ // DrizzleQueryError; der echte Code steckt in .cause. Sicherstellt,
47
+ // dass ensureEntityTable nicht versehentlich das strict-Verhalten
48
+ // verändert.
49
+ await expect(createEntityTable(db.db, tenantEntity, "probe")).rejects.toSatisfy((err) => {
50
+ const cause = (err as { cause?: { code?: string } }).cause;
51
+ return cause?.code === "42P07";
52
+ });
53
+ });
54
+ });