@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,566 @@
1
+ import { afterAll, beforeAll, describe, expect, test } from "vitest";
2
+ import { requestContext } from "../../api/request-context";
3
+ import { createRegistry, defineFeature } from "../../engine";
4
+ import type { AppContext, Registry } from "../../engine/types";
5
+ import { createTestRedis, type TestRedis } from "../../stack";
6
+ import { sleep, waitFor } from "../../testing";
7
+ import { createJobRunner, type JobRunner } from "../job-runner";
8
+
9
+ // --- Shared state ---
10
+
11
+ let testRedis: TestRedis;
12
+ let redisUrl: string;
13
+
14
+ // Track which jobs ran and when
15
+ const jobLog: Array<{ name: string; payload: Record<string, unknown>; timestamp: number }> = [];
16
+
17
+ function clearLog() {
18
+ jobLog.length = 0;
19
+ }
20
+
21
+ // --- Feature with test jobs ---
22
+
23
+ const testFeature = defineFeature("test", (r) => {
24
+ // Scenario 1: Boot job
25
+ r.job("bootSync", { trigger: { manual: true }, runOnBoot: true }, async (payload) => {
26
+ jobLog.push({ name: "test:job:boot-sync", payload, timestamp: Date.now() });
27
+ });
28
+
29
+ // Scenario 2: Scheduled job (cron every second for testing)
30
+ r.job("scheduled", { trigger: { cron: "* * * * * *" } }, async (payload) => {
31
+ jobLog.push({ name: "test:job:scheduled", payload, timestamp: Date.now() });
32
+ });
33
+
34
+ // Scenario 3: Manual trigger
35
+ r.job("manualReport", { trigger: { manual: true } }, async (payload) => {
36
+ jobLog.push({ name: "test:job:manual-report", payload, timestamp: Date.now() });
37
+ });
38
+
39
+ // Concurrency: skip — if running, skip new
40
+ r.job("skipJob", { trigger: { manual: true }, concurrency: "skip" }, async (payload) => {
41
+ jobLog.push({ name: "test:job:skip-job", payload, timestamp: Date.now() });
42
+ await sleep(500); // Simulate long-running job
43
+ });
44
+
45
+ // Concurrency: parallel — multiple can run
46
+ r.job("parallelJob", { trigger: { manual: true }, concurrency: "parallel" }, async (payload) => {
47
+ jobLog.push({ name: "test:job:parallel-job", payload, timestamp: Date.now() });
48
+ });
49
+
50
+ // Concurrency: replace — cancel old, start new
51
+ r.job("replaceJob", { trigger: { manual: true }, concurrency: "replace" }, async (payload) => {
52
+ jobLog.push({ name: "test:job:replace-job", payload, timestamp: Date.now() });
53
+ await sleep(200);
54
+ });
55
+
56
+ // Concurrency: debounce — wait until quiet, then run once
57
+ r.job(
58
+ "debounceJob",
59
+ { trigger: { manual: true }, concurrency: "debounce", debounceMs: 300 },
60
+ async (payload) => {
61
+ jobLog.push({ name: "test:job:debounce-job", payload, timestamp: Date.now() });
62
+ },
63
+ );
64
+
65
+ // Concurrency: sequential — same-name dispatches must serialise via the
66
+ // per-name Redis SETNX-lock. Sleep duration sets the gap the assertions
67
+ // measure: parallel mode lands all entries within ~50ms; sequential
68
+ // spaces them by ≥sleep-duration each. If you tweak the sleep here,
69
+ // bump the timestamp deltas in the assertion to match.
70
+ r.job(
71
+ "sequentialJob",
72
+ { trigger: { manual: true }, concurrency: "sequential" },
73
+ async (payload) => {
74
+ jobLog.push({ name: "test:job:sequential-job", payload, timestamp: Date.now() });
75
+ await sleep(300);
76
+ },
77
+ );
78
+
79
+ // Sequential variant that throws. Used to assert the lock is released in
80
+ // the finally-path (next dispatch must still acquire it). retries=0 so
81
+ // the failure doesn't replay and pollute the log.
82
+ r.job(
83
+ "sequentialFailJob",
84
+ { trigger: { manual: true }, concurrency: "sequential", retries: 0 },
85
+ async (payload) => {
86
+ jobLog.push({ name: "test:job:sequential-fail-job", payload, timestamp: Date.now() });
87
+ throw new Error("sequential boom");
88
+ },
89
+ );
90
+
91
+ // maxPerTenant: cap concurrent + waiting jobs per tenant. Long sleep so
92
+ // the dispatcher checks ALL queued counts (including waiting ones).
93
+ r.job(
94
+ "perTenantLimited",
95
+ { trigger: { manual: true }, concurrency: "parallel", maxPerTenant: 2 },
96
+ async (payload) => {
97
+ jobLog.push({ name: "test:job:per-tenant-limited", payload, timestamp: Date.now() });
98
+ await sleep(500);
99
+ },
100
+ );
101
+
102
+ // Job that fails
103
+ r.job("failingJob", { trigger: { manual: true }, retries: 1 }, async () => {
104
+ throw new Error("intentional failure");
105
+ });
106
+
107
+ // Correlation propagation probe — records the requestContext it sees at
108
+ // handler-time so tests can assert the scheduling request's correlationId
109
+ // made it through BullMQ into the worker process.
110
+ r.job("correlationProbe", { trigger: { manual: true } }, async (payload) => {
111
+ const seen = requestContext.get();
112
+ jobLog.push({
113
+ name: "test:job:correlation-probe",
114
+ payload: {
115
+ ...payload,
116
+ observedCorrelationId: seen?.correlationId ?? null,
117
+ observedRequestId: seen?.requestId ?? null,
118
+ },
119
+ timestamp: Date.now(),
120
+ });
121
+ });
122
+ });
123
+
124
+ beforeAll(async () => {
125
+ testRedis = await createTestRedis();
126
+ redisUrl = `redis://${testRedis.redis.options.host}:${testRedis.redis.options.port}/${testRedis.redis.options.db}`;
127
+ });
128
+
129
+ afterAll(async () => {
130
+ await testRedis.cleanup();
131
+ });
132
+
133
+ // Helper to create a runner, run tests, then stop
134
+ async function withRunner(
135
+ fn: (runner: JobRunner, registry: Registry) => Promise<void>,
136
+ ): Promise<void> {
137
+ const registry = createRegistry([testFeature]);
138
+ const context: AppContext = {};
139
+ // Date.now() alone collided when two tests ran in the same millisecond;
140
+ // adding a random suffix keeps queue names unique across the whole run.
141
+ const queueNamePrefix = `kumiko-test-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
142
+ const runner = createJobRunner({
143
+ registry,
144
+ context,
145
+ redisUrl,
146
+ consumerLane: "worker",
147
+ queueNamePrefix,
148
+ });
149
+
150
+ try {
151
+ await runner.start();
152
+ await fn(runner, registry);
153
+ } finally {
154
+ await runner.stop();
155
+ // Purge any lingering scheduler/repeat keys the worker-lane queue left
156
+ // behind. BullMQ stores them under <queueName>:* — orphaned schedulers
157
+ // from a previous test run would otherwise fire into a now-stopped
158
+ // worker. Only the worker lane is queried because these tests run jobs
159
+ // with the default runIn, which resolves to "worker".
160
+ const keys = await testRedis.redis.keys(`bull:${queueNamePrefix}-worker:*`);
161
+ if (keys.length > 0) await testRedis.redis.del(...keys);
162
+ }
163
+ }
164
+
165
+ // --- Scenario 1: Boot job runs on startup ---
166
+
167
+ describe("scenario 1: boot job", () => {
168
+ test("runOnBoot job executes when runner starts", async () => {
169
+ clearLog();
170
+ await withRunner(async () => {
171
+ await waitFor(() => {
172
+ const bootEntries = jobLog.filter((e) => e.name === "test:job:boot-sync");
173
+ expect(bootEntries.length).toBeGreaterThanOrEqual(1);
174
+ });
175
+ });
176
+ });
177
+ });
178
+
179
+ // --- Scenario 2: Scheduled (cron) job ---
180
+
181
+ describe("scenario 2: scheduled job", () => {
182
+ test("cron job is registered in registry", () => {
183
+ const registry = createRegistry([testFeature]);
184
+ const job = registry.getJob("test:job:scheduled");
185
+ expect(job).toBeDefined();
186
+ if (job && "cron" in job.trigger) {
187
+ expect(job.trigger.cron).toBe("* * * * * *");
188
+ } else {
189
+ expect.unreachable("Expected cron trigger");
190
+ }
191
+ });
192
+
193
+ // BullMQ's repeatable scheduler needs a second or two to register its
194
+ // first tick — a generous delay schedule covers the startup window.
195
+ test("cron job fires via BullMQ scheduler", { timeout: 15_000 }, async () => {
196
+ clearLog();
197
+ await withRunner(async () => {
198
+ await waitFor(
199
+ () => {
200
+ const entries = jobLog.filter((e) => e.name === "test:job:scheduled");
201
+ expect(entries.length).toBeGreaterThanOrEqual(1);
202
+ },
203
+ { delays: [2000, 3000, 5000] },
204
+ );
205
+ });
206
+ });
207
+ });
208
+
209
+ // --- Scenario 3: Manual trigger ---
210
+
211
+ describe("scenario 3: manual trigger", () => {
212
+ test("dispatch runs the job with payload", async () => {
213
+ clearLog();
214
+ await withRunner(async (runner) => {
215
+ await runner.dispatch("test:job:manual-report", { reportId: 42 });
216
+ await waitFor(() => {
217
+ const entries = jobLog.filter((e) => e.name === "test:job:manual-report");
218
+ expect(entries.length).toBe(1);
219
+ expect(entries[0]?.payload).toEqual({ reportId: 42 });
220
+ });
221
+ });
222
+ });
223
+
224
+ test("dispatch unknown job throws", async () => {
225
+ await withRunner(async (runner) => {
226
+ await expect(runner.dispatch("nonexistent:job:missing")).rejects.toThrow("Unknown job");
227
+ });
228
+ });
229
+ });
230
+
231
+ // --- Concurrency modes ---
232
+
233
+ describe("concurrency: parallel", () => {
234
+ test("multiple parallel jobs all run", async () => {
235
+ clearLog();
236
+ await withRunner(async (runner) => {
237
+ await runner.dispatch("test:job:parallel-job", { n: 1 });
238
+ await runner.dispatch("test:job:parallel-job", { n: 2 });
239
+ await runner.dispatch("test:job:parallel-job", { n: 3 });
240
+ await waitFor(() => {
241
+ const entries = jobLog.filter((e) => e.name === "test:job:parallel-job");
242
+ expect(entries.length).toBe(3);
243
+ });
244
+ });
245
+ });
246
+ });
247
+
248
+ describe("concurrency: skip", () => {
249
+ test("skip mode prevents duplicate execution", async () => {
250
+ clearLog();
251
+ await withRunner(async (runner) => {
252
+ // First job takes 500ms
253
+ await runner.dispatch("test:job:skip-job", { n: 1 });
254
+
255
+ // Try dispatching multiple times while first is running
256
+ let skippedCount = 0;
257
+ for (let i = 0; i < 5; i++) {
258
+ await sleep(50);
259
+ const id = await runner.dispatch("test:job:skip-job", { n: i + 2 });
260
+ if (id === "skipped") skippedCount++;
261
+ }
262
+
263
+ // Wait until the first (running) job finishes so its log entry lands.
264
+ // Skip-mode guarantees max one job at a time, so we only ever need to
265
+ // see a single entry to know the run settled.
266
+ await waitFor(() => {
267
+ const entries = jobLog.filter((e) => e.name === "test:job:skip-job");
268
+ expect(entries.length).toBeGreaterThanOrEqual(1);
269
+ });
270
+
271
+ // At least some should have been skipped
272
+ expect(skippedCount).toBeGreaterThan(0);
273
+ // Should not have run all 6 times
274
+ const entries = jobLog.filter((e) => e.name === "test:job:skip-job");
275
+ expect(entries.length).toBeLessThan(6);
276
+ });
277
+ });
278
+ });
279
+
280
+ describe("concurrency: sequential", () => {
281
+ test("same-name dispatches run strictly one after the other", { timeout: 15_000 }, async () => {
282
+ clearLog();
283
+ await withRunner(async (runner) => {
284
+ // Three rapid dispatches. Parallel mode would land all entries
285
+ // within a single poll cycle (~50ms apart). The SETNX lock in the
286
+ // job-runner forces them to wait — each picks up only after the
287
+ // previous releases its lock at the end of its 300ms sleep.
288
+ await runner.dispatch("test:job:sequential-job", { n: 1 });
289
+ await runner.dispatch("test:job:sequential-job", { n: 2 });
290
+ await runner.dispatch("test:job:sequential-job", { n: 3 });
291
+
292
+ // Generous polling — re-enqueue with delay 200ms means the third
293
+ // job needs at least ~600ms total to land. Worst case allows for
294
+ // some BullMQ poll overhead.
295
+ await waitFor(
296
+ () => {
297
+ const entries = jobLog.filter((e) => e.name === "test:job:sequential-job");
298
+ expect(entries.length).toBe(3);
299
+ },
300
+ { delays: [400, 800, 1500, 3000] },
301
+ );
302
+
303
+ const entries = jobLog
304
+ .filter((e) => e.name === "test:job:sequential-job")
305
+ .sort((a, b) => a.timestamp - b.timestamp);
306
+ // Each entry must start at least ~250ms after the previous —
307
+ // sleep is 300ms, with slack for poll overhead. If sequential
308
+ // breaks (lock never acquired, group ignored), the deltas
309
+ // collapse to single-digit milliseconds.
310
+ const delta12 = (entries[1]?.timestamp ?? 0) - (entries[0]?.timestamp ?? 0);
311
+ const delta23 = (entries[2]?.timestamp ?? 0) - (entries[1]?.timestamp ?? 0);
312
+ expect(delta12).toBeGreaterThanOrEqual(250);
313
+ expect(delta23).toBeGreaterThanOrEqual(250);
314
+
315
+ // FIFO inside the same lock-name: the dispatch order is preserved
316
+ // even though re-enqueues happen.
317
+ expect(entries[0]?.payload).toEqual({ n: 1 });
318
+ });
319
+ });
320
+
321
+ test("lock is released even when the handler throws", { timeout: 10_000 }, async () => {
322
+ clearLog();
323
+ await withRunner(async (runner) => {
324
+ // First dispatch fails. If the finally-path didn't release the lock,
325
+ // the second dispatch couldn't acquire it and would loop forever in
326
+ // the re-enqueue path until BullMQ gave up.
327
+ await runner.dispatch("test:job:sequential-fail-job", { n: 1 });
328
+ await waitFor(
329
+ () => {
330
+ const entries = jobLog.filter((e) => e.name === "test:job:sequential-fail-job");
331
+ expect(entries.length).toBeGreaterThanOrEqual(1);
332
+ },
333
+ { delays: [200, 400, 800] },
334
+ );
335
+ // Tiny grace so BullMQ marks the failed job done and our finally
336
+ // ran — otherwise the lock-release race could outlast the next
337
+ // dispatch's acquire attempt.
338
+ await sleep(150);
339
+
340
+ // No surviving lock for this job-name in Redis — the value-matched
341
+ // DEL ran in finally.
342
+ const surviving = await testRedis.redis.keys("kumiko:lock:seq:*sequential-fail-job");
343
+ expect(surviving.length).toBe(0);
344
+
345
+ // Fresh dispatch must run — proves the lock isn't blocking new
346
+ // arrivals after the throw.
347
+ await runner.dispatch("test:job:sequential-fail-job", { n: 2 });
348
+ await waitFor(
349
+ () => {
350
+ const entries = jobLog.filter((e) => e.name === "test:job:sequential-fail-job");
351
+ expect(entries.length).toBeGreaterThanOrEqual(2);
352
+ },
353
+ { delays: [200, 400, 800] },
354
+ );
355
+ });
356
+ });
357
+
358
+ test("lock release is value-matched: foreign tokens survive expiration races", {
359
+ timeout: 5_000,
360
+ }, async () => {
361
+ // Pin the contract that distributed-lock's release script enforces:
362
+ // a release call from a worker whose token has already expired and
363
+ // been claimed by someone else must NOT delete the new owner's lock.
364
+ // Tested at the lock layer because we can't reliably race a TTL
365
+ // expiration inside the job-runner inside a 5s test budget.
366
+ const { createDistributedLock } = await import("../../pipeline/distributed-lock");
367
+ const prefix = "kumiko:lock:seq:test-vmd:";
368
+ const lock = createDistributedLock(testRedis.redis, prefix);
369
+
370
+ const tokenA = await lock.acquire("contract-key", { ttlSeconds: 10 });
371
+ expect(tokenA).not.toBeNull();
372
+ // Forcibly take it away — simulates the TTL-expired-and-reclaimed
373
+ // sequence without waiting 10s.
374
+ await testRedis.redis.set(`${prefix}contract-key`, "different-token");
375
+
376
+ // Worker A (now stale) tries to release: must be a no-op.
377
+ const releasedByStale = await lock.release("contract-key", tokenA as string);
378
+ expect(releasedByStale).toBe(false);
379
+ const stillHeld = await testRedis.redis.get(`${prefix}contract-key`);
380
+ expect(stillHeld).toBe("different-token");
381
+
382
+ await testRedis.redis.del(`${prefix}contract-key`);
383
+ });
384
+ });
385
+
386
+ describe("concurrency: debounce", () => {
387
+ test("rapid dispatches result in fewer executions than dispatches", async () => {
388
+ clearLog();
389
+ await withRunner(async (runner) => {
390
+ // Rapid fire 5 times — debounce should collapse some
391
+ await runner.dispatch("test:job:debounce-job", { n: 1 });
392
+ await runner.dispatch("test:job:debounce-job", { n: 2 });
393
+ await runner.dispatch("test:job:debounce-job", { n: 3 });
394
+ await runner.dispatch("test:job:debounce-job", { n: 4 });
395
+ await runner.dispatch("test:job:debounce-job", { n: 5 });
396
+
397
+ // Debounce (300ms) fires after the last rapid dispatch, then BullMQ
398
+ // picks the job up — first successful poll usually lands around 500ms.
399
+ await waitFor(
400
+ () => {
401
+ const entries = jobLog.filter((e) => e.name === "test:job:debounce-job");
402
+ expect(entries.length).toBeGreaterThanOrEqual(1);
403
+ },
404
+ { delays: [500, 1000, 2000] },
405
+ );
406
+
407
+ const entries = jobLog.filter((e) => e.name === "test:job:debounce-job");
408
+ // Debounce should result in fewer executions than dispatches
409
+ expect(entries.length).toBeLessThan(5);
410
+ expect(entries.length).toBeGreaterThanOrEqual(1);
411
+ });
412
+ });
413
+ });
414
+
415
+ describe("concurrency: maxPerTenant", () => {
416
+ test("max=2: third dispatch for same tenant returns skipped, other tenant unaffected", async () => {
417
+ clearLog();
418
+ await withRunner(async (runner) => {
419
+ const tenantA = "tenant-a";
420
+ const tenantB = "tenant-b";
421
+
422
+ // First two for tenantA fill the bucket — both should accept.
423
+ const idA1 = await runner.dispatch("test:job:per-tenant-limited", {
424
+ n: 1,
425
+ _tenantId: tenantA,
426
+ });
427
+ const idA2 = await runner.dispatch("test:job:per-tenant-limited", {
428
+ n: 2,
429
+ _tenantId: tenantA,
430
+ });
431
+ expect(idA1).not.toBe("skipped:max-per-tenant");
432
+ expect(idA2).not.toBe("skipped:max-per-tenant");
433
+
434
+ // Third for tenantA hits the cap before BullMQ drains the first.
435
+ // Small sleep so we don't race the queue.add of the first two.
436
+ await sleep(50);
437
+ const idA3 = await runner.dispatch("test:job:per-tenant-limited", {
438
+ n: 3,
439
+ _tenantId: tenantA,
440
+ });
441
+ expect(idA3).toBe("skipped:max-per-tenant");
442
+
443
+ // tenantB has its own bucket — accepted.
444
+ const idB1 = await runner.dispatch("test:job:per-tenant-limited", {
445
+ n: 4,
446
+ _tenantId: tenantB,
447
+ });
448
+ expect(idB1).not.toBe("skipped:max-per-tenant");
449
+
450
+ // After the 500ms-handlers settle the bucket empties. jobLog.push runs
451
+ // at handler START, so a log entry doesn't mean the job is "done" —
452
+ // it's still in `active` for the rest of the sleep. Wait long enough
453
+ // that the slowest 500ms handler has returned, then a fresh tenantA
454
+ // dispatch lands again.
455
+ await sleep(900);
456
+ const idA4 = await runner.dispatch("test:job:per-tenant-limited", {
457
+ n: 5,
458
+ _tenantId: tenantA,
459
+ });
460
+ expect(idA4).not.toBe("skipped:max-per-tenant");
461
+ });
462
+ });
463
+
464
+ test("missing _tenantId disables the guard (backwards-compatible)", async () => {
465
+ clearLog();
466
+ await withRunner(async (runner) => {
467
+ // No _tenantId in payload — guard inactive, all 4 accepted regardless of cap.
468
+ for (let i = 0; i < 4; i++) {
469
+ const id = await runner.dispatch("test:job:per-tenant-limited", { n: i });
470
+ expect(id).not.toBe("skipped:max-per-tenant");
471
+ }
472
+ });
473
+ });
474
+ });
475
+
476
+ // --- Correlation propagation ---
477
+
478
+ describe("correlation propagation", () => {
479
+ test("dispatch inside requestContext.run passes correlationId into the job", async () => {
480
+ clearLog();
481
+ await withRunner(async (runner) => {
482
+ // Enter a synthetic request-context, dispatch → the scheduler should
483
+ // pack the correlationId into the job data; the worker reads it back
484
+ // and re-enters requestContext.run.
485
+ await requestContext.run(
486
+ { requestId: "req-outer", correlationId: "carry-me-across-bullmq" },
487
+ async () => {
488
+ await runner.dispatch("test:job:correlation-probe", { n: 1 });
489
+ },
490
+ );
491
+ await waitFor(() => {
492
+ const entries = jobLog.filter((e) => e.name === "test:job:correlation-probe");
493
+ expect(entries.length).toBe(1);
494
+ });
495
+ const entry = jobLog.find((e) => e.name === "test:job:correlation-probe");
496
+ expect(entry?.payload["observedCorrelationId"]).toBe("carry-me-across-bullmq");
497
+ // requestId is fresh per job run, NOT the scheduler's requestId.
498
+ expect(entry?.payload["observedRequestId"]).not.toBe("req-outer");
499
+ expect(typeof entry?.payload["observedRequestId"]).toBe("string");
500
+ });
501
+ });
502
+
503
+ test("dispatch outside any request-context: job gets a fresh correlationId (not null)", async () => {
504
+ clearLog();
505
+ await withRunner(async (runner) => {
506
+ await runner.dispatch("test:job:correlation-probe", { n: 2 });
507
+ await waitFor(() => {
508
+ const entries = jobLog.filter((e) => e.name === "test:job:correlation-probe");
509
+ expect(entries.length).toBe(1);
510
+ });
511
+ const entry = jobLog.find((e) => e.name === "test:job:correlation-probe");
512
+ // Fresh correlationId — new requestId mirrored onto correlationId
513
+ // when no parent-context provided one.
514
+ expect(typeof entry?.payload["observedCorrelationId"]).toBe("string");
515
+ expect(entry?.payload["observedCorrelationId"]).toBe(entry?.payload["observedRequestId"]);
516
+ });
517
+ });
518
+ });
519
+
520
+ // --- Error handling ---
521
+
522
+ describe("error handling", () => {
523
+ test("failing job is caught, does not crash worker", async () => {
524
+ clearLog();
525
+ await withRunner(async (runner) => {
526
+ const id = await runner.dispatch("test:job:failing-job");
527
+ expect(id).toBeDefined();
528
+
529
+ // No fixed sleep needed — the follow-up dispatch + waitFor below prove
530
+ // the worker is still alive. If the failing job had crashed the worker,
531
+ // the manual-report would never land and waitFor would time out.
532
+ await runner.dispatch("test:job:manual-report", { after: "failure" });
533
+ await waitFor(() => {
534
+ const entries = jobLog.filter((e) => e.name === "test:job:manual-report");
535
+ expect(entries.length).toBeGreaterThanOrEqual(1);
536
+ });
537
+ });
538
+ });
539
+ });
540
+
541
+ // --- Registry ---
542
+
543
+ describe("job registry", () => {
544
+ test("getAllJobs returns all registered jobs with feature prefix", () => {
545
+ const registry = createRegistry([testFeature]);
546
+ const jobs = registry.getAllJobs();
547
+ expect(jobs.has("test:job:boot-sync")).toBe(true);
548
+ expect(jobs.has("test:job:scheduled")).toBe(true);
549
+ expect(jobs.has("test:job:manual-report")).toBe(true);
550
+ expect(jobs.has("test:job:skip-job")).toBe(true);
551
+ });
552
+
553
+ test("getJob returns job definition", () => {
554
+ const registry = createRegistry([testFeature]);
555
+ const job = registry.getJob("test:job:skip-job");
556
+ expect(job).toBeDefined();
557
+ expect(job?.concurrency).toBe("skip");
558
+ });
559
+
560
+ test("boot job has runOnBoot flag", () => {
561
+ const registry = createRegistry([testFeature]);
562
+ const job = registry.getJob("test:job:boot-sync");
563
+ expect(job).toBeDefined();
564
+ expect(job?.runOnBoot).toBe(true);
565
+ });
566
+ });
@@ -0,0 +1,2 @@
1
+ export type { JobLogEntry, JobMeta, JobRunner, JobRunnerOptions } from "./job-runner";
2
+ export { createJobRunner } from "./job-runner";