@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,80 @@
1
+ // auth-middleware: cookie + bearer + reject-both. Pure-logic unit tests
2
+ // against a hand-rolled Hono app — no DB, no dispatcher. Exercises the
3
+ // transport-extraction path that drives the csrf-middleware downstream.
4
+
5
+ import { Hono } from "hono";
6
+ import { describe, expect, test } from "vitest";
7
+ import { TestUsers } from "../../stack";
8
+ import { AUTH_COOKIE_NAME, authMiddleware, getAuthTransport, getUser } from "../auth-middleware";
9
+ import { createJwtHelper } from "../jwt";
10
+
11
+ const JWT_SECRET = "auth-middleware-transport-test-secret-min-32-chars";
12
+
13
+ async function buildApp(): Promise<{ app: Hono; token: string }> {
14
+ const jwt = createJwtHelper(JWT_SECRET);
15
+ const token = await jwt.sign(TestUsers.user);
16
+ const app = new Hono();
17
+ app.use("/api/*", authMiddleware(jwt));
18
+ app.get("/api/ping", (c) => {
19
+ const user = getUser(c);
20
+ const transport = getAuthTransport(c);
21
+ return c.json({ userId: user.id, transport });
22
+ });
23
+ return { app, token };
24
+ }
25
+
26
+ describe("auth-middleware transport selection", () => {
27
+ test("bearer header authenticates and sets transport=bearer", async () => {
28
+ const { app, token } = await buildApp();
29
+ const res = await app.request("/api/ping", {
30
+ headers: { Authorization: `Bearer ${token}` },
31
+ });
32
+ expect(res.status).toBe(200);
33
+ const body = (await res.json()) as { userId: string; transport: string };
34
+ expect(body.userId).toBe(TestUsers.user.id);
35
+ expect(body.transport).toBe("bearer");
36
+ });
37
+
38
+ test("auth cookie authenticates and sets transport=cookie", async () => {
39
+ const { app, token } = await buildApp();
40
+ const res = await app.request("/api/ping", {
41
+ headers: { Cookie: `${AUTH_COOKIE_NAME}=${token}` },
42
+ });
43
+ expect(res.status).toBe(200);
44
+ const body = (await res.json()) as { userId: string; transport: string };
45
+ expect(body.userId).toBe(TestUsers.user.id);
46
+ expect(body.transport).toBe("cookie");
47
+ });
48
+
49
+ test("both cookie AND bearer → 400 ambiguous_auth", async () => {
50
+ const { app, token } = await buildApp();
51
+ const res = await app.request("/api/ping", {
52
+ headers: {
53
+ Authorization: `Bearer ${token}`,
54
+ Cookie: `${AUTH_COOKIE_NAME}=${token}`,
55
+ },
56
+ });
57
+ expect(res.status).toBe(400);
58
+ const body = (await res.json()) as { error: { code: string; httpStatus: number } };
59
+ expect(body.error.code).toBe("ambiguous_auth");
60
+ expect(body.error.httpStatus).toBe(400);
61
+ });
62
+
63
+ test("neither cookie nor bearer → 401 missing_token", async () => {
64
+ const { app } = await buildApp();
65
+ const res = await app.request("/api/ping");
66
+ expect(res.status).toBe(401);
67
+ const body = (await res.json()) as { error: { code: string } };
68
+ expect(body.error.code).toBe("missing_token");
69
+ });
70
+
71
+ test("invalid cookie JWT → 401 invalid_token", async () => {
72
+ const { app } = await buildApp();
73
+ const res = await app.request("/api/ping", {
74
+ headers: { Cookie: `${AUTH_COOKIE_NAME}=not-a-real-jwt` },
75
+ });
76
+ expect(res.status).toBe(401);
77
+ const body = (await res.json()) as { error: { code: string } };
78
+ expect(body.error.code).toBe("invalid_token");
79
+ });
80
+ });
@@ -0,0 +1,179 @@
1
+ // auth-routes cookie behaviour — login sets cookies, logout clears them,
2
+ // switch-tenant rotates them, cookieSameSite controls the SameSite flag.
3
+ //
4
+ // Uses a stub Dispatcher so the tests exercise ONLY the HTTP-layer cookie
5
+ // logic — full-stack login via real loginHandler is covered by the
6
+ // auth-cookie sample integration test.
7
+
8
+ import type { Hono } from "hono";
9
+ import { Hono as HonoCtor } from "hono";
10
+ import { describe, expect, test } from "vitest";
11
+ import type { SessionUser } from "../../engine/types";
12
+ import type { BatchResult, Dispatcher, WriteResult } from "../../pipeline/dispatcher";
13
+ import { TestUsers } from "../../stack";
14
+ import { getSetCookieRaw, getSetCookies } from "../../testing/http-cookies";
15
+ import { PUBLIC_API_PATHS } from "../api-constants";
16
+ import { AUTH_COOKIE_NAME, authMiddleware, CSRF_COOKIE_NAME } from "../auth-middleware";
17
+ import { type AuthRoutesConfig, createAuthRoutes } from "../auth-routes";
18
+ import { createJwtHelper } from "../jwt";
19
+
20
+ const JWT_SECRET = "auth-routes-cookie-test-secret-min-32-characters";
21
+
22
+ function createStubDispatcher(overrides?: Partial<Dispatcher>): Dispatcher {
23
+ const base: Dispatcher = {
24
+ async write(): Promise<WriteResult> {
25
+ // Explicit `const: WriteResult` locks the `isSuccess: true` branch of
26
+ // the union without an `as`-cast (which widens + silences the
27
+ // compiler). Success shape has to satisfy `{ isSuccess: true; data }`.
28
+ const ok: WriteResult = {
29
+ isSuccess: true,
30
+ data: {
31
+ kind: "auth-session",
32
+ session: TestUsers.user,
33
+ },
34
+ };
35
+ return ok;
36
+ },
37
+ async query(): Promise<unknown> {
38
+ return [];
39
+ },
40
+ async command(): Promise<void> {},
41
+ async batch(): Promise<BatchResult> {
42
+ const ok: BatchResult = { isSuccess: true, results: [] };
43
+ return ok;
44
+ },
45
+ async resolveAuthClaims(): Promise<Record<string, unknown>> {
46
+ return {};
47
+ },
48
+ };
49
+ return { ...base, ...overrides };
50
+ }
51
+
52
+ async function buildApp(
53
+ overrides: Partial<AuthRoutesConfig> = {},
54
+ dispatcher: Dispatcher = createStubDispatcher(),
55
+ ): Promise<{ app: Hono; validToken: string }> {
56
+ const jwt = createJwtHelper(JWT_SECRET);
57
+ const validToken = await jwt.sign(TestUsers.user);
58
+ const config: AuthRoutesConfig = {
59
+ membershipQuery: "tenant:query:memberships",
60
+ loginHandler: "auth:write:login",
61
+ loginRateLimit: null, // don't need rate-limit interference in cookie tests
62
+ ...overrides,
63
+ };
64
+ const app = new HonoCtor();
65
+ const jwtGuard = authMiddleware(jwt);
66
+ app.use("/api/*", async (c, next) => {
67
+ if (PUBLIC_API_PATHS.has(c.req.path)) return next();
68
+ return jwtGuard(c, next);
69
+ });
70
+ app.route("/api", createAuthRoutes(dispatcher, jwt, config));
71
+ return { app, validToken };
72
+ }
73
+
74
+ describe("auth-routes cookie behaviour on /auth/login", () => {
75
+ test("login sets both kumiko_auth and kumiko_csrf cookies", async () => {
76
+ const { app } = await buildApp();
77
+ const res = await app.request("/api/auth/login", {
78
+ method: "POST",
79
+ headers: { "Content-Type": "application/json" },
80
+ body: JSON.stringify({ email: "a@b.c", password: "pw" }),
81
+ });
82
+ expect(res.status).toBe(200);
83
+ const cookies = getSetCookies(res);
84
+ expect(cookies.get(AUTH_COOKIE_NAME)).toBeDefined();
85
+ expect(cookies.get(CSRF_COOKIE_NAME)).toBeDefined();
86
+ });
87
+
88
+ test("cookieSameSite defaults to Lax", async () => {
89
+ const { app } = await buildApp();
90
+ const res = await app.request("/api/auth/login", {
91
+ method: "POST",
92
+ headers: { "Content-Type": "application/json" },
93
+ body: JSON.stringify({ email: "a@b.c", password: "pw" }),
94
+ });
95
+ expect(getSetCookieRaw(res, AUTH_COOKIE_NAME)).toMatch(/SameSite=Lax/i);
96
+ });
97
+
98
+ test("cookieSameSite: strict → SameSite=Strict flag", async () => {
99
+ const { app } = await buildApp({ cookieSameSite: "strict" });
100
+ const res = await app.request("/api/auth/login", {
101
+ method: "POST",
102
+ headers: { "Content-Type": "application/json" },
103
+ body: JSON.stringify({ email: "a@b.c", password: "pw" }),
104
+ });
105
+ expect(getSetCookieRaw(res, AUTH_COOKIE_NAME)).toMatch(/SameSite=Strict/i);
106
+ });
107
+
108
+ test("auth cookie is HttpOnly, csrf cookie is not", async () => {
109
+ const { app } = await buildApp();
110
+ const res = await app.request("/api/auth/login", {
111
+ method: "POST",
112
+ headers: { "Content-Type": "application/json" },
113
+ body: JSON.stringify({ email: "a@b.c", password: "pw" }),
114
+ });
115
+ expect(getSetCookieRaw(res, AUTH_COOKIE_NAME)).toMatch(/HttpOnly/i);
116
+ expect(getSetCookieRaw(res, CSRF_COOKIE_NAME)).not.toMatch(/HttpOnly/i);
117
+ });
118
+
119
+ test("login still returns token in body (for native bearer clients)", async () => {
120
+ const { app } = await buildApp();
121
+ const res = await app.request("/api/auth/login", {
122
+ method: "POST",
123
+ headers: { "Content-Type": "application/json" },
124
+ body: JSON.stringify({ email: "a@b.c", password: "pw" }),
125
+ });
126
+ const body = (await res.json()) as { isSuccess: boolean; token: string };
127
+ expect(body.isSuccess).toBe(true);
128
+ expect(typeof body.token).toBe("string");
129
+ expect(body.token.length).toBeGreaterThan(20);
130
+ });
131
+ });
132
+
133
+ describe("auth-routes cookie behaviour on /auth/logout", () => {
134
+ test("logout clears both cookies via Max-Age=0", async () => {
135
+ const { app, validToken } = await buildApp();
136
+ const res = await app.request("/api/auth/logout", {
137
+ method: "POST",
138
+ headers: { Authorization: `Bearer ${validToken}` },
139
+ });
140
+ expect(res.status).toBe(200);
141
+ expect(getSetCookieRaw(res, AUTH_COOKIE_NAME)).toMatch(/Max-Age=0/i);
142
+ expect(getSetCookieRaw(res, CSRF_COOKIE_NAME)).toMatch(/Max-Age=0/i);
143
+ });
144
+ });
145
+
146
+ describe("auth-routes cookie behaviour on /auth/switch-tenant", () => {
147
+ test("switch-tenant rotates both cookies", async () => {
148
+ const otherTenant = TestUsers.otherTenant;
149
+ const dispatcher = createStubDispatcher({
150
+ async query(type: string, _payload: unknown, _user: SessionUser): Promise<unknown> {
151
+ if (type === "tenant:query:memberships") {
152
+ return [
153
+ {
154
+ userId: TestUsers.user.id,
155
+ tenantId: otherTenant.tenantId,
156
+ roles: otherTenant.roles,
157
+ },
158
+ ];
159
+ }
160
+ return [];
161
+ },
162
+ });
163
+ const { app, validToken } = await buildApp({}, dispatcher);
164
+ const res = await app.request("/api/auth/switch-tenant", {
165
+ method: "POST",
166
+ headers: {
167
+ "Content-Type": "application/json",
168
+ Authorization: `Bearer ${validToken}`,
169
+ },
170
+ body: JSON.stringify({ tenantId: otherTenant.tenantId }),
171
+ });
172
+ expect(res.status).toBe(200);
173
+ const cookies = getSetCookies(res);
174
+ const newAuth = cookies.get(AUTH_COOKIE_NAME);
175
+ expect(newAuth).toBeDefined();
176
+ expect(cookies.get(CSRF_COOKIE_NAME)).toBeDefined();
177
+ expect(newAuth?.value).not.toBe(validToken); // new jwt
178
+ });
179
+ });
@@ -0,0 +1,404 @@
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 { buildDrizzleTable } from "../../db/table-builder";
5
+ import {
6
+ createEntity,
7
+ createNumberField,
8
+ createTextField,
9
+ defineFeature,
10
+ type EntityId,
11
+ HookPhases,
12
+ type SaveContext,
13
+ } from "../../engine";
14
+ import { UnprocessableError, writeFailure } from "../../errors";
15
+ import { createEntityTable, setupTestStack, type TestStack, TestUsers } from "../../stack";
16
+
17
+ // Entity: a simple "item" with name + counter
18
+ const itemEntity = createEntity({
19
+ table: "batch_items",
20
+ fields: {
21
+ name: createTextField({ required: true }),
22
+ counter: createNumberField({ default: 0 }),
23
+ },
24
+ });
25
+
26
+ const itemTable = buildDrizzleTable("item", itemEntity);
27
+
28
+ // Second entity used by an inTransaction hook to prove that hook DB writes
29
+ // roll back with the main transaction.
30
+ const auditEntity = createEntity({
31
+ table: "batch_audit",
32
+ fields: {
33
+ action: createTextField({ required: true }),
34
+ itemId: createTextField({ required: true }),
35
+ },
36
+ });
37
+ const auditTable = buildDrizzleTable("audit", auditEntity);
38
+
39
+ // Hook invocation logs — reset per test. Captures which phase each hook saw.
40
+ const inTxHookLog: Array<{ id: EntityId; name: string }> = [];
41
+ const afterCommitHookLog: Array<{ id: EntityId; name: string }> = [];
42
+
43
+ // Toggles for afterCommit fault-injection test
44
+ let afterCommitShouldThrow = false;
45
+ const afterCommitThirdHookRan: string[] = [];
46
+
47
+ const itemFeature = defineFeature("batch", (r) => {
48
+ const item = r.entity("item", itemEntity);
49
+
50
+ r.writeHandler(
51
+ "item:create",
52
+ z.object({ name: z.string().min(1), counter: z.number().optional() }),
53
+ async (event, ctx) => {
54
+ const crud = createEventStoreExecutor(itemTable, itemEntity, { entityName: "item" });
55
+ return crud.create(event.payload, event.user, ctx.db);
56
+ },
57
+ { access: { roles: ["Admin"] } },
58
+ );
59
+
60
+ // Handler that always fails validation — used to trigger rollback mid-batch
61
+ r.writeHandler(
62
+ "item:fail",
63
+ z.object({ name: z.string().min(1) }),
64
+ async () => writeFailure(new UnprocessableError("intentional_failure")),
65
+ { access: { roles: ["Admin"] } },
66
+ );
67
+
68
+ // Handler that always throws — used to verify unexpected throws surface as failures
69
+ r.writeHandler(
70
+ "item:throw",
71
+ z.object({ name: z.string().min(1) }),
72
+ async () => {
73
+ throw new Error("handler_crashed");
74
+ },
75
+ { access: { roles: ["Admin"] } },
76
+ );
77
+
78
+ // Entity hook: inTransaction — records in memory
79
+ r.entityHook(
80
+ "postSave",
81
+ item,
82
+ async (result: SaveContext) => {
83
+ inTxHookLog.push({ id: result.id, name: (result.data["name"] as string) ?? "" });
84
+ },
85
+ { phase: HookPhases.inTransaction },
86
+ );
87
+
88
+ // Entity hook: inTransaction — writes to DB via ctx.db (the tx-scoped TenantDb).
89
+ // Proves that hook DB writes roll back with the main transaction on failure.
90
+ r.entityHook(
91
+ "postSave",
92
+ item,
93
+ async (result, ctx) => {
94
+ if (!ctx.db) return;
95
+ await ctx.db
96
+ .insert(auditTable)
97
+ .values({ action: "item_saved", itemId: result.id })
98
+ .returning();
99
+ },
100
+ { phase: HookPhases.inTransaction },
101
+ );
102
+
103
+ // Entity hook: afterCommit — records in memory (default phase)
104
+ r.entityHook("postSave", item, async (result: SaveContext) => {
105
+ afterCommitHookLog.push({ id: result.id, name: (result.data["name"] as string) ?? "" });
106
+ });
107
+
108
+ // Entity hook: afterCommit — may throw, used to verify error isolation
109
+ r.entityHook("postSave", item, async () => {
110
+ if (afterCommitShouldThrow) throw new Error("afterCommit_boom");
111
+ });
112
+
113
+ // Entity hook: afterCommit — runs AFTER the throwing one. Used to prove the
114
+ // next hooks still fire despite the earlier failure.
115
+ r.entityHook("postSave", item, async (result: SaveContext) => {
116
+ afterCommitThirdHookRan.push((result.data["name"] as string) ?? "");
117
+ });
118
+
119
+ // Two hooks used by the parallelism test. Each records its start+end
120
+ // timestamps so the assertion can compare intervals rather than elapsed
121
+ // wall-clock time (which is timing-flaky on loaded CI boxes).
122
+ r.entityHook("postSave", item, async (result: SaveContext) => {
123
+ const name = result.data["name"] as string;
124
+ if (!name?.startsWith("slowness-")) return;
125
+ parallelismWindows.push({ hook: "A", start: Date.now() });
126
+ await new Promise((r) => setTimeout(r, 80));
127
+ parallelismWindows.push({ hook: "A", end: Date.now() });
128
+ });
129
+ r.entityHook("postSave", item, async (result: SaveContext) => {
130
+ const name = result.data["name"] as string;
131
+ if (!name?.startsWith("slowness-")) return;
132
+ parallelismWindows.push({ hook: "B", start: Date.now() });
133
+ await new Promise((r) => setTimeout(r, 80));
134
+ parallelismWindows.push({ hook: "B", end: Date.now() });
135
+ });
136
+ });
137
+
138
+ // Start + end timestamps recorded by the parallelism hooks above. A pair of
139
+ // hooks that ran truly in parallel will show B.start < A.end (and vice-versa),
140
+ // regardless of how long the whole request took overall.
141
+ //
142
+ // Module-level mutable state — safe here because Vitest runs tests inside a
143
+ // single file sequentially (the default). If someone flips vitest's
144
+ // `sequence.concurrent` on for this file, the test body would need its own
145
+ // window collector passed through ctx instead.
146
+ type ParallelismEvent = { hook: "A" | "B"; start?: number; end?: number };
147
+ const parallelismWindows: ParallelismEvent[] = [];
148
+
149
+ let stack: TestStack;
150
+ const admin = TestUsers.admin;
151
+
152
+ beforeAll(async () => {
153
+ stack = await setupTestStack({ features: [itemFeature] });
154
+ await createEntityTable(stack.db, itemEntity);
155
+ await createEntityTable(stack.db, auditEntity);
156
+ });
157
+
158
+ afterAll(async () => {
159
+ await stack.cleanup();
160
+ });
161
+
162
+ beforeEach(async () => {
163
+ inTxHookLog.length = 0;
164
+ afterCommitHookLog.length = 0;
165
+ afterCommitThirdHookRan.length = 0;
166
+ afterCommitShouldThrow = false;
167
+ parallelismWindows.length = 0;
168
+ stack.events.reset();
169
+ await stack.db.delete(itemTable);
170
+ await stack.db.delete(auditTable);
171
+ });
172
+
173
+ describe("POST /api/batch", () => {
174
+ test("empty commands array returns success with empty results", async () => {
175
+ const res = await stack.http.batch([], admin);
176
+ expect(res.status).toBe(200);
177
+ const body = await res.json();
178
+ expect(body.isSuccess).toBe(true);
179
+ expect(body.results).toEqual([]);
180
+ });
181
+
182
+ test("rejects non-array commands with 400", async () => {
183
+ const res = await stack.http.raw(
184
+ "POST",
185
+ "/api/batch",
186
+ // biome-ignore lint/suspicious/noExplicitAny: intentional bad body
187
+ { commands: "not-an-array" as any },
188
+ { Authorization: `Bearer ${await stack.jwt.sign(admin)}` },
189
+ );
190
+ expect(res.status).toBe(400);
191
+ });
192
+
193
+ test("all-succeed: writes persist, both phases fire per command", async () => {
194
+ const res = await stack.http.batch(
195
+ [
196
+ { type: "batch:write:item:create", payload: { name: "alpha" } },
197
+ { type: "batch:write:item:create", payload: { name: "beta" } },
198
+ { type: "batch:write:item:create", payload: { name: "gamma" } },
199
+ ],
200
+ admin,
201
+ );
202
+
203
+ const body = await res.json();
204
+ expect(res.status).toBe(200);
205
+ expect(body.isSuccess).toBe(true);
206
+ expect(body.results).toHaveLength(3);
207
+ for (const r of body.results) expect(r.isSuccess).toBe(true);
208
+
209
+ // Both phases fired once per command, same ids, same order
210
+ expect(inTxHookLog).toHaveLength(3);
211
+ expect(afterCommitHookLog).toHaveLength(3);
212
+ expect(inTxHookLog.map((h) => h.name)).toEqual(["alpha", "beta", "gamma"]);
213
+ expect(afterCommitHookLog.map((h) => h.name)).toEqual(["alpha", "beta", "gamma"]);
214
+
215
+ // Rows actually persisted
216
+ const rows = await stack.db.select().from(itemTable);
217
+ expect(rows).toHaveLength(3);
218
+ });
219
+
220
+ test("mid-batch failure: all writes roll back, afterCommit hooks do NOT fire", async () => {
221
+ // Seed with one existing item so we can verify the batch didn't persist anything
222
+ await stack.db
223
+ .insert(itemTable)
224
+ .values({ name: "seed", counter: 0, tenantId: "00000000-0000-4000-8000-000000000001" });
225
+ const seedCount = (await stack.db.select().from(itemTable)).length;
226
+
227
+ const res = await stack.http.batch(
228
+ [
229
+ { type: "batch:write:item:create", payload: { name: "will-rollback-1" } },
230
+ { type: "batch:write:item:fail", payload: { name: "fails" } },
231
+ { type: "batch:write:item:create", payload: { name: "never-runs" } },
232
+ ],
233
+ admin,
234
+ );
235
+
236
+ const body = await res.json();
237
+ // UnprocessableError → 422 (business-rule violation), which is the
238
+ // "expected failure" HTTP status. The batch envelope keeps `failedIndex`
239
+ // + `results` alongside the error payload so callers know which command
240
+ // tripped the rollback.
241
+ expect(res.status).toBe(422);
242
+ expect(body.isSuccess).toBe(false);
243
+ expect(body.failedIndex).toBe(1);
244
+ expect(body.error.code).toBe("unprocessable");
245
+ expect(body.error.details.reason).toBe("intentional_failure");
246
+
247
+ // inTransaction hook fired for the first successful command (then rolled back
248
+ // — but the hook log is in-memory, it persists)
249
+ expect(inTxHookLog.map((h) => h.name)).toEqual(["will-rollback-1"]);
250
+
251
+ // afterCommit hook must NOT have fired (transaction rolled back)
252
+ expect(afterCommitHookLog).toEqual([]);
253
+
254
+ // DB: only the seed row remains, the batch's first successful write rolled back
255
+ const rows = await stack.db.select().from(itemTable);
256
+ expect(rows).toHaveLength(seedCount);
257
+ expect((rows[0] as { name: string }).name).toBe("seed");
258
+ });
259
+
260
+ test("inTransaction hook DB writes roll back with the batch", async () => {
261
+ // Successful batch: audit rows should be written
262
+ const okRes = await stack.http.batch(
263
+ [{ type: "batch:write:item:create", payload: { name: "alpha" } }],
264
+ admin,
265
+ );
266
+ expect((await okRes.json()).isSuccess).toBe(true);
267
+ const auditAfterOk = await stack.db.select().from(auditTable);
268
+ expect(auditAfterOk).toHaveLength(1);
269
+ expect((auditAfterOk[0] as { action: string }).action).toBe("item_saved");
270
+
271
+ // Reset — new batch fails mid-way. Both entity rows AND audit rows must roll back.
272
+ await stack.db.delete(itemTable);
273
+ await stack.db.delete(auditTable);
274
+
275
+ const failRes = await stack.http.batch(
276
+ [
277
+ { type: "batch:write:item:create", payload: { name: "beta" } },
278
+ { type: "batch:write:item:fail", payload: { name: "stop" } },
279
+ ],
280
+ admin,
281
+ );
282
+ expect((await failRes.json()).isSuccess).toBe(false);
283
+
284
+ // Both tables are empty — the inTransaction audit hook's write rolled back
285
+ // together with the item row.
286
+ const itemsAfterFail = await stack.db.select().from(itemTable);
287
+ const auditAfterFail = await stack.db.select().from(auditTable);
288
+ expect(itemsAfterFail).toHaveLength(0);
289
+ expect(auditAfterFail).toHaveLength(0);
290
+ });
291
+
292
+ test("afterCommit hooks run in parallel (B starts before A finishes)", async () => {
293
+ const res = await stack.http.batch(
294
+ [{ type: "batch:write:item:create", payload: { name: "slowness-parallel" } }],
295
+ admin,
296
+ );
297
+ expect(res.status).toBe(200);
298
+
299
+ // Extract each hook's interval independently — checks overlap of
300
+ // intervals, not total elapsed time. Robust against CI noise.
301
+ const aStart = parallelismWindows.find((e) => e.hook === "A" && e.start !== undefined)?.start;
302
+ const aEnd = parallelismWindows.find((e) => e.hook === "A" && e.end !== undefined)?.end;
303
+ const bStart = parallelismWindows.find((e) => e.hook === "B" && e.start !== undefined)?.start;
304
+ const bEnd = parallelismWindows.find((e) => e.hook === "B" && e.end !== undefined)?.end;
305
+
306
+ expect(aStart).toBeDefined();
307
+ expect(aEnd).toBeDefined();
308
+ expect(bStart).toBeDefined();
309
+ expect(bEnd).toBeDefined();
310
+
311
+ // Parallel iff the two intervals overlap: one starts before the other
312
+ // ends. Sequential execution would produce disjoint intervals.
313
+ const overlap = (aStart as number) < (bEnd as number) && (bStart as number) < (aEnd as number);
314
+ expect(overlap).toBe(true);
315
+ });
316
+
317
+ test("afterCommit hook error is isolated: batch succeeds, other hooks still fire", async () => {
318
+ afterCommitShouldThrow = true;
319
+
320
+ const res = await stack.http.batch(
321
+ [{ type: "batch:write:item:create", payload: { name: "omega" } }],
322
+ admin,
323
+ );
324
+
325
+ // Batch is reported successful despite the afterCommit hook throwing
326
+ const body = await res.json();
327
+ expect(res.status).toBe(200);
328
+ expect(body.isSuccess).toBe(true);
329
+
330
+ // DB row persisted (tx committed)
331
+ const rows = await stack.db.select().from(itemTable);
332
+ expect(rows).toHaveLength(1);
333
+
334
+ // The hook AFTER the throwing one still ran — errors don't cascade
335
+ expect(afterCommitThirdHookRan).toEqual(["omega"]);
336
+ });
337
+
338
+ test("idempotency: repeated batch with same requestId returns cached result, no re-exec", async () => {
339
+ const requestId = "batch-rid-123";
340
+ const commands = [{ type: "batch:write:item:create", payload: { name: "once" } }];
341
+
342
+ const first = await stack.http.batch(commands, admin, requestId);
343
+ const firstBody = await first.json();
344
+ expect(firstBody.isSuccess).toBe(true);
345
+ expect(firstBody.results).toHaveLength(1);
346
+
347
+ const rowsAfterFirst = await stack.db.select().from(itemTable);
348
+ expect(rowsAfterFirst).toHaveLength(1);
349
+
350
+ // Hook logs reflect one execution
351
+ expect(inTxHookLog).toHaveLength(1);
352
+ expect(afterCommitHookLog).toHaveLength(1);
353
+
354
+ // Retry with the same requestId — same response, but commands did NOT run again
355
+ const second = await stack.http.batch(commands, admin, requestId);
356
+ const secondBody = await second.json();
357
+
358
+ expect(secondBody.isSuccess).toBe(true);
359
+ expect(secondBody.results).toEqual(firstBody.results);
360
+
361
+ // DB still has only one row (no double-insert)
362
+ const rowsAfterSecond = await stack.db.select().from(itemTable);
363
+ expect(rowsAfterSecond).toHaveLength(1);
364
+
365
+ // Hooks didn't fire a second time
366
+ expect(inTxHookLog).toHaveLength(1);
367
+ expect(afterCommitHookLog).toHaveLength(1);
368
+ });
369
+ });
370
+
371
+ describe("POST /api/write (single write runs in its own transaction)", () => {
372
+ test("inTransaction hook DB write persists with the entity write", async () => {
373
+ const res = await stack.http.write("batch:write:item:create", { name: "single" }, admin);
374
+ const body = await res.json();
375
+ expect(body.isSuccess).toBe(true);
376
+
377
+ // Both the item row AND the audit row exist — proves the single write
378
+ // went through a transaction and the inTx hook shared it.
379
+ const items = await stack.db.select().from(itemTable);
380
+ const audits = await stack.db.select().from(auditTable);
381
+ expect(items).toHaveLength(1);
382
+ expect(audits).toHaveLength(1);
383
+ });
384
+
385
+ test("handler throw rolls back inTransaction hook writes too", async () => {
386
+ // First a successful write so there's something to compare against
387
+ await stack.http.write("batch:write:item:create", { name: "survivor" }, admin);
388
+ const beforeItems = await stack.db.select().from(itemTable);
389
+ const beforeAudits = await stack.db.select().from(auditTable);
390
+ expect(beforeItems).toHaveLength(1);
391
+ expect(beforeAudits).toHaveLength(1);
392
+
393
+ // Now a write whose handler throws — nothing new should be committed
394
+ const res = await stack.http.write("batch:write:item:throw", { name: "crash" }, admin);
395
+ const body = await res.json();
396
+ expect(body.isSuccess).toBe(false);
397
+
398
+ const afterItems = await stack.db.select().from(itemTable);
399
+ const afterAudits = await stack.db.select().from(auditTable);
400
+ // Counts unchanged — no partial commit
401
+ expect(afterItems).toHaveLength(beforeItems.length);
402
+ expect(afterAudits).toHaveLength(beforeAudits.length);
403
+ });
404
+ });