@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,166 @@
1
+ // F8 — pg-unique-violation auf entity-level-Indices wird sauber zu
2
+ // einer 409 UniqueViolationError gemapped, NICHT zu einer 500
3
+ // InternalError.
4
+ //
5
+ // Der event-store-Layer hatte das schon (Sprint 4d Patch:
6
+ // EventStoreVersionConflict-catch im executor.create/update). Aber
7
+ // app-level unique-Indices auf der Projection-Tabelle (z.B. (tenantId,
8
+ // email) auf User-Entity) liefen ohne mapping durch — krachten als
9
+ // pg-23505 InternalError. F8 schließt diese Lücke.
10
+ //
11
+ // **Test-Setup:** ein User-style entity mit composite-unique-Index
12
+ // (tenantId, email). Aggregate-id ist auto-generated UUID, kollidiert
13
+ // also nicht. Erst die projection-INSERT verletzt den Index. Ohne F8:
14
+ // 500. Mit F8: writeFailure(UniqueViolationError) → 409.
15
+
16
+ import { sql } from "drizzle-orm";
17
+ import { afterAll, beforeAll, beforeEach, describe, expect, test } from "vitest";
18
+ import { createEntity, createTextField } from "../../engine";
19
+ import { createEventsTable } from "../../event-store";
20
+ import { createEntityTable, createTestDb, type TestDb, TestUsers } from "../../stack";
21
+ import { createEventStoreExecutor } from "../event-store-executor";
22
+ import { buildDrizzleTable } from "../table-builder";
23
+ import { createTenantDb, type TenantDb } from "../tenant-db";
24
+
25
+ const userEntity = createEntity({
26
+ table: "read_unique_users",
27
+ fields: {
28
+ email: createTextField({ required: true }),
29
+ displayName: createTextField({ required: true }),
30
+ },
31
+ // softDelete=true damit wir den restore-Pfad pinnen können (siehe
32
+ // restore-Test unten — "kein 23505 möglich" claim).
33
+ softDelete: true,
34
+ // Composite-unique auf (tenantId, email) — typisches User-Pattern.
35
+ // Der unique-Index lebt auf der Projection, NICHT auf der events-
36
+ // Tabelle. Daher fängt der existing event-store-23505-catch (Sprint
37
+ // 4d) das nicht; das ist der Pfad den F8 abdeckt.
38
+ indexes: [
39
+ { columns: ["tenantId", "email"], unique: true, name: "read_unique_users_tenant_email_uniq" },
40
+ ],
41
+ });
42
+ const table = buildDrizzleTable("unique-user", userEntity);
43
+ const exec = createEventStoreExecutor(table, userEntity, { entityName: "unique-user" });
44
+
45
+ let testDb: TestDb;
46
+ let tdb: TenantDb;
47
+ const admin = TestUsers.admin;
48
+
49
+ beforeAll(async () => {
50
+ testDb = await createTestDb();
51
+ await createEntityTable(testDb.db, userEntity, "unique-user");
52
+ await createEventsTable(testDb.db);
53
+ tdb = createTenantDb(testDb.db, admin.tenantId);
54
+ });
55
+
56
+ afterAll(async () => {
57
+ await testDb.cleanup();
58
+ });
59
+
60
+ beforeEach(async () => {
61
+ await testDb.db.execute(sql`TRUNCATE kumiko_events, read_unique_users RESTART IDENTITY CASCADE`);
62
+ });
63
+
64
+ // =============================================================================
65
+ // create — duplicate email → 409 unique_violation
66
+ // =============================================================================
67
+
68
+ describe("F8 — entity-level unique-violation auf create", () => {
69
+ test("zweiter create mit selber email → unique_violation 409 (nicht internal_error 500)", async () => {
70
+ const first = await exec.create(
71
+ { email: "alice@example.com", displayName: "Alice 1" },
72
+ admin,
73
+ tdb,
74
+ );
75
+ expect(first.isSuccess).toBe(true);
76
+
77
+ const second = await exec.create(
78
+ { email: "alice@example.com", displayName: "Alice 2" },
79
+ admin,
80
+ tdb,
81
+ );
82
+ expect(second.isSuccess).toBe(false);
83
+ if (second.isSuccess) return;
84
+ expect(second.error.code).toBe("unique_violation");
85
+ expect(second.error.httpStatus).toBe(409);
86
+ // constraintName aus dem PG-error durchgereicht — App-Code kann
87
+ // damit auf den richtigen field-name mappen.
88
+ const details = second.error.details as { constraintName?: string; entityName?: string };
89
+ expect(details.entityName).toBe("unique-user");
90
+ expect(details.constraintName).toBe("read_unique_users_tenant_email_uniq");
91
+ });
92
+
93
+ test("DB-Beweis: nach 23505-conflict ist nur die erste Row in der Projection", async () => {
94
+ await exec.create({ email: "bob@example.com", displayName: "Bob 1" }, admin, tdb);
95
+ const second = await exec.create(
96
+ { email: "bob@example.com", displayName: "Bob 2" },
97
+ admin,
98
+ tdb,
99
+ );
100
+ expect(second.isSuccess).toBe(false);
101
+ const rows = await testDb.db.select().from(table);
102
+ expect(rows).toHaveLength(1);
103
+ expect((rows[0] as { displayName: string }).displayName).toBe("Bob 1");
104
+ });
105
+ });
106
+
107
+ // =============================================================================
108
+ // update — change email to existing value → 409 unique_violation
109
+ // =============================================================================
110
+
111
+ describe("F8 — entity-level unique-violation auf update", () => {
112
+ test("update auf existing email-value → unique_violation 409", async () => {
113
+ const alice = await exec.create(
114
+ { email: "alice@example.com", displayName: "Alice" },
115
+ admin,
116
+ tdb,
117
+ );
118
+ const bob = await exec.create({ email: "bob@example.com", displayName: "Bob" }, admin, tdb);
119
+ if (!alice.isSuccess || !bob.isSuccess) throw new Error("create failed in setup");
120
+
121
+ // Bob versucht Alice's email zu nehmen → kollidiert mit dem
122
+ // existing alice-row. Vor F8 wäre das ein internal_error 500
123
+ // gewesen.
124
+ const conflict = await exec.update(
125
+ { id: bob.data.id, version: 1, changes: { email: "alice@example.com" } },
126
+ admin,
127
+ tdb,
128
+ );
129
+ expect(conflict.isSuccess).toBe(false);
130
+ if (conflict.isSuccess) return;
131
+ expect(conflict.error.code).toBe("unique_violation");
132
+ expect(conflict.error.httpStatus).toBe(409);
133
+ });
134
+ });
135
+
136
+ // =============================================================================
137
+ // restore — kein try-catch nötig (drift-pin: dokumentiert die Annahme)
138
+ // =============================================================================
139
+
140
+ describe("F8 — restore touch'd nur isDeleted, kein 23505-Pfad", () => {
141
+ test("restore einer soft-gedeleteten row mit unique-field läuft konfliktfrei durch", async () => {
142
+ // Audit-Annahme (advisor-Punkt verifiziert): restore mutiert nur
143
+ // isDeleted=false, kein unique-field-Touch. Der unique-Index ist
144
+ // global (kein partial-WHERE-NOT-isDeleted in framework's table-
145
+ // builder), also würde EIN paralleler create mit derselben email
146
+ // schon am unique-Index scheitern (F8-create-Pfad), bevor er den
147
+ // soft-deleted restore-Pfad konfliktfrei machen könnte.
148
+ //
149
+ // Dieser Test pinnt: restore allein wirft kein 23505. Wenn jemand
150
+ // morgen einen Pfad einbaut der restore mit field-changes
151
+ // kombiniert, fällt's hier auf — der Test war "soll konfliktfrei
152
+ // sein", die Annahme wird laut.
153
+ const alice = await exec.create(
154
+ { email: "carol@example.com", displayName: "Carol" },
155
+ admin,
156
+ tdb,
157
+ );
158
+ if (!alice.isSuccess) throw new Error("create failed in setup");
159
+
160
+ const deleted = await exec.delete({ id: alice.data.id }, admin, tdb);
161
+ expect(deleted.isSuccess).toBe(true);
162
+
163
+ const restored = await exec.restore({ id: alice.data.id }, admin, tdb);
164
+ expect(restored.isSuccess).toBe(true);
165
+ });
166
+ });
@@ -0,0 +1,188 @@
1
+ // applyEntityEvent — die EINZIGE Schreib-Logik für r.entity-Tabellen aus
2
+ // Stored-Events. Beide Aufrufer benutzen sie:
3
+ //
4
+ // - createEventStoreExecutor (live, im Write-TX) — übergibt ein
5
+ // "live event" mit unstripped flatData/flatChanges als payload damit
6
+ // sensitive Felder in der Read-Tabelle landen, das Event-Log selbst
7
+ // bleibt aber stripped (siehe append-Site im Executor).
8
+ // - rebuildProjection via ImplicitProjection (replay, im Rebuild-TX) —
9
+ // übergibt das StoredEvent direkt; payload ist dort ohne sensitive,
10
+ // was bei Rebuild akzeptiert wird (sensitive-Drift durch GDPR-Strip
11
+ // ist als load-bearing Backlog-Item gepinnt — siehe
12
+ // docs/plans/architecture/migrations.md Sektion "Backlog (Welle 3+)"
13
+ // → "Sensitive-Field-Persistenz im Rebuild" für Optionen a/b/c).
14
+ //
15
+ // Live==Rebuild-Equivalence ist damit by-construction für alle Felder
16
+ // die NICHT als sensitive markiert sind — eine geänderte Schreib-Logik
17
+ // muss nur an EINER Stelle gepflegt werden, kein Sync-Contract mehr.
18
+ // Der load-bearing Test bleibt für non-sensitive-Drift in
19
+ // db/__tests__/implicit-projection-equivalence.integration.ts.
20
+ //
21
+ // Tenant-Isolation: applyEntityEvent erwartet einen rohen DbRunner (TX
22
+ // oder pool), KEINEN TenantDb-Wrapper. Schutz kommt aus zwei Quellen:
23
+ // 1. Live-Pfad ruft VOR der Schreibung loadById (tenant-scoped) für
24
+ // update/delete/restore — die aggregateId ist also schon tenant-
25
+ // validiert bevor wir hier ankommen.
26
+ // 2. Bei create wird tenantId explizit aus event.tenantId gesetzt, also
27
+ // nie über den TenantDb-Wrapper-Default abgeleitet.
28
+ // Damit ist der TenantDb-Wrapper-Loss in dieser Funktion funktional ohne
29
+ // Sicherheitslücke.
30
+ //
31
+ // Auto-Verben:
32
+ // <entity>.created → INSERT
33
+ // <entity>.updated → UPDATE WHERE id=aggregateId
34
+ // <entity>.deleted → soft-delete-UPDATE wenn entity.softDelete, sonst hard-DELETE
35
+ // <entity>.restored → undelete-UPDATE (nur bei softDelete sinnvoll)
36
+ //
37
+ // Domain-Events (r.defineEvent) auf demselben Aggregate werden hier NICHT
38
+ // behandelt — die liefen im Live-Pfad nie durch den Executor und müssen
39
+ // von expliziten r.projection-apply-Handlern oder r.multiStreamProjection
40
+ // behandelt werden. ImplicitProjection registriert daher nur die 4
41
+ // Auto-Verben.
42
+ //
43
+ // Return-Shape: ApplyResult mit `kind` + optionaler `row`.
44
+ // - "applied" → Schreibung lief durch. `row` enthält die geschriebene
45
+ // Row für create/update/soft-delete/restore. Bei hard-delete ist
46
+ // `row` null (DELETE-Statements geben keine returning-Row her).
47
+ // - "skipped" → Event ist kein Auto-Verb (Domain-Event auf demselben
48
+ // Aggregate). Caller no-op.
49
+
50
+ import { eq } from "drizzle-orm";
51
+ import type { EntityDefinition } from "../engine/types";
52
+ import { InternalError } from "../errors";
53
+ import type { StoredEvent } from "../event-store";
54
+ import type { DbRow, DbRunner } from "./connection";
55
+ import type { TableColumns } from "./dialect";
56
+
57
+ // biome-ignore lint/suspicious/noExplicitAny: Drizzle-Tabellen sind generisch typed; framework code erasiert die Spalten-Union absichtlich.
58
+ type Table = TableColumns<any>;
59
+
60
+ export type AutoVerb = "created" | "updated" | "deleted" | "restored";
61
+
62
+ export type ApplyResult =
63
+ | { readonly kind: "applied"; readonly verb: AutoVerb; readonly row: DbRow | null }
64
+ | { readonly kind: "skipped" };
65
+
66
+ /** Parsed event.type → AutoVerb wenn das Event eines der 4 Auto-Verben
67
+ * auf dem gegebenen Aggregate ist. null sonst (Domain-Event). */
68
+ export function parseAutoVerb(event: StoredEvent): AutoVerb | null {
69
+ const prefix = `${event.aggregateType}.`;
70
+ if (!event.type.startsWith(prefix)) return null;
71
+ const verb = event.type.slice(prefix.length);
72
+ if (verb === "created" || verb === "updated" || verb === "deleted" || verb === "restored") {
73
+ return verb;
74
+ }
75
+ return null;
76
+ }
77
+
78
+ /** Idempotente Anwendung eines Auto-Events auf die Entity-Tabelle.
79
+ * Wird sowohl beim Live-Append (innerhalb der Write-TX) als auch beim
80
+ * Rebuild (innerhalb der Rebuild-TX) gerufen — identische Logik. */
81
+ export async function applyEntityEvent(
82
+ event: StoredEvent,
83
+ table: Table,
84
+ entity: EntityDefinition,
85
+ tx: DbRunner,
86
+ ): Promise<ApplyResult> {
87
+ const verb = parseAutoVerb(event);
88
+ if (verb === null) return { kind: "skipped" };
89
+ const softDelete = entity.softDelete ?? false;
90
+
91
+ switch (verb) {
92
+ case "created": {
93
+ // tenantId-Resolution explizit, nicht via Spread-Reihenfolge:
94
+ // Live-Pfad nutzt tx=db.raw (kein TenantDb-Wrapper-Auto-Inject),
95
+ // beim Replay erst recht keiner. Default = event.tenantId; payload
96
+ // gewinnt NUR wenn gültig string mit length > 0 (seedTenantMembership-
97
+ // Pfad: Operator schreibt im Ziel-Tenant, Event im Operator-Tenant).
98
+ // Pinst durch db/__tests__/apply-entity-event-tenant.integration.ts.
99
+ //
100
+ // Fail-loud wenn payload.tenantId gesetzt aber invalid (leer/null/
101
+ // non-string): das ist tenant-isolation-kritisch — silent fallback
102
+ // auf event.tenantId würde eine Bug-payload in den Operator-Tenant
103
+ // schreiben statt zu failen, was Cross-Tenant-Datendrift erzeugt.
104
+ const payloadTenantId = event.payload["tenantId"];
105
+ let tenantId: string;
106
+ if (payloadTenantId === undefined) {
107
+ tenantId = event.tenantId;
108
+ } else if (typeof payloadTenantId === "string" && payloadTenantId.length > 0) {
109
+ tenantId = payloadTenantId;
110
+ } else {
111
+ throw new InternalError({
112
+ message: `applyEntityEvent: payload.tenantId set but invalid (${JSON.stringify(payloadTenantId)}). Tenant-isolation-kritisch: silent fallback auf event.tenantId würde Cross-Tenant-Drift erzeugen.`,
113
+ });
114
+ }
115
+ const [row] = await tx
116
+ .insert(table)
117
+ .values({
118
+ ...event.payload,
119
+ tenantId,
120
+ id: event.aggregateId,
121
+ version: event.version,
122
+ insertedAt: event.createdAt,
123
+ insertedById: event.createdBy,
124
+ })
125
+ .returning();
126
+ return { kind: "applied", verb, row: (row as DbRow | undefined) ?? null };
127
+ }
128
+
129
+ case "updated": {
130
+ // payload-Shape: { changes, previous } — siehe event-store-executor.ts.
131
+ const changes = (event.payload["changes"] ?? {}) as Record<string, unknown>; // @cast-boundary engine-payload
132
+ const [row] = await tx
133
+ .update(table)
134
+ .set({
135
+ ...changes,
136
+ version: event.version,
137
+ modifiedAt: event.createdAt,
138
+ modifiedById: event.createdBy,
139
+ })
140
+ .where(eq(table["id"], event.aggregateId))
141
+ .returning();
142
+ return { kind: "applied", verb, row: (row as DbRow | undefined) ?? null };
143
+ }
144
+
145
+ case "deleted": {
146
+ if (softDelete) {
147
+ const [row] = await tx
148
+ .update(table)
149
+ .set({
150
+ isDeleted: true,
151
+ deletedAt: event.createdAt,
152
+ deletedById: event.createdBy,
153
+ version: event.version,
154
+ modifiedAt: event.createdAt,
155
+ modifiedById: event.createdBy,
156
+ })
157
+ .where(eq(table["id"], event.aggregateId))
158
+ .returning();
159
+ return { kind: "applied", verb, row: (row as DbRow | undefined) ?? null };
160
+ }
161
+ // Hard-Delete: DELETE-Statement gibt keine returning-Row her und
162
+ // der Live-Pfad nutzt eh `existing` (pre-delete-Snapshot) für die
163
+ // Response. Beim Replay ist das fine, der Caller braucht die Row
164
+ // nicht weiter.
165
+ await tx.delete(table).where(eq(table["id"], event.aggregateId));
166
+ return { kind: "applied", verb, row: null };
167
+ }
168
+
169
+ case "restored": {
170
+ // Restore ist nur bei softDelete sinnvoll. Hard-Delete-Entities sollten
171
+ // keine restored-Events erhalten — falls doch, defensive skip.
172
+ if (!softDelete) return { kind: "skipped" };
173
+ const [row] = await tx
174
+ .update(table)
175
+ .set({
176
+ isDeleted: false,
177
+ deletedAt: null,
178
+ deletedById: null,
179
+ version: event.version,
180
+ modifiedAt: event.createdAt,
181
+ modifiedById: event.createdBy,
182
+ })
183
+ .where(eq(table["id"], event.aggregateId))
184
+ .returning();
185
+ return { kind: "applied", verb, row: (row as DbRow | undefined) ?? null };
186
+ }
187
+ }
188
+ }
@@ -0,0 +1,59 @@
1
+ import type { TenantId } from "@cosmicdrift/kumiko-framework/engine";
2
+ import { and, eq, type SQL } from "drizzle-orm";
3
+ import { NotFoundError } from "../errors";
4
+ import type { DbConnection } from "./connection";
5
+ import type { TenantDb } from "./tenant-db";
6
+
7
+ /**
8
+ * Generic constraint helper: asserts a value exists in a table.
9
+ * Returns a ready-to-return NotFoundError when the row is missing, or null
10
+ * when it exists. Callers typically use it with writeFailure:
11
+ *
12
+ * const missing = await assertExistsIn(db, orderTable, { field: "id", value: id });
13
+ * if (missing) return writeFailure(missing);
14
+ *
15
+ * Accepts both DbConnection and TenantDb. When using TenantDb, the automatic
16
+ * tenant filter is applied. Use tenantId option for explicit tenant filtering
17
+ * on raw DbConnection.
18
+ */
19
+ export async function assertExistsIn(
20
+ db: DbConnection | TenantDb,
21
+ // biome-ignore lint/suspicious/noExplicitAny: Drizzle table types are dynamic
22
+ entity: any,
23
+ options: {
24
+ field: string;
25
+ value: unknown;
26
+ tenantId?: TenantId;
27
+ where?: Record<string, unknown>;
28
+ entityName?: string;
29
+ },
30
+ ): Promise<NotFoundError | null> {
31
+ const conditions = [eq(entity[options.field], options.value)];
32
+
33
+ if (options.tenantId !== undefined) {
34
+ conditions.push(eq(entity["tenantId"], options.tenantId));
35
+ }
36
+
37
+ if (options.where) {
38
+ for (const [key, val] of Object.entries(options.where)) {
39
+ conditions.push(eq(entity[key], val));
40
+ }
41
+ }
42
+
43
+ const [row] = await db
44
+ .select()
45
+ .from(entity)
46
+ .where(and(...conditions) as SQL);
47
+
48
+ if (!row) {
49
+ const entityName = options.entityName ?? String(options.field).replace(/Id$/, "");
50
+ return new NotFoundError(
51
+ entityName,
52
+ typeof options.value === "number" || typeof options.value === "string"
53
+ ? options.value
54
+ : undefined,
55
+ );
56
+ }
57
+
58
+ return null;
59
+ }
@@ -0,0 +1,47 @@
1
+ // Compound-Type Pipeline für den Executor.
2
+ //
3
+ // Ein Compound-Type ist ein Field das aus EINEM API-Object und MEHREREN
4
+ // DB-Spalten besteht. Beispiele:
5
+ // - locatedTimestamp: { at, tz, utc } ↔ <name>Utc + <name>Tz
6
+ // - money: { amount, currency } ↔ <name> + <name>Currency
7
+ // - (kommt) address: { street, zip, city } ↔ <name>Street + <name>Zip + <name>City
8
+ //
9
+ // Statt jeden Helper im Executor an 4 Stellen verschachtelt aufzurufen,
10
+ // pipeline-iert diese Funktion alle Compound-Type-Konvertierungen in
11
+ // einem Pass. Beim Hinzufügen eines neuen Compound-Types nur EINE Stelle
12
+ // erweitern (das Array hier), nicht alle Executor-Aufrufe.
13
+
14
+ import type { EntityDefinition } from "../engine/types";
15
+ import { flattenLocatedTimestamp, rehydrateLocatedTimestamp } from "./located-timestamp";
16
+ import { flattenMoney, rehydrateMoney } from "./money";
17
+
18
+ type Converter = (
19
+ payload: Record<string, unknown>,
20
+ entity: EntityDefinition,
21
+ ) => Record<string, unknown>;
22
+
23
+ // Reihenfolge ist egal solange die Konverter sich nicht gegenseitig
24
+ // überlappen (z.B. money darf nicht ein Feld berühren das locatedTimestamp
25
+ // schon erzeugt hat). Aktuell überlappen sie nicht — types sind disjunkt.
26
+ const FLATTENERS: readonly Converter[] = [flattenLocatedTimestamp, flattenMoney];
27
+ const REHYDRATORS: readonly Converter[] = [rehydrateLocatedTimestamp, rehydrateMoney];
28
+
29
+ /**
30
+ * API-Form (combined) → DB-Form (flat). Wird vor jedem Insert/Update aufgerufen.
31
+ */
32
+ export function flattenCompoundTypes(
33
+ payload: Record<string, unknown>,
34
+ entity: EntityDefinition,
35
+ ): Record<string, unknown> {
36
+ return FLATTENERS.reduce((acc, fn) => fn(acc, entity), payload);
37
+ }
38
+
39
+ /**
40
+ * DB-Form (flat) → API-Form (combined). Wird nach jedem Read aufgerufen.
41
+ */
42
+ export function rehydrateCompoundTypes(
43
+ row: Record<string, unknown>,
44
+ entity: EntityDefinition,
45
+ ): Record<string, unknown> {
46
+ return REHYDRATORS.reduce((acc, fn) => fn(acc, entity), row);
47
+ }
@@ -0,0 +1,104 @@
1
+ import { drizzle } from "drizzle-orm/postgres-js";
2
+ import postgres from "postgres";
3
+ import { readPositiveIntEnv } from "../utils/env-parse";
4
+
5
+ export type DbConnection = ReturnType<typeof drizzle>;
6
+
7
+ // Drizzle's transaction callback receives a tx handle with the same query API
8
+ // as the top-level DbConnection. Extracted via Parameters so we stay in sync
9
+ // with whatever Drizzle defines without hard-coding the internal type name.
10
+ export type DbTx = Parameters<Parameters<DbConnection["transaction"]>[0]>[0];
11
+
12
+ // Code paths that operate on either a connection or an active transaction
13
+ // (e.g. TenantDb, dispatcher pipeline) accept both.
14
+ export type DbRunner = DbConnection | DbTx;
15
+
16
+ // Dynamic Drizzle tables (buildDrizzleTable with `any` column schema) lose
17
+ // their per-column types at the Drizzle boundary. Query results come back as
18
+ // arbitrary records. `DbRow` marks those typing-loss sites so readers see the
19
+ // limitation without re-spelling `Record<string, unknown>` at every callsite.
20
+ // Use `DbRow` for rows read via dynamic tables; a concrete entity-row type
21
+ // is preferred whenever the table is statically typed.
22
+ export type DbRow = Record<string, unknown>;
23
+
24
+ // The raw postgres.js client. Exposed alongside the Drizzle wrapper so the
25
+ // event-dispatcher (or other components that need LISTEN / pg-specific
26
+ // features Drizzle doesn't surface) can subscribe without re-opening a
27
+ // connection from the URL.
28
+ export type PgClient = ReturnType<typeof postgres>;
29
+
30
+ // Connection-pool options — thin wrapper around the postgres.js fields the
31
+ // framework explicitly supports. Omitted keys fall back to postgres.js
32
+ // defaults (max=10, idle_timeout=PGIDLE_TIMEOUT env, connect_timeout=
33
+ // PGCONNECT_TIMEOUT env). See `docs/plans/architecture/scaling.md` for
34
+ // sizing guidance per deployment shape.
35
+ export type DbConnectionOptions = {
36
+ // Max concurrent connections in the pool. postgres.js defaults to 10 —
37
+ // fine for a single app process against a small DB. Multi-worker or
38
+ // high-concurrency API deploys should scale this with `num_workers *
39
+ // per-request-concurrency` and stay below the DB's own max_connections
40
+ // (typical managed postgres: 100–400).
41
+ readonly maxConnections?: number;
42
+ // Seconds before an idle connection is closed. Null/undefined → keep
43
+ // connections warm forever (postgres.js default when the env var is
44
+ // unset). Managed pgBouncer tiers usually want this explicitly set to
45
+ // something like 30–60 so a single burst doesn't hold connections
46
+ // indefinitely.
47
+ readonly idleTimeoutSeconds?: number;
48
+ // Seconds to wait while establishing a new connection. Fails the query
49
+ // with a timeout error rather than hanging indefinitely when the DB is
50
+ // unreachable — critical for `/health/ready` to actually flip to 503
51
+ // within its 2s probe budget.
52
+ readonly connectTimeoutSeconds?: number;
53
+ };
54
+
55
+ export function createDbConnection(
56
+ url: string,
57
+ options: DbConnectionOptions = {},
58
+ ): {
59
+ db: DbConnection;
60
+ client: PgClient;
61
+ close: () => Promise<void>;
62
+ } {
63
+ // Only forward fields the caller set — empty object otherwise preserves
64
+ // postgres.js's env-var-driven defaults (PGIDLE_TIMEOUT / PGCONNECT_TIMEOUT).
65
+ const pgOptions: Parameters<typeof postgres>[1] = {};
66
+ if (options.maxConnections !== undefined) pgOptions.max = options.maxConnections;
67
+ if (options.idleTimeoutSeconds !== undefined) pgOptions.idle_timeout = options.idleTimeoutSeconds;
68
+ if (options.connectTimeoutSeconds !== undefined) {
69
+ pgOptions.connect_timeout = options.connectTimeoutSeconds;
70
+ }
71
+
72
+ const client = postgres(url, pgOptions);
73
+ const db = drizzle(client);
74
+
75
+ return {
76
+ db,
77
+ client,
78
+ close: async () => {
79
+ await client.end();
80
+ },
81
+ };
82
+ }
83
+
84
+ // Parse the supported env vars into a DbConnectionOptions object. Useful
85
+ // for a main.ts that wants to read DATABASE_POOL_MAX / DATABASE_POOL_
86
+ // IDLE_TIMEOUT / DATABASE_POOL_CONNECT_TIMEOUT without re-implementing
87
+ // the number-coercion + validation. Unrecognised / non-numeric values
88
+ // throw — misconfig surfaces at boot, not mid-request.
89
+ export function dbConnectionOptionsFromEnv(
90
+ env: Readonly<Record<string, string | undefined>> = process.env,
91
+ ): DbConnectionOptions {
92
+ const opts: DbConnectionOptions & {
93
+ maxConnections?: number;
94
+ idleTimeoutSeconds?: number;
95
+ connectTimeoutSeconds?: number;
96
+ } = {};
97
+ const max = readPositiveIntEnv(env, "DATABASE_POOL_MAX");
98
+ const idle = readPositiveIntEnv(env, "DATABASE_POOL_IDLE_TIMEOUT");
99
+ const connect = readPositiveIntEnv(env, "DATABASE_POOL_CONNECT_TIMEOUT");
100
+ if (max !== undefined) opts.maxConnections = max;
101
+ if (idle !== undefined) opts.idleTimeoutSeconds = idle;
102
+ if (connect !== undefined) opts.connectTimeoutSeconds = connect;
103
+ return opts;
104
+ }
@@ -0,0 +1,83 @@
1
+ import type { EntityId, TenantId } from "@cosmicdrift/kumiko-framework/engine";
2
+ import { and, asc, desc, eq, gt, inArray, type SQL, sql } from "drizzle-orm";
3
+ import type { SelectQuery as PgSelect } from "./dialect";
4
+
5
+ export type CursorQueryOptions = {
6
+ tenantId: TenantId;
7
+ cursor?: string;
8
+ limit?: number;
9
+ filterIds?: readonly EntityId[];
10
+ sort?: string;
11
+ sortDirection?: "asc" | "desc";
12
+ extraWhere?: SQL;
13
+ };
14
+
15
+ export type CursorResult<T> = {
16
+ rows: T[];
17
+ nextCursor: string | null;
18
+ /** Optional total row count — nur present wenn der Caller `totalCount: true`
19
+ * in der Query setzt. Pager-UI braucht's für "Page X of Y"; Infinite-
20
+ * Scroll und Default-Lists lassen den extra COUNT(*) weg. */
21
+ total?: number;
22
+ };
23
+
24
+ // String-basiert damit sowohl UUIDs (Default seit Sprint F) als auch
25
+ // Integer-Auto-Increment-IDs (Legacy/Spezialfälle) durch denselben
26
+ // Cursor-Pfad laufen. Stable-Sort-Voraussetzung: die id-Spalte muss
27
+ // lexikografisch monoton zur Insertion-Order sein. UUIDv7 erfüllt das
28
+ // (time-ordered Prefix); UUIDv4 nicht — wer den nutzt, kriegt
29
+ // inkorrekte cursor-Reihenfolge, das ist erwartet (Default ist v7).
30
+ export function encodeCursor(id: string | number): string {
31
+ return Buffer.from(String(id)).toString("base64url");
32
+ }
33
+
34
+ export function decodeCursor(cursor: string): string {
35
+ const decoded = Buffer.from(cursor, "base64url").toString();
36
+ if (decoded === "") throw new Error(`Invalid cursor: ${cursor}`);
37
+ return decoded;
38
+ }
39
+
40
+ export function applyCursorQuery<T extends PgSelect>(
41
+ query: T,
42
+ // biome-ignore lint/suspicious/noExplicitAny: Drizzle dynamic tables lose column types
43
+ table: any,
44
+ options: CursorQueryOptions,
45
+ ): T {
46
+ const conditions: SQL[] = [eq(table.tenantId, options.tenantId)];
47
+
48
+ if (table.isDeleted) {
49
+ conditions.push(eq(table.isDeleted, false));
50
+ }
51
+
52
+ if (options.cursor) {
53
+ conditions.push(gt(table.id, decodeCursor(options.cursor)));
54
+ }
55
+
56
+ if (options.filterIds !== undefined) {
57
+ if (options.filterIds.length === 0) {
58
+ // No matching IDs — return empty result via raw `false`. Statisch
59
+ // false ist type-agnostisch (int-PK / uuid-PK egal); ein eq(id, "")
60
+ // oder eq(id, -1) würde je nach Spalten-Type einen Cast-Error
61
+ // werfen.
62
+ conditions.push(sql`false`);
63
+ } else {
64
+ conditions.push(inArray(table.id, options.filterIds as readonly string[]));
65
+ }
66
+ }
67
+
68
+ if (options.extraWhere) {
69
+ conditions.push(options.extraWhere);
70
+ }
71
+
72
+ const limit = options.limit ?? 50;
73
+
74
+ let result = query.where(and(...conditions)).limit(limit);
75
+
76
+ if (options.sort && table[options.sort]) {
77
+ const column = table[options.sort];
78
+ result =
79
+ options.sortDirection === "desc" ? result.orderBy(desc(column)) : result.orderBy(asc(column));
80
+ }
81
+
82
+ return result as T;
83
+ }