@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,574 @@
1
+ import { type Job, Queue, Worker } from "bullmq";
2
+ import { Redis } from "ioredis";
3
+ import { requestContext } from "../api/request-context";
4
+ import type { DbRow } from "../db/connection";
5
+ import { createSystemUser } from "../engine/system-user";
6
+ import {
7
+ type AppContext,
8
+ type JobRunIn,
9
+ type Registry,
10
+ type SessionUser,
11
+ SYSTEM_TENANT_ID,
12
+ } from "../engine/types";
13
+ import type { Logger } from "../logging/types";
14
+ import { getFallbackTracer, type SerializedTraceContext, type Tracer } from "../observability";
15
+ import { createDistributedLock, type DistributedLock } from "../pipeline/distributed-lock";
16
+ import { RedisKeys } from "../pipeline/redis-keys";
17
+
18
+ // Queue-name convention: <prefix>-<lane>. The prefix is fixed in prod
19
+ // ("kumiko-jobs") — it must match between enqueuers and consumers, and an
20
+ // accidental drift would silently drop jobs. Tests override via
21
+ // `queueNamePrefix` for per-run isolation (stale jobs from a prior run
22
+ // don't leak into a new test because the queue name includes a timestamp).
23
+ const DEFAULT_QUEUE_NAME_PREFIX = "kumiko-jobs";
24
+ function queueNameFor(prefix: string, lane: JobRunIn): string {
25
+ return `${prefix}-${lane}`;
26
+ }
27
+
28
+ export type JobLogEntry = {
29
+ level: "info" | "warn" | "error";
30
+ message: string;
31
+ timestamp: Temporal.Instant;
32
+ };
33
+
34
+ function createJobLogger(logs: JobLogEntry[]): Logger {
35
+ function push(level: "info" | "warn" | "error", msg: string, data?: Record<string, unknown>) {
36
+ const message = data ? `${msg} ${JSON.stringify(data)}` : msg;
37
+ logs.push({ level, message, timestamp: Temporal.Now.instant() });
38
+ }
39
+ const logger: Logger = {
40
+ info(msg, data) {
41
+ push("info", msg, data);
42
+ },
43
+ warn(msg, data) {
44
+ push("warn", msg, data);
45
+ },
46
+ error(msg, data) {
47
+ push("error", msg, data);
48
+ },
49
+ debug() {},
50
+ child() {
51
+ return logger;
52
+ },
53
+ };
54
+ return logger;
55
+ }
56
+
57
+ export type JobMeta = {
58
+ triggeredById?: string | undefined;
59
+ payload?: string | undefined;
60
+ // BullMQ numbers retries from 1 upward; the logger threads this into
61
+ // the run-started event so audit queries can distinguish "fresh run" vs.
62
+ // "nth retry" without joining back to BullMQ-internals.
63
+ attempt?: number | undefined;
64
+ };
65
+
66
+ export type JobRunner = {
67
+ start(): Promise<void>;
68
+ stop(): Promise<void>;
69
+ dispatch(jobName: string, payload?: Record<string, unknown>, meta?: JobMeta): Promise<string>;
70
+ handleEvent(
71
+ eventName: string,
72
+ payload: Record<string, unknown>,
73
+ user?: SessionUser,
74
+ ): Promise<void>;
75
+ };
76
+
77
+ export type JobRunnerOptions = {
78
+ registry: Registry;
79
+ context: AppContext;
80
+ redisUrl: string;
81
+ // Which lane this runner CONSUMES — i.e. starts a BullMQ worker for and
82
+ // schedules cron/boot jobs on. Undefined = enqueuer-only: the runner
83
+ // still holds queue-clients for BOTH lanes so dispatch()/handleEvent()
84
+ // can enqueue jobs destined for either lane, but no BullMQ worker is
85
+ // started and no cron schedules fire. API processes that don't
86
+ // runLocalJobs leave this unset; worker processes set "worker"; api-
87
+ // processes with runLocalJobs set "api".
88
+ consumerLane?: JobRunIn | undefined;
89
+ // Override the queue-name prefix. Prod uses the default ("kumiko-jobs").
90
+ // Tests set a unique prefix (e.g. `"test-${Date.now()}"`) for isolation —
91
+ // two parallel test-runners never see each other's jobs.
92
+ queueNamePrefix?: string | undefined;
93
+ getActiveTenantIds?: () => Promise<number[]>;
94
+ onJobStart?: (jobName: string, jobId: string, meta: JobMeta) => void;
95
+ onJobComplete?: (jobName: string, jobId: string, duration: number, logs: JobLogEntry[]) => void;
96
+ onJobFailed?: (jobName: string, jobId: string, error: string, logs: JobLogEntry[]) => void;
97
+ };
98
+
99
+ // Serialized trace context lives under this key in the BullMQ job data.
100
+ // Leading underscore matches the existing internal-meta convention
101
+ // (_triggeredById, _tenantId, _payload).
102
+ const TRACE_CONTEXT_KEY = "_traceContext";
103
+
104
+ function readTraceContext(data: Record<string, unknown>): SerializedTraceContext | undefined {
105
+ const raw = data[TRACE_CONTEXT_KEY];
106
+ if (!raw || typeof raw !== "object") return undefined;
107
+ const ctx = raw as Partial<SerializedTraceContext>;
108
+ if (!ctx.traceId || !ctx.spanId) return undefined;
109
+ return { traceId: ctx.traceId, spanId: ctx.spanId };
110
+ }
111
+
112
+ function captureTraceContext(tracer: Tracer): SerializedTraceContext | undefined {
113
+ const span = tracer.getActiveSpan();
114
+ if (!span?.traceId) return undefined;
115
+ return { traceId: span.traceId, spanId: span.spanId };
116
+ }
117
+
118
+ function parseRedisOpts(url: string): { host: string; port: number; db?: number | undefined } {
119
+ const parsed = new URL(url);
120
+ const result: { host: string; port: number; db?: number | undefined } = {
121
+ host: parsed.hostname,
122
+ port: Number(parsed.port) || 6379,
123
+ };
124
+ if (parsed.pathname.length > 1) {
125
+ result.db = Number(parsed.pathname.slice(1));
126
+ }
127
+ return result;
128
+ }
129
+
130
+ export function createJobRunner(options: JobRunnerOptions): JobRunner {
131
+ const { registry, context, redisUrl, consumerLane } = options;
132
+ const queueNamePrefix = options.queueNamePrefix ?? DEFAULT_QUEUE_NAME_PREFIX;
133
+ const redisOpts = parseRedisOpts(redisUrl);
134
+ // Use the context's tracer when present (observability-provider injected at
135
+ // boot); otherwise noop so dispatch/handleJob stay zero-cost without config.
136
+ const tracer: Tracer = context.tracer ?? getFallbackTracer();
137
+
138
+ const allJobs = registry.getAllJobs();
139
+
140
+ // Resolve the lane for a job — "worker" is the default because that's the
141
+ // sensible prod lane (heavy async off the request path). Jobs that opted
142
+ // into "api" must have been validated at registry boot already.
143
+ function laneForJob(def: { readonly runIn?: JobRunIn | undefined }): JobRunIn {
144
+ return def.runIn ?? "worker";
145
+ }
146
+
147
+ // Sequential coordination: BullMQ OSS has no `group`, so we serialise
148
+ // same-name jobs ourselves with a per-name Redis lock. Only built when at
149
+ // least one job actually requested it — keeps the no-sequential boot path
150
+ // free of the extra Redis client. Scoped under the consumer lane so two
151
+ // runners on different lanes cannot collide on the same lock-key for
152
+ // unrelated jobs.
153
+ const hasSequential = [...allJobs.values()].some((def) => def.concurrency === "sequential");
154
+ let lockRedis: Redis | null = null;
155
+ let sequentialLock: DistributedLock | null = null;
156
+ if (hasSequential) {
157
+ lockRedis = new Redis(redisOpts);
158
+ const lockScope = consumerLane ?? "enqueue";
159
+ sequentialLock = createDistributedLock(lockRedis, `${RedisKeys.lock}seq:${lockScope}:`);
160
+ }
161
+ // Default lock-TTL for sequential jobs that didn't declare a timeout.
162
+ // 5 minutes matches BullMQ's default stalledInterval — long enough for
163
+ // any reasonable handler, short enough that a crashed worker recovers
164
+ // without manual intervention.
165
+ const SEQUENTIAL_DEFAULT_TTL_SEC = 305;
166
+ // How long to wait before re-trying a busy sequential lock. Short enough
167
+ // to feel responsive, long enough that we don't hammer Redis.
168
+ const SEQUENTIAL_RETRY_DELAY_MS = 200;
169
+
170
+ // Two queue-clients — one per lane. Every runner holds both, regardless of
171
+ // its own consumerLane, so dispatch()/handleEvent() always write to the
172
+ // queue matching the target job's runIn. Client-creation is cheap (shared
173
+ // ioredis connection via bullmq), so this doesn't scale with number of
174
+ // processes.
175
+ const queues: Readonly<Record<JobRunIn, Queue>> = {
176
+ api: new Queue(queueNameFor(queueNamePrefix, "api"), { connection: redisOpts }),
177
+ worker: new Queue(queueNameFor(queueNamePrefix, "worker"), { connection: redisOpts }),
178
+ };
179
+ let worker: Worker | null = null;
180
+
181
+ // Counts active + waiting jobs with this name for this tenant across
182
+ // BOTH lane queues. Jobs with the same name should only live in one
183
+ // lane (jobDef.runIn is static), but walking both is cheap and avoids
184
+ // a subtle bug if someone ever reassigns a job to a different lane
185
+ // between deploys while old queue contents are still draining.
186
+ async function isOverPerTenantLimit(
187
+ jobName: string,
188
+ tenantId: string,
189
+ max: number,
190
+ ): Promise<boolean> {
191
+ const results = await Promise.all([
192
+ queues.api.getActive(),
193
+ queues.api.getWaiting(),
194
+ queues.worker.getActive(),
195
+ queues.worker.getWaiting(),
196
+ ]);
197
+ let count = 0;
198
+ for (const list of results) {
199
+ for (const j of list) {
200
+ if (j.name !== jobName) continue;
201
+ const t = (j.data as { _tenantId?: string } | undefined)?._tenantId;
202
+ if (t === tenantId) {
203
+ count += 1;
204
+ if (count >= max) return true;
205
+ }
206
+ }
207
+ }
208
+ return false;
209
+ }
210
+
211
+ async function handleJob(bullJob: Job): Promise<void> {
212
+ const rawName = bullJob.name;
213
+
214
+ // Handle perTenant dispatch jobs — fan out to one job per tenant. The
215
+ // fan-out re-enqueues into the lane the actual job is assigned to;
216
+ // the _perTenant wrapper itself always lives in the consumer-lane
217
+ // (it's picked up by this runner's own worker).
218
+ if (rawName.startsWith("_perTenant:")) {
219
+ const actualName = rawName.slice("_perTenant:".length);
220
+ if (!options.getActiveTenantIds) {
221
+ throw new Error(`perTenant job "${actualName}" requires getActiveTenantIds option`);
222
+ }
223
+ const actualDef = allJobs.get(actualName);
224
+ if (!actualDef) {
225
+ throw new Error(`Unknown job: ${actualName}`);
226
+ }
227
+ const tenantIds = await options.getActiveTenantIds();
228
+ const targetQueue = queues[laneForJob(actualDef)];
229
+ for (const tenantId of tenantIds) {
230
+ await targetQueue.add(actualName, { ...bullJob.data, _tenantId: tenantId });
231
+ }
232
+ // skip: fan-out dispatcher job, per-tenant children enqueued
233
+ return;
234
+ }
235
+
236
+ const jobName = rawName;
237
+ const jobDef = allJobs.get(jobName);
238
+ if (!jobDef) {
239
+ throw new Error(`Unknown job: ${jobName}`);
240
+ }
241
+
242
+ // Sequential gate: try to claim the per-name lock. If another worker
243
+ // (or this worker on a different bullJob) holds it, re-enqueue with a
244
+ // small delay and exit *successfully* — using throw would burn the
245
+ // job's retry budget and pollute failure metrics, but a re-enqueue
246
+ // looks like an ordinary handoff to BullMQ.
247
+ let sequentialToken: string | null = null;
248
+ if (jobDef.concurrency === "sequential" && sequentialLock) {
249
+ const ttlSec = jobDef.timeout
250
+ ? Math.ceil(jobDef.timeout / 1000) + 5
251
+ : SEQUENTIAL_DEFAULT_TTL_SEC;
252
+ sequentialToken = await sequentialLock.acquire(jobName, { ttlSeconds: ttlSec });
253
+ if (!sequentialToken) {
254
+ // Re-enqueue onto the job's own lane-queue. In practice that's the
255
+ // same queue the worker just picked from (since only the consuming
256
+ // lane runs handleJob at all), but route explicitly — no implicit
257
+ // coupling to "whichever queue the caller happened to be on".
258
+ await queues[laneForJob(jobDef)].add(jobName, bullJob.data, {
259
+ delay: SEQUENTIAL_RETRY_DELAY_MS,
260
+ });
261
+ // skip: lock taken, work re-enqueued with delay, current invocation done
262
+ return;
263
+ }
264
+ }
265
+
266
+ const jobId = bullJob.id ?? "unknown";
267
+ const startTime = Date.now();
268
+ const logs: JobLogEntry[] = [];
269
+
270
+ // Extract meta from job data. `attempt` is BullMQ's own counter
271
+ // (1-based on the first run, incremented on each retry) — threading
272
+ // it through lets the logger tag the run-started event with the
273
+ // retry number, so audit queries distinguish fresh from retry runs
274
+ // without peeking at BullMQ internals.
275
+ const rawData = bullJob.data as DbRow;
276
+ const meta: JobMeta = {
277
+ triggeredById: rawData["_triggeredById"] as string | undefined,
278
+ payload: rawData["_payload"] as string | undefined,
279
+ attempt: bullJob.attemptsMade + 1,
280
+ };
281
+
282
+ // Build handler payload (without internal meta fields)
283
+ const payload: Record<string, unknown> = {};
284
+ for (const [k, v] of Object.entries(rawData)) {
285
+ if (!k.startsWith("_")) payload[k] = v;
286
+ }
287
+
288
+ // Determine tenantId and triggeredBy from meta
289
+ const tenantId =
290
+ (rawData["_tenantId"] as string | undefined) ??
291
+ (payload["tenantId"] as string | undefined) ??
292
+ SYSTEM_TENANT_ID;
293
+ const triggeredById = (rawData["_triggeredById"] as string | undefined) ?? null;
294
+
295
+ // _triggerName aus rawData übernehmen falls gesetzt — handleEvent
296
+ // packt das beim Multi-Trigger-Dispatch rein (siehe unten). Über
297
+ // jobContext.triggerName freigegeben damit der Handler nicht selbst
298
+ // im rohen Payload kramen muss.
299
+ const triggerName = rawData["_triggerName"] as string | undefined;
300
+ const jobContext: AppContext = {
301
+ ...context,
302
+ systemUser: createSystemUser(tenantId),
303
+ triggeredBy: triggeredById !== null ? { id: triggeredById, tenantId } : null,
304
+ log: createJobLogger(logs),
305
+ ...(triggerName !== undefined && { triggerName }),
306
+ };
307
+
308
+ await options.onJobStart?.(jobName, jobId, meta);
309
+
310
+ // Cross-process trace continuation: if the enqueuing code captured a
311
+ // parent span, start the job.execute span as its child. Works for event
312
+ // and manual triggers; cron jobs start a fresh root span.
313
+ const parentContext = readTraceContext(rawData);
314
+
315
+ // Correlation propagation: the scheduling request's correlationId was
316
+ // packed into _correlationId at dispatch time. Re-enter requestContext.run
317
+ // so event writes during this job stamp the same correlation as the
318
+ // request that scheduled it. Cron/boot jobs (no scheduler) start fresh
319
+ // — correlationId = new requestId, no parent causation.
320
+ const inheritedCorrelationId = (rawData["_correlationId"] as string | undefined) ?? undefined;
321
+ const jobRequestId = requestContext.generateId();
322
+ const jobCorrelationId = inheritedCorrelationId ?? jobRequestId;
323
+
324
+ const runInSpan = async (): Promise<void> => {
325
+ try {
326
+ await requestContext.run({ requestId: jobRequestId, correlationId: jobCorrelationId }, () =>
327
+ jobDef.handler(payload, jobContext),
328
+ );
329
+ const duration = Date.now() - startTime;
330
+ await options.onJobComplete?.(jobName, jobId, duration, logs);
331
+ } catch (err) {
332
+ const errorMsg = err instanceof Error ? err.message : String(err);
333
+ logs.push({ level: "error", message: errorMsg, timestamp: Temporal.Now.instant() });
334
+ await options.onJobFailed?.(jobName, jobId, errorMsg, logs);
335
+ throw err;
336
+ }
337
+ };
338
+
339
+ // Unified span creation: withSpan handles start/end + status/exception
340
+ // recording identically for both parent-context and no-parent paths.
341
+ // When parentContext is set, the new parent-aware StartSpanOptions
342
+ // plumbs it through to startSpan — no manual try/finally needed.
343
+ try {
344
+ await tracer.withSpan(
345
+ "job.execute",
346
+ {
347
+ attributes: {
348
+ "job.name": jobName,
349
+ "job.id": jobId,
350
+ "job.attempt": bullJob.attemptsMade + 1,
351
+ "kumiko.tenant_id": tenantId,
352
+ // Lane-routing attributes (Welle 2.6). `run_in` is the job's
353
+ // declared lane (explicit or default-"worker"); `consumer_lane`
354
+ // is which runner actually executed it. They diverge in
355
+ // all-in-one (both lanes live in one process) but must match
356
+ // in split deploys — a mismatch in prod logs signals a
357
+ // misrouted job that slipped past the boot-validator.
358
+ "kumiko.job.run_in": laneForJob(jobDef),
359
+ // Omit attribute entirely when no consumer (enqueuer-only runner) —
360
+ // SpanAttributeValue doesn't accept undefined.
361
+ ...(consumerLane !== undefined ? { "kumiko.job.consumer_lane": consumerLane } : {}),
362
+ },
363
+ ...(parentContext ? { parent: parentContext } : {}),
364
+ },
365
+ runInSpan,
366
+ );
367
+ } finally {
368
+ // Release the sequential lock value-matched (Lua compare-and-delete
369
+ // inside DistributedLock). A TTL-expired lock that's been claimed by
370
+ // a different owner stays put — releasing it would break sequencing
371
+ // for the new owner.
372
+ if (sequentialToken && sequentialLock) {
373
+ await sequentialLock.release(jobName, sequentialToken);
374
+ }
375
+ }
376
+ }
377
+
378
+ return {
379
+ async start(): Promise<void> {
380
+ // skip: enqueuer-only runner — no BullMQ worker, no cron schedules,
381
+ // no boot jobs. The API-process (runLocalJobs=false) lands here; it
382
+ // still holds the queue-clients so dispatch()/handleEvent() can
383
+ // target the worker-lane queue, but nothing local consumes.
384
+ if (!consumerLane) {
385
+ return;
386
+ }
387
+
388
+ const consumerQueue = queues[consumerLane];
389
+ worker = new Worker(queueNameFor(queueNamePrefix, consumerLane), handleJob, {
390
+ connection: redisOpts,
391
+ concurrency: 5,
392
+ });
393
+
394
+ // Only schedule cron + boot for jobs that belong to this lane. Jobs
395
+ // assigned to the other lane get their cron/boot wiring from the
396
+ // runner running on that lane. Running both here would double-fire.
397
+ for (const [name, jobDef] of allJobs) {
398
+ if (laneForJob(jobDef) !== consumerLane) continue;
399
+ if ("cron" in jobDef.trigger) {
400
+ await consumerQueue.upsertJobScheduler(
401
+ `scheduler-${name.replace(/\./g, "-")}`,
402
+ { pattern: jobDef.trigger.cron },
403
+ {
404
+ name: jobDef.perTenant ? `_perTenant:${name}` : name,
405
+ data: {},
406
+ },
407
+ );
408
+ }
409
+ }
410
+
411
+ for (const [name, jobDef] of allJobs) {
412
+ if (laneForJob(jobDef) !== consumerLane) continue;
413
+ if (jobDef.runOnBoot) {
414
+ const bootName = jobDef.perTenant ? `_perTenant:${name}` : name;
415
+ await consumerQueue.add(bootName, {}, { jobId: `boot-${name.replace(/\./g, "-")}` });
416
+ }
417
+ }
418
+ },
419
+
420
+ async stop(): Promise<void> {
421
+ if (worker) {
422
+ await worker.close();
423
+ worker = null;
424
+ }
425
+ await Promise.all([queues.api.close(), queues.worker.close()]);
426
+ if (lockRedis) {
427
+ // quit() drains in-flight commands; disconnect() would cancel them
428
+ // and risk a half-released lock.
429
+ await lockRedis.quit();
430
+ lockRedis = null;
431
+ }
432
+ },
433
+
434
+ async dispatch(
435
+ jobName: string,
436
+ payload?: Record<string, unknown>,
437
+ meta?: JobMeta,
438
+ ): Promise<string> {
439
+ const jobDef = allJobs.get(jobName);
440
+ if (!jobDef) {
441
+ throw new Error(`Unknown job: ${jobName}`);
442
+ }
443
+
444
+ // Route to the job's declared lane, not the runner's consumer lane —
445
+ // an api-runner is allowed to enqueue a worker-lane job and vice
446
+ // versa (that's the whole point of both queues being held).
447
+ const targetQueue = queues[laneForJob(jobDef)];
448
+
449
+ // perTenant: dispatch the fan-out wrapper instead
450
+ if (jobDef.perTenant) {
451
+ const job = await targetQueue.add(`_perTenant:${jobName}`, payload ?? {});
452
+ return job.id ?? "unknown";
453
+ }
454
+
455
+ // maxPerTenant guard: cap concurrent + waiting jobs of the same name
456
+ // for the same tenant. Orthogonal to the concurrency mode below — runs
457
+ // first because if we're over the limit nothing else matters.
458
+ // Requires a `_tenantId` in the payload to know which bucket to count
459
+ // against; without it the guard is inactive (system jobs, ambient
460
+ // dispatch). Fan-out children of perTenant jobs land here on their
461
+ // recursive queue.add and DO carry _tenantId.
462
+ if (jobDef.maxPerTenant !== undefined) {
463
+ const tenantId = (payload as { _tenantId?: string } | undefined)?._tenantId;
464
+ if (
465
+ tenantId !== undefined &&
466
+ (await isOverPerTenantLimit(jobName, tenantId, jobDef.maxPerTenant))
467
+ ) {
468
+ return "skipped:max-per-tenant";
469
+ }
470
+ }
471
+
472
+ const concurrency = jobDef.concurrency ?? "parallel";
473
+ const bullOpts: Record<string, unknown> = {};
474
+
475
+ switch (concurrency) {
476
+ case "skip": {
477
+ const active = await targetQueue.getActive();
478
+ const waiting = await targetQueue.getWaiting();
479
+ const isRunning = [...active, ...waiting].some((j) => j.name === jobName);
480
+ if (isRunning) {
481
+ return "skipped";
482
+ }
483
+ break;
484
+ }
485
+ case "replace": {
486
+ const waiting = await targetQueue.getWaiting();
487
+ for (const j of waiting) {
488
+ if (j.name === jobName && j.id) {
489
+ await j.remove();
490
+ }
491
+ }
492
+ break;
493
+ }
494
+ // case "sequential" is rejected at boot — see createJobRunner. Once
495
+ // the OSS-compatible implementation lands (per-name Redis lock),
496
+ // re-add the dispatch branch here.
497
+ case "debounce": {
498
+ const debounceMs = jobDef.debounceMs ?? 5000;
499
+ bullOpts["debounce"] = { id: jobName, ttl: debounceMs };
500
+ break;
501
+ }
502
+ default:
503
+ break;
504
+ }
505
+
506
+ if (jobDef.retries !== undefined) bullOpts["attempts"] = jobDef.retries + 1;
507
+ if (jobDef.backoff) bullOpts["backoff"] = { type: jobDef.backoff };
508
+ if (jobDef.timeout) bullOpts["timeout"] = jobDef.timeout;
509
+
510
+ // Pack meta into job data with _ prefix
511
+ const data: Record<string, unknown> = { ...payload };
512
+ if (meta?.triggeredById !== undefined) data["_triggeredById"] = meta.triggeredById;
513
+ if (meta?.payload !== undefined) data["_payload"] = meta.payload;
514
+ // Carry the enqueuing span context into the worker so job.execute shows
515
+ // as a child of the caller.
516
+ const traceCtx = captureTraceContext(tracer);
517
+ if (traceCtx) data[TRACE_CONTEXT_KEY] = traceCtx;
518
+ // Propagate correlation from the scheduling request into the job
519
+ // execution context. The worker re-enters requestContext.run with
520
+ // this value so ctx.appendEvent / executor writes during the job
521
+ // stamp the same correlation as the HTTP request that scheduled it.
522
+ const reqCtx = requestContext.get();
523
+ if (reqCtx?.correlationId) data["_correlationId"] = reqCtx.correlationId;
524
+
525
+ const job = await targetQueue.add(jobName, data, bullOpts);
526
+ return job.id ?? "unknown";
527
+ },
528
+
529
+ async handleEvent(
530
+ eventName: string,
531
+ payload: Record<string, unknown>,
532
+ user?: SessionUser,
533
+ ): Promise<void> {
534
+ const traceCtx = captureTraceContext(tracer);
535
+ // Same correlation propagation as dispatch(): events triggered from
536
+ // within a request (or an MSP-apply running under requestContext.run)
537
+ // get their correlationId into job data so the job execution keeps
538
+ // the same causal chain.
539
+ const reqCtx = requestContext.get();
540
+ for (const [name, jobDef] of allJobs) {
541
+ if (!("on" in jobDef.trigger)) continue;
542
+ // skip: andere Trigger-Formen (cron, manual) reagieren nicht auf
543
+ // Events. Nur "on"-Trigger werden hier matched.
544
+ const triggerOn = jobDef.trigger.on;
545
+ const matches = Array.isArray(triggerOn)
546
+ ? triggerOn.includes(eventName)
547
+ : triggerOn === eventName;
548
+ if (!matches) continue;
549
+ const data: Record<string, unknown> = { ...payload };
550
+ if (user) {
551
+ data["_tenantId"] = user.tenantId;
552
+ data["_triggeredById"] = user.id;
553
+ }
554
+ // Multi-Trigger: payload bekommt _triggerName damit der Handler
555
+ // weiß, welcher der N Trigger gefeuert hat. Bei Single-Trigger
556
+ // setzen wir es auch — kostet nichts und vereinfacht Handler-Code
557
+ // (kein "ist es Multi?"-Branch nötig).
558
+ data["_triggerName"] = eventName;
559
+ if (traceCtx) data[TRACE_CONTEXT_KEY] = traceCtx;
560
+ if (reqCtx?.correlationId) data["_correlationId"] = reqCtx.correlationId;
561
+ // Same maxPerTenant guard as dispatch — events that fan into many
562
+ // jobs must respect the per-tenant cap or the limit is one-sided.
563
+ if (jobDef.maxPerTenant !== undefined && user?.tenantId !== undefined) {
564
+ if (await isOverPerTenantLimit(name, String(user.tenantId), jobDef.maxPerTenant)) {
565
+ continue;
566
+ }
567
+ }
568
+ // Route to the job's declared lane, not a fixed queue — that's
569
+ // the whole reason both queues are held.
570
+ await queues[laneForJob(jobDef)].add(name, data);
571
+ }
572
+ },
573
+ };
574
+ }
@@ -0,0 +1,19 @@
1
+ // Test helper: builds a minimal Lifecycle-shaped stub with selectively
2
+ // overridable methods. Lets unit tests focus on the one method under test
3
+ // without repeating an 8-field boilerplate that breaks silently when the
4
+ // Lifecycle interface grows.
5
+
6
+ import type { Lifecycle } from "../lifecycle";
7
+
8
+ export function createTestLifecycle(overrides: Partial<Lifecycle> = {}): Lifecycle {
9
+ const defaults: Lifecycle = {
10
+ state: () => "ready",
11
+ uptimeSec: () => 0,
12
+ markReady: () => {},
13
+ onStateChange: () => () => {},
14
+ registerShutdownHook: () => {},
15
+ hookNames: () => [],
16
+ drain: async () => {},
17
+ };
18
+ return { ...defaults, ...overrides };
19
+ }