@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,914 @@
1
+ import { afterAll, beforeAll, beforeEach, describe, expect, test } from "vitest";
2
+ import { z } from "zod";
3
+ import { createEventStoreExecutor } from "../db/event-store-executor";
4
+ import { defineFeature, type EntityId, type HandlerContext, type SaveContext } from "../engine";
5
+ import { UnprocessableError, writeFailure } from "../errors";
6
+ import { eventsTable } from "../event-store";
7
+ import {
8
+ createEntityTable,
9
+ createTestUser,
10
+ setupTestStack,
11
+ type TestStack,
12
+ TestUsers,
13
+ } from "../stack";
14
+ import { expectErrorIncludes, sharedUserEntity, sharedUserTable } from "../testing";
15
+
16
+ // --- Entities ---
17
+
18
+ const userEntity = sharedUserEntity;
19
+ const userTable = sharedUserTable;
20
+
21
+ // --- Feature-level hook log (not a system hook, tracked separately) ---
22
+
23
+ const featurePostSaveLog: SaveContext[] = [];
24
+
25
+ // --- Feature definition ---
26
+
27
+ function userExecutor(ctx: { searchAdapter?: unknown; entityCache?: unknown }) {
28
+ return createEventStoreExecutor(userTable, userEntity, {
29
+ entityName: "user",
30
+ ...(ctx.searchAdapter
31
+ ? { searchAdapter: ctx.searchAdapter as import("../search").SearchAdapter }
32
+ : {}),
33
+ ...(ctx.entityCache
34
+ ? { entityCache: ctx.entityCache as import("../pipeline/entity-cache").EntityCache }
35
+ : {}),
36
+ });
37
+ }
38
+
39
+ // Single source of truth for the user-created domain-event name + payload.
40
+ // ctx.appendEvent writes this onto the user aggregate's own stream. The MSP
41
+ // below picks it up via the event-dispatcher after commit.
42
+ let USER_CREATED_EVENT: string;
43
+
44
+ // Test-level MSP capture — populated by the multiStreamProjection apply below.
45
+ // Declared here so tests can reset + assert against it across describe blocks.
46
+ const domainEventSubscriberCalls: Array<{ type: string; payload: unknown }> = [];
47
+
48
+ async function emitUserCreated(
49
+ ctx: Pick<HandlerContext, "appendEventUnsafe">,
50
+ id: EntityId,
51
+ email: string,
52
+ ): Promise<void> {
53
+ await ctx.appendEventUnsafe({
54
+ aggregateId: String(id),
55
+ aggregateType: "user",
56
+ type: USER_CREATED_EVENT,
57
+ payload: { id, email },
58
+ });
59
+ }
60
+
61
+ const userFeature = defineFeature("users", (r) => {
62
+ const user = r.entity("user", userEntity);
63
+
64
+ const userCreated = r.defineEvent("user.created", z.object({ id: z.any(), email: z.string() }));
65
+ USER_CREATED_EVENT = userCreated.name;
66
+
67
+ // r.multiStreamProjection: capture USER_CREATED_EVENT asynchronously via
68
+ // the event-dispatcher. Replaces the old r.postEvent path (removed in E2).
69
+ r.multiStreamProjection({
70
+ name: "user-created-capture",
71
+ apply: {
72
+ [userCreated.name]: async (event) => {
73
+ domainEventSubscriberCalls.push({ type: event.type, payload: event.payload });
74
+ },
75
+ },
76
+ });
77
+
78
+ const createHandler = r.writeHandler(
79
+ "user:create",
80
+ z.object({
81
+ email: z.email(),
82
+ firstName: z.string().optional(),
83
+ lastName: z.string().optional(),
84
+ }),
85
+ async (event, ctx) => userExecutor(ctx).create(event.payload, event.user, ctx.db),
86
+ { access: { roles: ["Admin"] } },
87
+ );
88
+
89
+ // Variant used by the ctx.appendEvent test block — creates the user AND
90
+ // appends a domain event (`users:event:user.created`) to the user's own
91
+ // aggregate stream. Separate from `user:create` so the CRUD-only happy-path
92
+ // tests don't unnecessarily bump the stream version past what the client
93
+ // sees in the response.
94
+ r.writeHandler(
95
+ "user:create-and-signal",
96
+ z.object({
97
+ email: z.email(),
98
+ firstName: z.string().optional(),
99
+ lastName: z.string().optional(),
100
+ }),
101
+ async (event, ctx) => {
102
+ const result = await userExecutor(ctx).create(event.payload, event.user, ctx.db);
103
+ if (result.isSuccess) {
104
+ await emitUserCreated(ctx, result.data.id, event.payload.email);
105
+ }
106
+ return result;
107
+ },
108
+ { access: { roles: ["Admin"] } },
109
+ );
110
+
111
+ // Rollback via controlled failure: writes to the user table AND appends a
112
+ // domain event, then deliberately returns isSuccess:false. The dispatcher
113
+ // raises BatchRollback, the surrounding tx rolls back — so NEITHER the user
114
+ // row NOR the domain event survive. Proves the controlled-failure path.
115
+ r.writeHandler(
116
+ "user:create-rollback",
117
+ z.object({ email: z.email() }),
118
+ async (event, ctx) => {
119
+ const created = await userExecutor(ctx).create(event.payload, event.user, ctx.db);
120
+ if (created.isSuccess) {
121
+ await emitUserCreated(ctx, created.data.id, event.payload.email);
122
+ }
123
+ return writeFailure(new UnprocessableError("intentional_rollback"));
124
+ },
125
+ { access: { roles: ["Admin"] } },
126
+ );
127
+
128
+ // Rollback via uncaught throw: appends TWICE, then throws. Exercises a
129
+ // different dispatcher branch than isSuccess:false — the generic catch block
130
+ // that wraps BatchRollback. Proves that:
131
+ // (a) an uncaught error rolls the tx back just like a controlled failure,
132
+ // (b) multiple domain event rows from the same handler roll back together.
133
+ r.writeHandler(
134
+ "user:create-throw",
135
+ z.object({ email: z.email() }),
136
+ async (event, ctx) => {
137
+ const created = await userExecutor(ctx).create(event.payload, event.user, ctx.db);
138
+ if (!created.isSuccess) return created;
139
+ await emitUserCreated(ctx, created.data.id, event.payload.email);
140
+ await emitUserCreated(ctx, created.data.id, `${event.payload.email}.secondary`);
141
+ throw new Error("unexpected_handler_failure");
142
+ },
143
+ { access: { roles: ["Admin"] } },
144
+ );
145
+
146
+ r.writeHandler(
147
+ "user:update",
148
+ z.object({
149
+ id: z.uuid(),
150
+ version: z.number().optional(),
151
+ changes: z.record(z.string(), z.unknown()),
152
+ }),
153
+ async (event, ctx) => userExecutor(ctx).update(event.payload, event.user, ctx.db),
154
+ { access: { roles: ["Admin"] } },
155
+ );
156
+
157
+ r.writeHandler(
158
+ "user:delete",
159
+ z.object({ id: z.uuid() }),
160
+ async (event, ctx) => userExecutor(ctx).delete(event.payload, event.user, ctx.db),
161
+ { access: { roles: ["Admin"] } },
162
+ );
163
+
164
+ r.queryHandler(
165
+ "user:list",
166
+ z.object({
167
+ search: z.string().optional(),
168
+ limit: z.number().optional(),
169
+ sort: z.string().optional(),
170
+ sortDirection: z.enum(["asc", "desc"]).optional(),
171
+ }),
172
+ async (query, ctx) => userExecutor(ctx).list(query.payload, query.user, ctx.db),
173
+ { access: { openToAll: true } },
174
+ );
175
+
176
+ r.queryHandler(
177
+ "user:detail",
178
+ z.object({ id: z.uuid() }),
179
+ async (query, ctx) => userExecutor(ctx).detail(query.payload, query.user, ctx.db),
180
+ { access: { openToAll: true } },
181
+ );
182
+
183
+ r.entityHook("postSave", user, async (result) => {
184
+ featurePostSaveLog.push(result);
185
+ });
186
+
187
+ r.hook("validation", createHandler, (data) => {
188
+ if (data["email"] === "banned@evil.com") return [{ field: "email", error: "banned_domain" }];
189
+ return null;
190
+ });
191
+ });
192
+
193
+ // --- Stack + Users ---
194
+
195
+ let stack: TestStack;
196
+
197
+ const adminUser = TestUsers.admin;
198
+ const guestUser = createTestUser({ id: 2, roles: ["Guest"] });
199
+ const otherTenantAdmin = createTestUser({
200
+ id: 3,
201
+ tenantId: "00000000-0000-4000-8000-000000000002",
202
+ });
203
+
204
+ beforeAll(async () => {
205
+ stack = await setupTestStack({ features: [userFeature] });
206
+ await createEntityTable(stack.db, userEntity, "user");
207
+ });
208
+
209
+ afterAll(async () => {
210
+ await stack.cleanup();
211
+ });
212
+
213
+ beforeEach(async () => {
214
+ // Advance the event-dispatcher cursor past all events from earlier tests
215
+ // FIRST, then reset the in-memory collector. This keeps per-test SSE +
216
+ // pubsub assertions honest — otherwise the dispatcher would replay every
217
+ // prior test's events and inflate counts.
218
+ await stack.eventDispatcher?.runOnce();
219
+ stack.events.reset();
220
+ featurePostSaveLog.length = 0;
221
+ domainEventSubscriberCalls.length = 0;
222
+ });
223
+
224
+ // =============================================================================
225
+ // CRUD
226
+ // =============================================================================
227
+
228
+ describe("full stack: CRUD", () => {
229
+ test("create and read back", async () => {
230
+ const data = await stack.http.writeOk(
231
+ "users:write:user:create",
232
+ {
233
+ email: "marc@test.de",
234
+ firstName: "Marc",
235
+ lastName: "Test",
236
+ },
237
+ adminUser,
238
+ );
239
+ expect(data["isNew"]).toBe(true);
240
+
241
+ const detail = await stack.http.queryOk<Record<string, unknown>>(
242
+ "users:query:user:detail",
243
+ { id: data["id"] },
244
+ adminUser,
245
+ );
246
+ expect(detail["email"]).toBe("marc@test.de");
247
+ expect(detail["version"]).toBe(1);
248
+ });
249
+
250
+ test("soft delete removes from queries", async () => {
251
+ const created = await stack.http.writeOk(
252
+ "users:write:user:create",
253
+ {
254
+ email: "del@test.de",
255
+ },
256
+ adminUser,
257
+ );
258
+
259
+ const del = await stack.http.writeOk(
260
+ "users:write:user:delete",
261
+ {
262
+ id: created["id"],
263
+ },
264
+ adminUser,
265
+ );
266
+ expect(del).toBeDefined();
267
+
268
+ const detail = await stack.http.queryOk<null>(
269
+ "users:query:user:detail",
270
+ { id: created["id"] },
271
+ adminUser,
272
+ );
273
+ expect(detail).toBeNull();
274
+ });
275
+ });
276
+
277
+ // =============================================================================
278
+ // SaveContext
279
+ // =============================================================================
280
+
281
+ describe("full stack: SaveContext changes + previous", () => {
282
+ test("update returns exact changes and previous state", async () => {
283
+ const created = await stack.http.writeOk(
284
+ "users:write:user:create",
285
+ {
286
+ email: "ctx@test.de",
287
+ firstName: "Before",
288
+ lastName: "Keep",
289
+ },
290
+ adminUser,
291
+ );
292
+
293
+ const updated = await stack.http.writeOk<{
294
+ isNew: boolean;
295
+ changes: { firstName: string };
296
+ previous: { firstName: string; lastName: string };
297
+ data: { firstName: string };
298
+ }>(
299
+ "users:write:user:update",
300
+ {
301
+ id: created["id"],
302
+ changes: { firstName: "After" },
303
+ version: 1,
304
+ },
305
+ adminUser,
306
+ );
307
+
308
+ expect(updated.isNew).toBe(false);
309
+ expect(updated.changes).toEqual({ firstName: "After" });
310
+ expect(updated.previous.firstName).toBe("Before");
311
+ expect(updated.previous.lastName).toBe("Keep");
312
+ expect(updated.data.firstName).toBe("After");
313
+ });
314
+ });
315
+
316
+ // =============================================================================
317
+ // Optimistic Locking
318
+ // =============================================================================
319
+
320
+ describe("full stack: optimistic locking", () => {
321
+ test("stale version returns version_conflict", async () => {
322
+ const created = await stack.http.writeOk(
323
+ "users:write:user:create",
324
+ {
325
+ email: "lock@test.de",
326
+ },
327
+ adminUser,
328
+ );
329
+
330
+ await stack.http.writeOk(
331
+ "users:write:user:update",
332
+ {
333
+ id: created["id"],
334
+ version: 1,
335
+ changes: { firstName: "V2" },
336
+ },
337
+ adminUser,
338
+ );
339
+
340
+ const error = await stack.http.writeErr(
341
+ "users:write:user:update",
342
+ {
343
+ id: created["id"],
344
+ version: 1,
345
+ changes: { firstName: "Stale" },
346
+ },
347
+ adminUser,
348
+ );
349
+
350
+ expect(error.code).toBe("version_conflict");
351
+ });
352
+ });
353
+
354
+ // =============================================================================
355
+ // System Hooks ACTUALLY FIRE
356
+ // =============================================================================
357
+
358
+ describe("full stack: lifecycle pipeline — system hooks fire", () => {
359
+ test("feature postSave hook receives SaveContext", async () => {
360
+ await stack.http.writeOk(
361
+ "users:write:user:create",
362
+ {
363
+ email: "hook@test.de",
364
+ firstName: "Hooked",
365
+ },
366
+ adminUser,
367
+ );
368
+
369
+ expect(featurePostSaveLog).toHaveLength(1);
370
+ expect(featurePostSaveLog[0]?.data["email"]).toBe("hook@test.de");
371
+ expect(featurePostSaveLog[0]?.isNew).toBe(true);
372
+ });
373
+
374
+ test("SSE broadcast fires on create (via async event-dispatcher)", async () => {
375
+ await stack.http.writeOk(
376
+ "users:write:user:create",
377
+ {
378
+ email: "sse@test.de",
379
+ },
380
+ adminUser,
381
+ );
382
+
383
+ // SSE runs as an async subscriber on the event-dispatcher since D.3.
384
+ // Drain deterministically instead of sleeping.
385
+ await stack.eventDispatcher?.runOnce();
386
+
387
+ expect(stack.events.sse).toHaveLength(1);
388
+ // New shape: event.type directly (no "system:event:" wrapper).
389
+ expect(stack.events.sse[0]?.type).toBe("user.created");
390
+ expect(stack.events.sse[0]?.data["id"]).toBeDefined();
391
+ });
392
+
393
+ test("SSE broadcast fires on update (via async event-dispatcher)", async () => {
394
+ const created = await stack.http.writeOk(
395
+ "users:write:user:create",
396
+ {
397
+ email: "sse-upd@test.de",
398
+ },
399
+ adminUser,
400
+ );
401
+
402
+ // Drain the create event first, then reset + update.
403
+ await stack.eventDispatcher?.runOnce();
404
+ stack.events.reset();
405
+
406
+ await stack.http.writeOk(
407
+ "users:write:user:update",
408
+ {
409
+ id: created["id"],
410
+ changes: { firstName: "SSE" },
411
+ version: 1,
412
+ },
413
+ adminUser,
414
+ );
415
+ await stack.eventDispatcher?.runOnce();
416
+
417
+ const updateEvent = stack.events.sse.find((e) => e.type === "user.updated");
418
+ expect(updateEvent).toBeDefined();
419
+ // Shape carries the full event.payload (changes + previous) under data.payload.
420
+ const payload = updateEvent!.data["payload"] as { changes: { firstName: string } };
421
+ expect(payload.changes).toEqual({ firstName: "SSE" });
422
+ });
423
+
424
+ test("search index updated via async event-dispatcher after create", async () => {
425
+ await stack.http.writeOk(
426
+ "users:write:user:create",
427
+ {
428
+ email: "indexed@test.de",
429
+ firstName: "Indexed",
430
+ },
431
+ adminUser,
432
+ );
433
+ await stack.eventDispatcher?.runOnce();
434
+
435
+ const results = await stack.search.search("00000000-0000-4000-8000-000000000001", "indexed", {
436
+ filterType: "user",
437
+ });
438
+ expect(results.some((r) => r.entityType === "user")).toBe(true);
439
+ });
440
+ });
441
+
442
+ // =============================================================================
443
+ // Auth + Access + Validation + Tenant Isolation
444
+ // =============================================================================
445
+
446
+ describe("full stack: auth + access + validation", () => {
447
+ test("unauthenticated → 401", async () => {
448
+ const res = await stack.app.request("/api/write", {
449
+ method: "POST",
450
+ headers: { "Content-Type": "application/json" },
451
+ body: JSON.stringify({ type: "users:write:user:create", payload: { email: "x@x.de" } }),
452
+ });
453
+ expect(res.status).toBe(401);
454
+ });
455
+
456
+ test("guest → access denied", async () => {
457
+ const error = await stack.http.writeErr(
458
+ "users:write:user:create",
459
+ {
460
+ email: "guest@test.de",
461
+ },
462
+ guestUser,
463
+ );
464
+ expect(error.code).toBe("access_denied");
465
+ });
466
+
467
+ test("other tenant cannot see data", async () => {
468
+ const created = await stack.http.writeOk(
469
+ "users:write:user:create",
470
+ {
471
+ email: "secret@test.de",
472
+ },
473
+ adminUser,
474
+ );
475
+
476
+ const detail = await stack.http.queryOk<null>(
477
+ "users:query:user:detail",
478
+ { id: created["id"] },
479
+ otherTenantAdmin,
480
+ );
481
+ expect(detail).toBeNull();
482
+ });
483
+
484
+ test("validation hook rejects banned domain", async () => {
485
+ const error = await stack.http.writeErr(
486
+ "users:write:user:create",
487
+ {
488
+ email: "banned@evil.com",
489
+ },
490
+ adminUser,
491
+ );
492
+ expectErrorIncludes(error, "banned_domain");
493
+ });
494
+ });
495
+
496
+ // =============================================================================
497
+ // Search + Sort
498
+ // =============================================================================
499
+
500
+ describe("full stack: search + sort", () => {
501
+ test("search finds via SearchAdapter", async () => {
502
+ await stack.http.writeOk(
503
+ "users:write:user:create",
504
+ {
505
+ email: "findable@test.de",
506
+ firstName: "Findable",
507
+ },
508
+ adminUser,
509
+ );
510
+ // Search indexing is async (D.4) — drain before querying.
511
+ await stack.eventDispatcher?.runOnce();
512
+
513
+ const res = await stack.http.queryOk<{ rows: Record<string, unknown>[] }>(
514
+ "users:query:user:list",
515
+ { search: "findable" },
516
+ adminUser,
517
+ );
518
+ expect(res.rows.some((r) => r["email"] === "findable@test.de")).toBe(true);
519
+ });
520
+
521
+ test("sort by lastName ASC", async () => {
522
+ await stack.http.writeOk(
523
+ "users:write:user:create",
524
+ {
525
+ email: "sz@test.de",
526
+ lastName: "Zebra",
527
+ },
528
+ adminUser,
529
+ );
530
+ await stack.http.writeOk(
531
+ "users:write:user:create",
532
+ {
533
+ email: "sa@test.de",
534
+ lastName: "Alpha",
535
+ },
536
+ adminUser,
537
+ );
538
+
539
+ const res = await stack.http.queryOk<{ rows: Record<string, unknown>[] }>(
540
+ "users:query:user:list",
541
+ { sort: "lastName", sortDirection: "asc" },
542
+ adminUser,
543
+ );
544
+
545
+ const names = res.rows.map((r) => r["lastName"]).filter(Boolean);
546
+ const sorted = [...names].sort();
547
+ expect(names).toEqual(sorted);
548
+ });
549
+ });
550
+
551
+ // =============================================================================
552
+ // SSE Route + Health
553
+ // =============================================================================
554
+
555
+ describe("full stack: SSE route", () => {
556
+ test("requires auth", async () => {
557
+ expect((await stack.app.request("/api/sse")).status).toBe(401);
558
+ });
559
+
560
+ test("returns event stream", async () => {
561
+ const token = await stack.jwt.sign(adminUser);
562
+ const res = await stack.app.request("/api/sse", {
563
+ headers: { Authorization: `Bearer ${token}` },
564
+ });
565
+ expect(res.status).toBe(200);
566
+ expect(res.headers.get("content-type")).toContain("text/event-stream");
567
+ });
568
+ });
569
+
570
+ // =============================================================================
571
+ // Idempotency (Redis-backed, end-to-end)
572
+ // =============================================================================
573
+
574
+ describe("full stack: idempotency", () => {
575
+ test("duplicate requestId returns cached result, no double insert", async () => {
576
+ const requestId = "idem-fullstack-001";
577
+
578
+ const res1 = await stack.http.writeOk(
579
+ "users:write:user:create",
580
+ {
581
+ email: "idem@test.de",
582
+ },
583
+ adminUser,
584
+ requestId,
585
+ );
586
+ const firstId = res1["id"];
587
+
588
+ // Same requestId → should return cached result, NOT create a second user
589
+ const res2 = await stack.http.writeOk(
590
+ "users:write:user:create",
591
+ {
592
+ email: "idem@test.de",
593
+ },
594
+ adminUser,
595
+ requestId,
596
+ );
597
+ expect(res2["id"]).toBe(firstId);
598
+ });
599
+
600
+ test("different requestIds create separate records", async () => {
601
+ const res1 = await stack.http.writeOk(
602
+ "users:write:user:create",
603
+ {
604
+ email: "idem-a@test.de",
605
+ },
606
+ adminUser,
607
+ "idem-a",
608
+ );
609
+
610
+ const res2 = await stack.http.writeOk(
611
+ "users:write:user:create",
612
+ {
613
+ email: "idem-b@test.de",
614
+ },
615
+ adminUser,
616
+ "idem-b",
617
+ );
618
+
619
+ expect(res1["id"]).not.toBe(res2["id"]);
620
+ });
621
+ });
622
+
623
+ // =============================================================================
624
+ // Request Context (X-Request-ID)
625
+ // =============================================================================
626
+
627
+ describe("full stack: request context", () => {
628
+ test("response contains X-Request-ID header", async () => {
629
+ const token = await stack.jwt.sign(adminUser);
630
+ const res = await stack.app.request("/api/write", {
631
+ method: "POST",
632
+ headers: {
633
+ Authorization: `Bearer ${token}`,
634
+ "Content-Type": "application/json",
635
+ },
636
+ body: JSON.stringify({
637
+ type: "users:write:user:create",
638
+ payload: { email: "reqid@test.de" },
639
+ }),
640
+ });
641
+ expect(res.status).toBe(200);
642
+ expect(res.headers.get("X-Request-ID")).toBeDefined();
643
+ expect(res.headers.get("X-Request-ID")?.length).toBeGreaterThan(0);
644
+ });
645
+
646
+ test("echoes back client-provided X-Request-ID", async () => {
647
+ const token = await stack.jwt.sign(adminUser);
648
+ const res = await stack.app.request("/api/write", {
649
+ method: "POST",
650
+ headers: {
651
+ Authorization: `Bearer ${token}`,
652
+ "Content-Type": "application/json",
653
+ "X-Request-ID": "client-req-42",
654
+ },
655
+ body: JSON.stringify({
656
+ type: "users:write:user:create",
657
+ payload: { email: "echoid@test.de" },
658
+ }),
659
+ });
660
+ expect(res.headers.get("X-Request-ID")).toBe("client-req-42");
661
+ });
662
+
663
+ test("error responses include requestId", async () => {
664
+ const token = await stack.jwt.sign(guestUser);
665
+ const res = await stack.app.request("/api/write", {
666
+ method: "POST",
667
+ headers: {
668
+ Authorization: `Bearer ${token}`,
669
+ "Content-Type": "application/json",
670
+ "X-Request-ID": "err-req-99",
671
+ },
672
+ body: JSON.stringify({
673
+ type: "users:write:user:create",
674
+ payload: { email: "denied@test.de" },
675
+ }),
676
+ });
677
+ const body = (await res.json()) as { isSuccess: boolean; error: { requestId?: string } };
678
+ expect(body.isSuccess).toBe(false);
679
+ // requestId lives inside the serialized error body under the new contract
680
+ expect(body.error.requestId).toBe("err-req-99");
681
+ });
682
+ });
683
+
684
+ // =============================================================================
685
+ // Entity Cache
686
+ // =============================================================================
687
+
688
+ describe("full stack: entity cache", () => {
689
+ test("detail returns cached data after create (no second DB hit needed)", async () => {
690
+ const created = await stack.http.writeOk(
691
+ "users:write:user:create",
692
+ { email: "cached@test.de", firstName: "Cached" },
693
+ adminUser,
694
+ );
695
+
696
+ // Detail should return the same data (from cache or DB — both valid)
697
+ const detail = await stack.http.queryOk<Record<string, unknown>>(
698
+ "users:query:user:detail",
699
+ { id: created["id"] },
700
+ adminUser,
701
+ );
702
+ expect(detail["email"]).toBe("cached@test.de");
703
+ });
704
+
705
+ test("cache serves stale data until invalidated by update", async () => {
706
+ const created = await stack.http.writeOk(
707
+ "users:write:user:create",
708
+ { email: "stale@test.de", firstName: "Before" },
709
+ adminUser,
710
+ );
711
+ const id = created["id"] as string;
712
+
713
+ // First detail populates cache
714
+ await stack.http.queryOk("users:query:user:detail", { id }, adminUser);
715
+
716
+ // Raw DB update — bypasses cache invalidation
717
+ const { eq } = await import("drizzle-orm");
718
+ await stack.db
719
+ .update(userTable)
720
+ .set({ firstName: "RawDbChange" })
721
+ .where(eq(userTable["id"], id));
722
+
723
+ // Detail still returns cached (old) value
724
+ const stale = await stack.http.queryOk<Record<string, unknown>>(
725
+ "users:query:user:detail",
726
+ { id },
727
+ adminUser,
728
+ );
729
+ expect(stale["firstName"]).toBe("Before");
730
+
731
+ // Update via API — invalidates cache
732
+ await stack.http.writeOk(
733
+ "users:write:user:update",
734
+ { id, changes: { firstName: "AfterUpdate" }, version: 1 },
735
+ adminUser,
736
+ );
737
+
738
+ // Now detail returns fresh data
739
+ const fresh = await stack.http.queryOk<Record<string, unknown>>(
740
+ "users:query:user:detail",
741
+ { id },
742
+ adminUser,
743
+ );
744
+ expect(fresh["firstName"]).toBe("AfterUpdate");
745
+ });
746
+
747
+ test("delete invalidates cache", async () => {
748
+ const created = await stack.http.writeOk(
749
+ "users:write:user:create",
750
+ { email: "delcache@test.de" },
751
+ adminUser,
752
+ );
753
+ const id = created["id"] as string;
754
+
755
+ // Populate cache
756
+ await stack.http.queryOk("users:query:user:detail", { id }, adminUser);
757
+
758
+ // Delete via API
759
+ await stack.http.writeOk("users:write:user:delete", { id }, adminUser);
760
+
761
+ // Detail returns null (soft deleted + cache invalidated)
762
+ const gone = await stack.http.queryOk<null>("users:query:user:detail", { id }, adminUser);
763
+ expect(gone).toBeNull();
764
+ });
765
+ });
766
+
767
+ // =============================================================================
768
+ // Health
769
+ // =============================================================================
770
+
771
+ describe("full stack: health", () => {
772
+ test("GET /health", async () => {
773
+ expect(await (await stack.app.request("/health")).json()).toEqual({ status: "ok" });
774
+ });
775
+ });
776
+
777
+ // =============================================================================
778
+ // ctx.appendEvent — domain events on the user aggregate stream
779
+ // =============================================================================
780
+ //
781
+ // ctx.appendEvent writes the event onto the user aggregate's own stream (same
782
+ // aggregateId as the CRUD row). The event-dispatcher picks it up and delivers
783
+ // to r.multiStreamProjection consumers after commit. Proves the full
784
+ // TX-atomicity: user row + domain event + feature postSave + SSE + search —
785
+ // all commit-or-rollback together.
786
+
787
+ describe("full stack: ctx.appendEvent via event-dispatcher", () => {
788
+ // Filter the appended domain-event rows by type AND payload.email — the
789
+ // events table is shared across tests so we pick out just the ones this
790
+ // test appended.
791
+ async function domainEventsForEmail(email: string) {
792
+ const rows = await stack.db
793
+ .select()
794
+ .from(eventsTable)
795
+ .where(
796
+ // eq + and via a lazy dynamic import keeps the top-level imports lean.
797
+ (await import("drizzle-orm")).and(
798
+ (await import("drizzle-orm")).eq(eventsTable.aggregateType, "user"),
799
+ (await import("drizzle-orm")).eq(eventsTable.type, USER_CREATED_EVENT),
800
+ ),
801
+ );
802
+ return rows.filter((r) => (r.payload as { email?: string }).email === email);
803
+ }
804
+
805
+ test("commit path: user row, domain event, feature postSave, SSE, search, subscriber — all consistent", async () => {
806
+ const data = await stack.http.writeOk(
807
+ "users:write:user:create-and-signal",
808
+ { email: "emit-happy@test.de", firstName: "Happy", lastName: "Path" },
809
+ adminUser,
810
+ );
811
+
812
+ // Business row committed
813
+ expect(data["isNew"]).toBe(true);
814
+ const userId = data["id"] as string;
815
+
816
+ // Domain event row committed on the SAME aggregate stream as the CRUD event
817
+ const domainRows = await domainEventsForEmail("emit-happy@test.de");
818
+ expect(domainRows).toHaveLength(1);
819
+ expect(domainRows[0]).toMatchObject({
820
+ tenantId: adminUser.tenantId,
821
+ type: USER_CREATED_EVENT,
822
+ aggregateType: "user",
823
+ aggregateId: userId,
824
+ });
825
+ expect(domainRows[0]?.payload).toMatchObject({ id: userId, email: "emit-happy@test.de" });
826
+
827
+ // Feature postSave ran inline
828
+ expect(featurePostSaveLog).toHaveLength(1);
829
+ expect(featurePostSaveLog[0]).toMatchObject({ kind: "save", id: userId, isNew: true });
830
+
831
+ // System consumers + MSP subscriber fire on the next dispatcher pass
832
+ expect(domainEventSubscriberCalls).toHaveLength(0);
833
+ await stack.eventDispatcher?.runOnce();
834
+
835
+ // Search + SSE saw the user.created aggregate event
836
+ expect(stack.events.sse.some((e) => e.type === "user.created")).toBe(true);
837
+ const searchHits = await stack.search.search(adminUser.tenantId, "emit-happy");
838
+ expect(searchHits.map((h) => h.entityId)).toContain(userId);
839
+
840
+ // Subscriber saw the domain event
841
+ expect(domainEventSubscriberCalls).toHaveLength(1);
842
+ expect(domainEventSubscriberCalls[0]).toMatchObject({
843
+ type: USER_CREATED_EVENT,
844
+ payload: { id: userId, email: "emit-happy@test.de" },
845
+ });
846
+ });
847
+
848
+ test("rollback path: handler returns isSuccess:false after append+insert → no user, no event, no side-effects", async () => {
849
+ const res = await stack.http.write(
850
+ "users:write:user:create-rollback",
851
+ { email: "emit-rollback@test.de" },
852
+ adminUser,
853
+ );
854
+ const body = (await res.json()) as {
855
+ isSuccess: boolean;
856
+ error: { code: string; details: { reason: string } };
857
+ };
858
+ expect(body.isSuccess).toBe(false);
859
+ expect(body.error.code).toBe("unprocessable");
860
+ expect(body.error.details.reason).toBe("intentional_rollback");
861
+
862
+ // User table: the insert rolled back
863
+ const users = await stack.http.queryOk<{ rows: Array<Record<string, unknown>> }>(
864
+ "users:query:user:list",
865
+ { search: "emit-rollback" },
866
+ adminUser,
867
+ );
868
+ expect(users.rows.some((u) => u["email"] === "emit-rollback@test.de")).toBe(false);
869
+
870
+ // Domain event rolled back too — nothing in events table for this email
871
+ expect(await domainEventsForEmail("emit-rollback@test.de")).toHaveLength(0);
872
+
873
+ // Side-effects: feature postSave ran inline in the tx that rolled back →
874
+ // no entry. Dispatcher drains nothing (no committed event). Subscriber
875
+ // never saw the rolled-back append.
876
+ expect(featurePostSaveLog).toHaveLength(0);
877
+ await stack.eventDispatcher?.runOnce();
878
+ const searchHits = await stack.search.search(adminUser.tenantId, "emit-rollback");
879
+ expect(searchHits).toHaveLength(0);
880
+ expect(domainEventSubscriberCalls).toHaveLength(0);
881
+ });
882
+
883
+ test("uncaught throw + multi-append: both domain events roll back, error reported, no side-effects", async () => {
884
+ const res = await stack.http.write(
885
+ "users:write:user:create-throw",
886
+ { email: "emit-throw@test.de" },
887
+ adminUser,
888
+ );
889
+ const body = (await res.json()) as { isSuccess: boolean; error: unknown };
890
+ expect(body.isSuccess).toBe(false);
891
+ // Uncaught Error → auto-wrapped to InternalError.
892
+ expect((body.error as { code: string }).code).toBe("internal_error");
893
+
894
+ // User row rolled back
895
+ const users = await stack.http.queryOk<{ rows: Array<Record<string, unknown>> }>(
896
+ "users:query:user:list",
897
+ { search: "emit-throw" },
898
+ adminUser,
899
+ );
900
+ expect(users.rows.some((u) => u["email"] === "emit-throw@test.de")).toBe(false);
901
+
902
+ // BOTH domain event rows rolled back — multi-append in one tx is atomic.
903
+ // Primary + secondary email variants should both be absent.
904
+ expect(await domainEventsForEmail("emit-throw@test.de")).toHaveLength(0);
905
+ expect(await domainEventsForEmail("emit-throw@test.de.secondary")).toHaveLength(0);
906
+
907
+ // Subscribers + system consumers stayed idle
908
+ expect(featurePostSaveLog).toHaveLength(0);
909
+ await stack.eventDispatcher?.runOnce();
910
+ const searchHits = await stack.search.search(adminUser.tenantId, "emit-throw");
911
+ expect(searchHits).toHaveLength(0);
912
+ expect(domainEventSubscriberCalls).toHaveLength(0);
913
+ });
914
+ });