@cosmicdrift/kumiko-framework 1.0.0 → 2.0.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 (424) hide show
  1. package/README.md +9 -38
  2. package/package.json +21 -2
  3. package/src/__tests__/anonymous-access.integration.test.ts +185 -0
  4. package/src/__tests__/consumer-cli.integration.test.ts +172 -0
  5. package/src/__tests__/entity-permalink-open.integration.test.ts +94 -0
  6. package/src/__tests__/full-stack.integration.test.ts +1 -1
  7. package/src/__tests__/schema-cli-temporal-polyfill.test.ts +48 -0
  8. package/src/__tests__/schema-cli.integration.test.ts +50 -0
  9. package/src/__tests__/store-table.integration.test.ts +93 -0
  10. package/src/api/__tests__/api.test.ts +373 -0
  11. package/src/api/__tests__/auth-middleware-anonymous-access-boot.test.ts +40 -0
  12. package/src/api/__tests__/auth-routes-cookie.test.ts +17 -1
  13. package/src/api/__tests__/auth-routes-invalid-body-invite.test.ts +253 -0
  14. package/src/api/__tests__/auth-routes-mfa-preauth-confirm.test.ts +222 -0
  15. package/src/api/__tests__/auth-routes-mfa-preauth-enable-start.test.ts +249 -0
  16. package/src/api/__tests__/auth-routes-mfa-verify.test.ts +202 -0
  17. package/src/api/__tests__/auth-routes-trusted-proxy.test.ts +135 -0
  18. package/src/api/__tests__/batch.integration.test.ts +101 -11
  19. package/src/api/__tests__/csrf-constants-sync.test.ts +20 -0
  20. package/src/api/__tests__/dispatcher-live.integration.test.ts +74 -0
  21. package/src/api/__tests__/jwt.test.ts +200 -1
  22. package/src/api/__tests__/login-rate-limiter-sweep.test.ts +50 -0
  23. package/src/api/__tests__/pat-scope.test.ts +36 -0
  24. package/src/api/__tests__/pii-leak-guard.integration.test.ts +103 -0
  25. package/src/api/__tests__/redis-login-rate-limiter.integration.test.ts +138 -0
  26. package/src/api/__tests__/request-id-middleware.test.ts +51 -0
  27. package/src/api/__tests__/server-boot-guards.test.ts +71 -0
  28. package/src/api/__tests__/server-jwt-ttl.test.ts +58 -0
  29. package/src/api/__tests__/sse-broker.test.ts +57 -0
  30. package/src/api/__tests__/sse-route.test.ts +4 -0
  31. package/src/api/api-constants.ts +11 -0
  32. package/src/api/auth-middleware.ts +248 -40
  33. package/src/api/auth-routes.ts +576 -95
  34. package/src/api/index.ts +13 -4
  35. package/src/api/jwt.ts +170 -11
  36. package/src/api/pat-scope.ts +14 -0
  37. package/src/api/pii-leak-guard.ts +47 -0
  38. package/src/api/request-context.ts +3 -0
  39. package/src/api/request-id-middleware.ts +2 -0
  40. package/src/api/routes.ts +169 -2
  41. package/src/api/server.ts +112 -16
  42. package/src/api/sse-broker.ts +39 -0
  43. package/src/bun-db/__tests__/PATTERN.md +0 -1
  44. package/src/bun-db/__tests__/coerce-row-temporal.test.ts +41 -0
  45. package/src/bun-db/__tests__/select-many-retry.test.ts +79 -0
  46. package/src/bun-db/__tests__/write-brand.test.ts +21 -0
  47. package/src/bun-db/connection.ts +3 -3
  48. package/src/bun-db/index.ts +2 -0
  49. package/src/bun-db/query.ts +105 -32
  50. package/src/consumer-cli.ts +134 -0
  51. package/src/crypto/__tests__/blind-index.test.ts +130 -0
  52. package/src/crypto/__tests__/event-pii.test.ts +161 -0
  53. package/src/crypto/__tests__/kek-rotation.integration.test.ts +180 -0
  54. package/src/crypto/__tests__/kms-adapter-contract.ts +134 -0
  55. package/src/crypto/__tests__/kms-adapter.contract.test.ts +4 -0
  56. package/src/crypto/__tests__/pg-kms-adapter.integration.test.ts +117 -0
  57. package/src/crypto/__tests__/pii-field-encryption.test.ts +376 -0
  58. package/src/crypto/__tests__/request-kms-cache.test.ts +74 -0
  59. package/src/crypto/__tests__/subject-resolver.test.ts +108 -0
  60. package/src/crypto/blind-index.ts +118 -0
  61. package/src/crypto/event-pii.ts +70 -0
  62. package/src/crypto/in-memory-kms-adapter.ts +50 -0
  63. package/src/crypto/index.ts +67 -0
  64. package/src/crypto/kms-adapter.ts +2 -0
  65. package/src/crypto/pg-kms-adapter.ts +295 -0
  66. package/src/crypto/pii-field-encryption.ts +248 -0
  67. package/src/crypto/request-kms-cache.ts +38 -0
  68. package/src/crypto/subject-resolver.ts +91 -0
  69. package/src/db/__tests__/assert-no-unreachable-live-rows.integration.test.ts +191 -0
  70. package/src/db/__tests__/blind-index.integration.test.ts +248 -0
  71. package/src/db/__tests__/build-filter-where.test.ts +34 -0
  72. package/src/db/__tests__/collect-table-metas.test.ts +10 -10
  73. package/src/db/__tests__/config-seed.integration.test.ts +13 -5
  74. package/src/db/__tests__/dialect-instant.test.ts +1 -4
  75. package/src/db/__tests__/entity-field-encryption.test.ts +7 -7
  76. package/src/db/__tests__/entity-table-meta-source.test.ts +50 -0
  77. package/src/db/__tests__/event-store-executor-context.pii-roundtrip.test.ts +67 -0
  78. package/src/db/__tests__/event-store-executor-write-verbs.integration.test.ts +402 -0
  79. package/src/db/__tests__/event-store-executor.integration.test.ts +299 -29
  80. package/src/db/__tests__/feature-table-sources.test.ts +34 -0
  81. package/src/db/__tests__/implicit-projection-equivalence.integration.test.ts +118 -39
  82. package/src/db/__tests__/instant-to-driver-temporal.test.ts +21 -0
  83. package/src/db/__tests__/located-timestamp.test.ts +19 -0
  84. package/src/db/__tests__/migrate-generator.test.ts +21 -0
  85. package/src/db/__tests__/migrate-runner.test.ts +61 -0
  86. package/src/db/__tests__/number-field-fractional.integration.test.ts +58 -0
  87. package/src/db/__tests__/rebuild-marker.test.ts +13 -3
  88. package/src/db/__tests__/replay-migration-sql.test.ts +384 -0
  89. package/src/db/__tests__/schema-inspection.test.ts +7 -0
  90. package/src/db/__tests__/schema-migration.integration.test.ts +1 -1
  91. package/src/db/__tests__/table-builder-meta-lockstep.test.ts +39 -0
  92. package/src/db/__tests__/tenant-db-where-merge.test.ts +49 -3
  93. package/src/db/__tests__/tenant-db.integration.test.ts +6 -2
  94. package/src/db/api.ts +2 -2
  95. package/src/db/apply-entity-event.ts +18 -14
  96. package/src/db/blind-index-cleanup.ts +55 -0
  97. package/src/db/bun-provider.ts +2 -2
  98. package/src/db/collect-table-metas.ts +6 -7
  99. package/src/db/config-seed.ts +9 -9
  100. package/src/db/connection.ts +7 -12
  101. package/src/db/cursor.ts +1 -18
  102. package/src/db/dialect.ts +20 -26
  103. package/src/db/entity-field-encryption.ts +81 -24
  104. package/src/db/entity-table-meta-types.ts +2 -0
  105. package/src/db/entity-table-meta.ts +63 -90
  106. package/src/db/event-store-executor-context.ts +404 -0
  107. package/src/db/event-store-executor-read.ts +275 -0
  108. package/src/db/event-store-executor-write.ts +644 -0
  109. package/src/db/event-store-executor.ts +28 -1155
  110. package/src/db/feature-table-sources.ts +6 -5
  111. package/src/db/index.ts +20 -3
  112. package/src/db/located-timestamp.ts +4 -0
  113. package/src/db/migrate-generator.ts +39 -6
  114. package/src/db/migrate-runner.ts +107 -11
  115. package/src/db/pg-error.ts +9 -1
  116. package/src/db/postgres-provider.ts +2 -2
  117. package/src/db/queries/__tests__/event-store-idempotency-index.integration.test.ts +80 -0
  118. package/src/db/queries/backfill-pii.ts +277 -0
  119. package/src/db/queries/ddl.ts +45 -0
  120. package/src/db/queries/event-consumer.ts +35 -2
  121. package/src/db/queries/event-store.ts +126 -19
  122. package/src/db/queries/projection-rebuild.ts +22 -8
  123. package/src/db/queries/shadow-swap.ts +183 -0
  124. package/src/db/queries/test-stack.ts +4 -30
  125. package/src/db/query-api.ts +1 -0
  126. package/src/db/query.ts +1 -0
  127. package/src/db/rebuild-marker.ts +18 -2
  128. package/src/db/reference-data.ts +2 -3
  129. package/src/db/replay-migration-sql.ts +257 -0
  130. package/src/db/schema-inspection.ts +1 -1
  131. package/src/db/table-builder.ts +102 -71
  132. package/src/db/tenant-db.ts +15 -53
  133. package/src/engine/__tests__/boot-validator-action-wiring.test.ts +242 -0
  134. package/src/engine/__tests__/boot-validator-boot-check.test.ts +99 -0
  135. package/src/engine/__tests__/boot-validator-dashboard.test.ts +237 -0
  136. package/src/engine/__tests__/boot-validator-entity-list.test.ts +104 -0
  137. package/src/engine/__tests__/boot-validator-gdpr-storage.test.ts +7 -69
  138. package/src/engine/__tests__/boot-validator-i18n-keys.test.ts +26 -5
  139. package/src/engine/__tests__/boot-validator-located-timestamps.test.ts +3 -19
  140. package/src/engine/__tests__/boot-validator-pii-retention.test.ts +246 -4
  141. package/src/engine/__tests__/boot-validator.test.ts +219 -15
  142. package/src/engine/__tests__/build-app-schema.test.ts +82 -0
  143. package/src/engine/__tests__/build-config-feature-schema.test.ts +125 -0
  144. package/src/engine/__tests__/build-target.test.ts +3 -11
  145. package/src/engine/__tests__/codemod-pipeline.test.ts +152 -21
  146. package/src/engine/__tests__/config-helpers.test.ts +16 -0
  147. package/src/engine/__tests__/define-feature-entity-mapping.test.ts +6 -0
  148. package/src/engine/__tests__/define-roles.test.ts +21 -0
  149. package/src/engine/__tests__/engine.test.ts +163 -1
  150. package/src/engine/__tests__/entity-handlers.test.ts +80 -0
  151. package/src/engine/__tests__/event-migration-declarative.test.ts +65 -0
  152. package/src/engine/__tests__/event-type-map-augmentation.test.ts +24 -0
  153. package/src/engine/__tests__/extend-entity-projection.test.ts +123 -0
  154. package/src/engine/__tests__/factories-time.test.ts +2 -66
  155. package/src/engine/__tests__/feature-crud-shorthand.test.ts +28 -0
  156. package/src/engine/__tests__/feature-manifest.test.ts +31 -1
  157. package/src/engine/__tests__/field-access.test.ts +23 -1
  158. package/src/engine/__tests__/hook-phases.test.ts +5 -5
  159. package/src/engine/__tests__/membership-roles.test.ts +4 -10
  160. package/src/engine/__tests__/nav.test.ts +86 -1
  161. package/src/engine/__tests__/pipeline-engine.test.ts +9 -9
  162. package/src/engine/__tests__/pipeline-handler.integration.test.ts +18 -18
  163. package/src/engine/__tests__/pipeline-observability.integration.test.ts +2 -2
  164. package/src/engine/__tests__/pipeline-performance.integration.test.ts +3 -3
  165. package/src/engine/__tests__/pipeline-sub-pipelines.test.ts +9 -9
  166. package/src/engine/__tests__/post-query-hook.test.ts +7 -7
  167. package/src/engine/__tests__/registrar-object-form.test.ts +141 -0
  168. package/src/engine/__tests__/registry.test.ts +144 -0
  169. package/src/engine/__tests__/schema-builder.test.ts +18 -0
  170. package/src/engine/__tests__/screen.test.ts +147 -1
  171. package/src/engine/__tests__/soft-delete-cleanup.test.ts +3 -0
  172. package/src/engine/__tests__/store-table.test.ts +229 -0
  173. package/src/engine/__tests__/tier-resolver-extension.test.ts +19 -1
  174. package/src/engine/__tests__/{visual-tree-patterns.test.ts → tree-actions-patterns.test.ts} +7 -95
  175. package/src/engine/__tests__/validate-projection-allowlist.test.ts +15 -15
  176. package/src/engine/boot-validator/__tests__/config-deps.test.ts +92 -0
  177. package/src/engine/boot-validator/action-wiring.ts +149 -0
  178. package/src/engine/boot-validator/boot-check.ts +21 -0
  179. package/src/engine/boot-validator/config-deps.ts +40 -4
  180. package/src/engine/boot-validator/entity-handler.ts +20 -21
  181. package/src/engine/boot-validator/entity-list-screens.ts +88 -0
  182. package/src/engine/boot-validator/gdpr-storage.ts +0 -20
  183. package/src/engine/boot-validator/i18n-keys.ts +58 -4
  184. package/src/engine/boot-validator/index.ts +33 -12
  185. package/src/engine/boot-validator/nav.ts +125 -0
  186. package/src/engine/boot-validator/pii-retention.ts +95 -10
  187. package/src/engine/boot-validator/{screens-nav.ts → screens.ts} +214 -191
  188. package/src/engine/boot-validator/workspaces.ts +68 -0
  189. package/src/engine/build-app-schema.ts +16 -0
  190. package/src/engine/build-config-feature-schema.ts +44 -7
  191. package/src/engine/codemod/pipeline-codemod.ts +5 -5
  192. package/src/engine/config-helpers.ts +15 -0
  193. package/src/engine/constants.ts +32 -6
  194. package/src/engine/create-app.ts +11 -0
  195. package/src/engine/define-feature.ts +95 -947
  196. package/src/engine/define-handler.ts +28 -94
  197. package/src/engine/define-workflow.ts +1 -1
  198. package/src/engine/effective-features.ts +12 -2
  199. package/src/engine/entity-handlers.ts +76 -11
  200. package/src/engine/extensions/tenant-data.ts +19 -0
  201. package/src/engine/extensions/user-data.ts +29 -2
  202. package/src/engine/factories.ts +8 -49
  203. package/src/engine/feature-ast/__tests__/canonical-form.test.ts +26 -29
  204. package/src/engine/feature-ast/__tests__/fixtures/cross-file-registrar/feature.ts +9 -0
  205. package/src/engine/feature-ast/__tests__/fixtures/cross-file-registrar/screens.ts +4 -0
  206. package/src/engine/feature-ast/__tests__/parse-happy-path.test.ts +1 -2
  207. package/src/engine/feature-ast/__tests__/parse-real-features.test.ts +18 -8
  208. package/src/engine/feature-ast/__tests__/parse.test.ts +1640 -160
  209. package/src/engine/feature-ast/__tests__/patch.test.ts +206 -12
  210. package/src/engine/feature-ast/__tests__/patcher.test.ts +20 -23
  211. package/src/engine/feature-ast/__tests__/render-roundtrip.test.ts +380 -5
  212. package/src/engine/feature-ast/extractors/events.ts +322 -0
  213. package/src/engine/feature-ast/extractors/handlers.ts +234 -0
  214. package/src/engine/feature-ast/extractors/hooks.ts +243 -0
  215. package/src/engine/feature-ast/extractors/index.ts +34 -31
  216. package/src/engine/feature-ast/extractors/jobs-routes.ts +221 -0
  217. package/src/engine/feature-ast/extractors/projections-screens.ts +269 -0
  218. package/src/engine/feature-ast/extractors/round1.ts +3 -3
  219. package/src/engine/feature-ast/extractors/round5.ts +3 -3
  220. package/src/engine/feature-ast/extractors/round6.ts +2 -36
  221. package/src/engine/feature-ast/extractors/shared.ts +64 -6
  222. package/src/engine/feature-ast/index.ts +2 -4
  223. package/src/engine/feature-ast/parse.ts +130 -23
  224. package/src/engine/feature-ast/patch.ts +39 -50
  225. package/src/engine/feature-ast/patcher.ts +37 -38
  226. package/src/engine/feature-ast/patterns.ts +50 -62
  227. package/src/engine/feature-ast/render.ts +67 -44
  228. package/src/engine/feature-builder-state.ts +168 -0
  229. package/src/engine/feature-config-events-jobs.ts +403 -0
  230. package/src/engine/feature-entity-handlers.ts +208 -0
  231. package/src/engine/feature-manifest.ts +2 -1
  232. package/src/engine/feature-ui-extensions.ts +500 -0
  233. package/src/engine/field-access.ts +13 -2
  234. package/src/engine/field-helpers.ts +31 -0
  235. package/src/engine/handler-helpers.ts +26 -0
  236. package/src/engine/hook-helpers.ts +16 -0
  237. package/src/engine/index.ts +23 -7
  238. package/src/engine/membership-roles.ts +13 -0
  239. package/src/engine/object-form.ts +27 -0
  240. package/src/engine/ownership.ts +26 -79
  241. package/src/engine/pattern-library/__tests__/library.test.ts +7 -20
  242. package/src/engine/pattern-library/library.ts +44 -1152
  243. package/src/engine/pattern-library/mixed-schemas.ts +450 -0
  244. package/src/engine/pattern-library/opaque-schemas.ts +124 -0
  245. package/src/engine/pattern-library/shared-fields.ts +75 -0
  246. package/src/engine/pattern-library/static-schemas.ts +456 -0
  247. package/src/engine/pipeline.ts +6 -11
  248. package/src/engine/registry-facade.ts +362 -0
  249. package/src/engine/registry-ingest.ts +478 -0
  250. package/src/engine/registry-state.ts +388 -0
  251. package/src/engine/registry-validate.ts +642 -0
  252. package/src/engine/registry.ts +78 -1658
  253. package/src/engine/run-pipeline.ts +1 -1
  254. package/src/engine/schema-builder.ts +1 -0
  255. package/src/engine/screen-helpers.ts +54 -0
  256. package/src/engine/soft-delete-cleanup.ts +6 -2
  257. package/src/engine/steps/__tests__/duration-utils.test.ts +20 -0
  258. package/src/engine/steps/_duration-utils.ts +2 -0
  259. package/src/engine/steps/unsafe-projection-upsert.ts +1 -4
  260. package/src/engine/tier-resolver-extension.ts +3 -2
  261. package/src/engine/types/config.ts +2 -482
  262. package/src/engine/types/define-handler.ts +2 -0
  263. package/src/engine/types/entity-handlers.ts +2 -0
  264. package/src/engine/types/event-type-map.ts +1 -37
  265. package/src/engine/types/feature.ts +2 -972
  266. package/src/engine/types/fields.ts +2 -675
  267. package/src/engine/types/handlers.ts +2 -774
  268. package/src/engine/types/hooks.ts +2 -184
  269. package/src/engine/types/http-route.ts +1 -54
  270. package/src/engine/types/identifiers.ts +1 -47
  271. package/src/engine/types/index.ts +86 -40
  272. package/src/engine/types/nav.ts +2 -63
  273. package/src/engine/types/ownership.ts +2 -0
  274. package/src/engine/types/projection.ts +2 -138
  275. package/src/engine/types/relations.ts +1 -51
  276. package/src/engine/types/screen.ts +2 -574
  277. package/src/engine/types/step.ts +2 -334
  278. package/src/engine/types/target-ref.ts +1 -21
  279. package/src/engine/types/tree-node.ts +1 -132
  280. package/src/engine/types/workspace.ts +2 -49
  281. package/src/engine/validate-projection-allowlist.ts +6 -6
  282. package/src/entrypoint/__tests__/entrypoint-job-wiring.integration.test.ts +120 -1
  283. package/src/entrypoint/index.ts +49 -6
  284. package/src/errors/classes.ts +22 -1
  285. package/src/errors/field-issue.ts +0 -3
  286. package/src/errors/index.ts +1 -1
  287. package/src/errors/write-error-info.ts +10 -22
  288. package/src/es-ops/README.md +1 -1
  289. package/src/es-ops/__tests__/runner.integration.test.ts +74 -0
  290. package/src/es-ops/context.ts +4 -2
  291. package/src/es-ops/types.ts +8 -1
  292. package/src/event-store/__tests__/admin-api.integration.test.ts +27 -1
  293. package/src/event-store/__tests__/backfill-pii.integration.test.ts +279 -0
  294. package/src/event-store/__tests__/event-store.integration.test.ts +201 -0
  295. package/src/event-store/__tests__/row-to-stored-event.test.ts +2 -2
  296. package/src/event-store/__tests__/snapshot.integration.test.ts +86 -0
  297. package/src/event-store/__tests__/unscoped-stream-primitives.guard.test.ts +58 -0
  298. package/src/event-store/__tests__/upcaster.integration.test.ts +77 -45
  299. package/src/event-store/admin-api.ts +11 -4
  300. package/src/event-store/errors.ts +2 -35
  301. package/src/event-store/event-store.ts +64 -78
  302. package/src/event-store/events-schema.ts +11 -13
  303. package/src/event-store/index.ts +18 -3
  304. package/src/event-store/rebuild-dead-letter.ts +111 -0
  305. package/src/event-store/snapshot.ts +63 -40
  306. package/src/event-store/types.ts +2 -0
  307. package/src/files/__tests__/build-storage-key.test.ts +28 -0
  308. package/src/files/__tests__/file-ref-entity.test.ts +8 -0
  309. package/src/files/__tests__/files.integration.test.ts +24 -0
  310. package/src/files/__tests__/in-memory-provider.contract.test.ts +4 -0
  311. package/src/files/__tests__/local-provider.test.ts +31 -0
  312. package/src/files/__tests__/provider-resolver.test.ts +70 -0
  313. package/src/files/__tests__/write-stream.test.ts +13 -0
  314. package/src/files/file-handle.ts +2 -19
  315. package/src/files/file-ref-entity.ts +2 -2
  316. package/src/files/file-routes.ts +13 -0
  317. package/src/files/index.ts +1 -1
  318. package/src/files/local-provider.ts +27 -8
  319. package/src/files/provider-resolver.ts +31 -20
  320. package/src/files/types.ts +13 -55
  321. package/src/i18n/__tests__/required-surface-keys.test.ts +17 -0
  322. package/src/i18n/required-surface-keys.ts +120 -1
  323. package/src/jobs/__tests__/job-queue-depth.integration.test.ts +82 -0
  324. package/src/jobs/__tests__/jobs.integration.test.ts +329 -3
  325. package/src/jobs/job-runner.ts +82 -12
  326. package/src/logging/types.ts +1 -7
  327. package/src/migrations/pending-rebuilds.ts +1 -1
  328. package/src/observability/__tests__/observability.integration.test.ts +4 -1
  329. package/src/observability/__tests__/recording-tracer.test.ts +6 -4
  330. package/src/observability/index.ts +2 -0
  331. package/src/observability/noop-provider.ts +0 -9
  332. package/src/observability/recording-tracer.ts +0 -12
  333. package/src/observability/standard-metrics.ts +64 -2
  334. package/src/observability/types/index.ts +1 -29
  335. package/src/observability/types/metric.ts +1 -56
  336. package/src/observability/types/provider.ts +1 -32
  337. package/src/observability/types/span.ts +1 -64
  338. package/src/pipeline/__tests__/ctx-bridge.integration.test.ts +2 -2
  339. package/src/pipeline/__tests__/dispatcher.test.ts +366 -1
  340. package/src/pipeline/__tests__/event-consumer-state.integration.test.ts +31 -0
  341. package/src/pipeline/__tests__/event-dispatcher-delivery-max-attempts.test.ts +126 -0
  342. package/src/pipeline/__tests__/event-dispatcher-rearm.integration.test.ts +263 -0
  343. package/src/pipeline/__tests__/event-dispatcher.integration.test.ts +10 -0
  344. package/src/pipeline/__tests__/job-trigger-consumer.integration.test.ts +106 -0
  345. package/src/pipeline/__tests__/lifecycle-pipeline.test.ts +308 -77
  346. package/src/pipeline/__tests__/load-aggregate-query.integration.test.ts +16 -8
  347. package/src/pipeline/__tests__/post-query-hook.integration.test.ts +3 -3
  348. package/src/pipeline/__tests__/projection-rebuild.integration.test.ts +80 -2
  349. package/src/pipeline/__tests__/rebuild-poison-quarantine.integration.test.ts +274 -0
  350. package/src/pipeline/__tests__/try-append-event.integration.test.ts +166 -0
  351. package/src/pipeline/dispatch-batch.ts +187 -0
  352. package/src/pipeline/dispatch-query.ts +165 -0
  353. package/src/pipeline/dispatch-shared.ts +826 -0
  354. package/src/pipeline/dispatch-stream.ts +90 -0
  355. package/src/pipeline/dispatch-write.ts +451 -0
  356. package/src/pipeline/dispatcher-utils.ts +1 -1
  357. package/src/pipeline/dispatcher.ts +44 -1372
  358. package/src/pipeline/entity-cache.ts +2 -33
  359. package/src/pipeline/event-consumer-state.ts +30 -2
  360. package/src/pipeline/event-dispatcher-admin.ts +293 -0
  361. package/src/pipeline/event-dispatcher-delivery.ts +305 -0
  362. package/src/pipeline/event-dispatcher.ts +85 -542
  363. package/src/pipeline/index.ts +2 -0
  364. package/src/pipeline/msp-rebuild.ts +42 -3
  365. package/src/pipeline/multi-stream-apply-context.ts +4 -42
  366. package/src/pipeline/projection-rebuild.ts +105 -3
  367. package/src/pipeline/system-hooks.ts +144 -1
  368. package/src/rate-limit/__tests__/resolver.integration.test.ts +18 -0
  369. package/src/rate-limit/resolver.ts +16 -32
  370. package/src/schema-cli.ts +76 -16
  371. package/src/search/__tests__/meilisearch-adapter.integration.test.ts +207 -185
  372. package/src/search/__tests__/meilisearch-ids.test.ts +30 -0
  373. package/src/search/__tests__/reindex-entity.integration.test.ts +144 -0
  374. package/src/search/index.ts +6 -0
  375. package/src/search/meilisearch-adapter.ts +13 -12
  376. package/src/search/reindex-entity.ts +177 -0
  377. package/src/search/types.ts +1 -39
  378. package/src/secrets/__tests__/contains-secret.test.ts +34 -0
  379. package/src/secrets/__tests__/envelope-cipher.test.ts +59 -0
  380. package/src/secrets/__tests__/envelope.test.ts +1 -1
  381. package/src/secrets/dek-cache.ts +26 -5
  382. package/src/secrets/envelope-cipher.ts +53 -0
  383. package/src/secrets/envelope.ts +5 -3
  384. package/src/secrets/index.ts +13 -1
  385. package/src/secrets/stored-envelope.ts +46 -0
  386. package/src/secrets/types.ts +2 -162
  387. package/src/stack/__tests__/event-collector.test.ts +42 -0
  388. package/src/stack/__tests__/setup-test-stack-jobs.integration.test.ts +125 -0
  389. package/src/stack/db.ts +2 -1
  390. package/src/stack/push-entity-projection-tables.ts +4 -3
  391. package/src/stack/redis.ts +8 -0
  392. package/src/stack/request-helper.ts +38 -2
  393. package/src/stack/table-helpers.ts +6 -4
  394. package/src/stack/test-stack.ts +249 -131
  395. package/src/testing/__tests__/late-bound.test.ts +32 -0
  396. package/src/testing/__tests__/wait-for.test.ts +59 -0
  397. package/src/testing/boot-validator-fixture.ts +121 -0
  398. package/src/testing/e2e-generator.ts +8 -0
  399. package/src/testing/file-provider-contract.ts +104 -0
  400. package/src/testing/handler-context.ts +3 -1
  401. package/src/testing/index.ts +5 -0
  402. package/src/testing/late-bound.ts +5 -3
  403. package/src/testing/mutable-master-key-provider.ts +29 -3
  404. package/src/testing/wait-for.ts +3 -0
  405. package/src/testing/without-ambient-temporal.ts +14 -0
  406. package/src/time/__tests__/tz-dateline.test.ts +8 -10
  407. package/src/time/geo-tz.ts +1 -32
  408. package/src/time/index.ts +1 -0
  409. package/src/time/legacy-date.ts +18 -0
  410. package/src/time/polyfill.ts +21 -38
  411. package/src/time/tz-context.ts +44 -85
  412. package/src/ui-types/app-schema.ts +12 -0
  413. package/src/ui-types/index.ts +20 -7
  414. package/src/utils/__tests__/safe-json-temporal.test.ts +18 -0
  415. package/src/utils/safe-json.ts +13 -1
  416. package/src/__tests__/raw-table.integration.test.ts +0 -116
  417. package/src/bun-db/__tests__/bun-test-stack.ts +0 -6
  418. package/src/db/__tests__/encryption.test.ts +0 -39
  419. package/src/db/encryption.ts +0 -39
  420. package/src/db/row-helpers.ts +0 -4
  421. package/src/engine/__tests__/raw-table.test.ts +0 -150
  422. package/src/engine/__tests__/unmanaged-table.test.ts +0 -127
  423. package/src/engine/feature-ast/__tests__/visual-tree-parse.test.ts +0 -184
  424. package/src/engine/feature-ast/extractors/round4.ts +0 -1366
@@ -1,10 +1,26 @@
1
1
  import { afterAll, beforeAll, describe, expect, test } from "bun:test";
2
+ import { Queue } from "bullmq";
3
+ import { z } from "zod";
2
4
  import { requestContext } from "../../api/request-context";
3
5
  import { createRegistry, defineFeature } from "../../engine";
4
- import type { AppContext, Registry } from "../../engine/types";
5
- import { createTestRedis, type TestRedis } from "../../stack";
6
+ import type {
7
+ AppContext,
8
+ ConfigAccessor,
9
+ ConfigAccessorFactory,
10
+ NotifyFactory,
11
+ Registry,
12
+ TenantId,
13
+ } from "../../engine/types";
14
+ import { createInMemoryFileProvider } from "../../files/in-memory-provider";
15
+ import { createTestRedis, type TestRedis, TestUsers } from "../../stack";
6
16
  import { sleep, waitFor } from "../../testing";
7
- import { createJobRunner, type JobRunner } from "../job-runner";
17
+ import {
18
+ createJobRunner,
19
+ type JobLogEntry,
20
+ type JobMeta,
21
+ type JobRunner,
22
+ type JobRunnerOptions,
23
+ } from "../job-runner";
8
24
 
9
25
  // --- Shared state ---
10
26
 
@@ -98,12 +114,42 @@ const testFeature = defineFeature("test", (r) => {
98
114
  await sleep(500);
99
115
  },
100
116
  );
117
+ r.writeHandler(
118
+ "capped-event",
119
+ z.object({ n: z.number() }),
120
+ async (event) => ({ isSuccess: true, data: { n: event.payload.n } }),
121
+ { access: { openToAll: true } },
122
+ );
123
+ // Event-triggered twin — exercises handleEvent's maxPerTenant guard
124
+ // (dispatch and handleEvent share isOverPerTenantLimit).
125
+ r.job(
126
+ "perTenantLimitedEvent",
127
+ {
128
+ trigger: { on: "test:write:capped-event" },
129
+ concurrency: "parallel",
130
+ maxPerTenant: 2,
131
+ },
132
+ async (payload) => {
133
+ jobLog.push({
134
+ name: "test:job:per-tenant-limited-event",
135
+ payload,
136
+ timestamp: Date.now(),
137
+ });
138
+ await sleep(500);
139
+ },
140
+ );
101
141
 
102
142
  // Job that fails
103
143
  r.job("failingJob", { trigger: { manual: true }, retries: 1 }, async () => {
104
144
  throw new Error("intentional failure");
105
145
  });
106
146
 
147
+ // perTenant fan-out — used to assert missing getActiveTenantIds fails
148
+ // the wrapper job without killing the worker.
149
+ r.job("perTenantFanout", { trigger: { manual: true }, perTenant: true }, async (payload) => {
150
+ jobLog.push({ name: "test:job:per-tenant-fanout", payload, timestamp: Date.now() });
151
+ });
152
+
107
153
  // Correlation propagation probe — records the requestContext it sees at
108
154
  // handler-time so tests can assert the scheduling request's correlationId
109
155
  // made it through BullMQ into the worker process.
@@ -119,6 +165,42 @@ const testFeature = defineFeature("test", (r) => {
119
165
  timestamp: Date.now(),
120
166
  });
121
167
  });
168
+ // Exercises createJobLogger (info/warn/error/debug/child) — otherwise those
169
+ // one-liners stay uncovered even though every job builds a logger.
170
+ r.job("logProbe", { trigger: { manual: true } }, async (payload, ctx) => {
171
+ expect(ctx.log).toBeDefined();
172
+ const log = ctx.log!;
173
+ log.info("log-probe-info", { n: payload["n"] });
174
+ log.warn("log-probe-warn");
175
+ log.error("log-probe-error", { ok: false });
176
+ log.debug("log-probe-debug");
177
+ log.child({ probe: true }).info("log-probe-child");
178
+ jobLog.push({ name: "test:job:log-probe", payload, timestamp: Date.now() });
179
+ });
180
+ // job-runner must build ctx.files the same way dispatch-shared.ts does for
181
+ // write-handlers/MSPs — regression guard for #1520 (ctx.files was never
182
+ // materialized for jobs, so ctx.files.ref(...).read() threw for every job).
183
+ r.job("filesProbe", { trigger: { manual: true } }, async (payload, ctx) => {
184
+ const content = await ctx.files!.ref(payload["key"] as string).read();
185
+ jobLog.push({
186
+ name: "test:job:files-probe",
187
+ payload: { text: new TextDecoder().decode(content) },
188
+ timestamp: Date.now(),
189
+ });
190
+ });
191
+
192
+ // job-runner must build ctx.notify/ctx.config the same way dispatch-shared.ts
193
+ // does for write-handlers — regression guard for #1532 (only ctx.files got
194
+ // the same fix; ctx.notify/ctx.config stayed undefined for every job).
195
+ r.job("notifyProbe", { trigger: { manual: true } }, async (payload, ctx) => {
196
+ await ctx.notify!("test-notification", { to: payload["toUserId"] as string });
197
+ jobLog.push({ name: "test:job:notify-probe", payload, timestamp: Date.now() });
198
+ });
199
+
200
+ r.job("configProbe", { trigger: { manual: true } }, async (_payload, ctx) => {
201
+ const value = await ctx.config!("test:config:probe-key");
202
+ jobLog.push({ name: "test:job:config-probe", payload: { value }, timestamp: Date.now() });
203
+ });
122
204
  });
123
205
 
124
206
  beforeAll(async () => {
@@ -133,6 +215,7 @@ afterAll(async () => {
133
215
  // Helper to create a runner, run tests, then stop
134
216
  async function withRunner(
135
217
  fn: (runner: JobRunner, registry: Registry) => Promise<void>,
218
+ opts?: Partial<JobRunnerOptions>,
136
219
  ): Promise<void> {
137
220
  const registry = createRegistry([testFeature]);
138
221
  const context: AppContext = {};
@@ -145,6 +228,7 @@ async function withRunner(
145
228
  redisUrl,
146
229
  consumerLane: "worker",
147
230
  queueNamePrefix,
231
+ ...opts,
148
232
  });
149
233
 
150
234
  try {
@@ -228,6 +312,75 @@ describe("scenario 3: manual trigger", () => {
228
312
  });
229
313
  });
230
314
 
315
+ describe("scenario 3b: ctx.files resolves through the file provider", () => {
316
+ test("job handler can read back a file via ctx.files.ref(key)", async () => {
317
+ clearLog();
318
+ const provider = createInMemoryFileProvider();
319
+ await provider.write("probe/hello.txt", new TextEncoder().encode("hello from files probe"));
320
+
321
+ await withRunner(
322
+ async (runner) => {
323
+ await runner.dispatch("test:job:files-probe", { key: "probe/hello.txt" });
324
+ await waitFor(() => {
325
+ const entries = jobLog.filter((e) => e.name === "test:job:files-probe");
326
+ expect(entries.length).toBe(1);
327
+ expect(entries[0]?.payload["text"]).toBe("hello from files probe");
328
+ });
329
+ },
330
+ { context: { _fileProviderResolver: () => Promise.resolve(provider) } },
331
+ );
332
+ });
333
+ });
334
+
335
+ describe("scenario 3c: ctx.notify resolves through the notify factory (#1532)", () => {
336
+ test("job handler can call ctx.notify and the factory is bound to the job's system user + tenant", async () => {
337
+ clearLog();
338
+ const calls: Array<{ readonly notifierTenantId: string; readonly notificationType: string }> =
339
+ [];
340
+ const notifyFactory: NotifyFactory = (_user, tenantId) => async (notificationType) => {
341
+ calls.push({ notifierTenantId: tenantId, notificationType });
342
+ };
343
+
344
+ await withRunner(
345
+ async (runner) => {
346
+ await runner.dispatch("test:job:notify-probe", { toUserId: "u1" });
347
+ await waitFor(() => {
348
+ const entries = jobLog.filter((e) => e.name === "test:job:notify-probe");
349
+ expect(entries.length).toBe(1);
350
+ });
351
+ expect(calls).toHaveLength(1);
352
+ expect(calls[0]?.notificationType).toBe("test-notification");
353
+ },
354
+ { context: { _notifyFactory: notifyFactory } },
355
+ );
356
+ });
357
+ });
358
+
359
+ describe("scenario 3d: ctx.config resolves through the config accessor factory (#1532)", () => {
360
+ test("job handler can call ctx.config and the factory receives a tenant-scoped db", async () => {
361
+ clearLog();
362
+ let receivedDb: unknown;
363
+ const configAccessorFactory: ConfigAccessorFactory = (deps) => {
364
+ receivedDb = deps.db;
365
+ // Stub accessor: only the string-key overload is exercised here.
366
+ return (async () => "probe-value") as ConfigAccessor;
367
+ };
368
+
369
+ await withRunner(
370
+ async (runner) => {
371
+ await runner.dispatch("test:job:config-probe", {});
372
+ await waitFor(() => {
373
+ const entries = jobLog.filter((e) => e.name === "test:job:config-probe");
374
+ expect(entries.length).toBe(1);
375
+ expect(entries[0]?.payload["value"]).toBe("probe-value");
376
+ });
377
+ expect(receivedDb).toBeDefined();
378
+ },
379
+ { context: { _configAccessorFactory: configAccessorFactory, db: {} as AppContext["db"] } },
380
+ );
381
+ });
382
+ });
383
+
231
384
  // --- Concurrency modes ---
232
385
 
233
386
  describe("concurrency: parallel", () => {
@@ -410,6 +563,40 @@ describe("concurrency: debounce", () => {
410
563
  });
411
564
  });
412
565
 
566
+ describe("concurrency: replace", () => {
567
+ test("enqueuer-only: each dispatch removes prior waiting peers", async () => {
568
+ // No consumer → jobs stay waiting. The replace branch walks getWaiting()
569
+ // and removes same-name peers before add — so three dispatches leave one.
570
+ const queueNamePrefix = `kumiko-test-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
571
+ await withRunner(
572
+ async (runner) => {
573
+ const id1 = await runner.dispatch("test:job:replace-job", { n: 1 });
574
+ const id2 = await runner.dispatch("test:job:replace-job", { n: 2 });
575
+ const id3 = await runner.dispatch("test:job:replace-job", { n: 3 });
576
+ expect(id1).toBeDefined();
577
+ expect(id2).toBeDefined();
578
+ expect(id3).toBeDefined();
579
+ expect(id1).not.toBe("skipped");
580
+ expect(id3).not.toBe(id1);
581
+
582
+ const queue = new Queue(`${queueNamePrefix}-worker`, {
583
+ connection: { host: testRedis.redis.options.host, port: testRedis.redis.options.port },
584
+ });
585
+ try {
586
+ const waiting = (await queue.getWaiting()).filter(
587
+ (job) => job.name === "test:job:replace-job",
588
+ );
589
+ expect(waiting).toHaveLength(1);
590
+ expect(waiting[0]?.data.n).toBe(3);
591
+ } finally {
592
+ await queue.close();
593
+ }
594
+ },
595
+ { consumerLane: undefined, queueNamePrefix },
596
+ );
597
+ });
598
+ });
599
+
413
600
  describe("concurrency: maxPerTenant", () => {
414
601
  test("max=2: third dispatch for same tenant returns skipped, other tenant unaffected", async () => {
415
602
  clearLog();
@@ -473,6 +660,48 @@ describe("concurrency: maxPerTenant", () => {
473
660
 
474
661
  // --- Correlation propagation ---
475
662
 
663
+ describe("job logger", () => {
664
+ test("handler can call info/warn/error/debug/child without throw", async () => {
665
+ clearLog();
666
+ await withRunner(async (runner) => {
667
+ await runner.dispatch("test:job:log-probe", { n: 1 });
668
+ await waitFor(() => {
669
+ expect(jobLog.some((e) => e.name === "test:job:log-probe")).toBe(true);
670
+ });
671
+ });
672
+ });
673
+
674
+ // Inspects the actual createJobLogger output instead of only asserting the
675
+ // handler ran — a child() that drops its context or a warn() mapped to
676
+ // debug would previously stay green.
677
+ test("info/warn/error land with the right level; debug is dropped; child merges its context", async () => {
678
+ clearLog();
679
+ let capturedLogs: JobLogEntry[] = [];
680
+ await withRunner(
681
+ async (runner) => {
682
+ await runner.dispatch("test:job:log-probe", { n: 1 });
683
+ await waitFor(() => {
684
+ expect(capturedLogs.length).toBeGreaterThan(0);
685
+ });
686
+ },
687
+ {
688
+ onJobComplete: (jobName, _id, _duration, logs) => {
689
+ if (jobName === "test:job:log-probe") capturedLogs = logs;
690
+ },
691
+ },
692
+ );
693
+
694
+ // debug() is a no-op by design (JobLogEntry.level has no "debug" slot) —
695
+ // info/warn/error from the handler body, plus one from child().info(...).
696
+ expect(capturedLogs).toHaveLength(4);
697
+ expect(capturedLogs.map((l) => l.level)).toEqual(["info", "warn", "error", "info"]);
698
+ expect(capturedLogs[0]?.message).toContain("log-probe-info");
699
+ expect(capturedLogs[3]?.message).toContain("log-probe-child");
700
+ expect(capturedLogs[3]?.message).toContain("probe");
701
+ expect(capturedLogs[3]?.message).toContain("true");
702
+ });
703
+ });
704
+
476
705
  describe("correlation propagation", () => {
477
706
  test("dispatch inside requestContext.run passes correlationId into the job", async () => {
478
707
  clearLog();
@@ -534,6 +763,103 @@ describe("error handling", () => {
534
763
  });
535
764
  });
536
765
  });
766
+
767
+ test("onJobFailed receives the handler error on each attempt", async () => {
768
+ clearLog();
769
+ const failures: Array<{ jobName: string; error: string }> = [];
770
+ const starts: JobMeta[] = [];
771
+ await withRunner(
772
+ async (runner) => {
773
+ await runner.dispatch("test:job:failing-job");
774
+ // retries: 1 → attempts = 2 (initial + one retry)
775
+ await waitFor(() => {
776
+ expect(failures.length).toBeGreaterThanOrEqual(2);
777
+ });
778
+ expect(failures.every((f) => f.jobName === "test:job:failing-job")).toBe(true);
779
+ expect(failures.every((f) => f.error === "intentional failure")).toBe(true);
780
+ expect(starts.map((s) => s.attempt)).toEqual([1, 2]);
781
+
782
+ // Worker still alive after exhausted retries
783
+ await runner.dispatch("test:job:manual-report", { after: "retries" });
784
+ await waitFor(() => {
785
+ expect(
786
+ jobLog.some(
787
+ (e) => e.name === "test:job:manual-report" && e.payload["after"] === "retries",
788
+ ),
789
+ ).toBe(true);
790
+ });
791
+ },
792
+ {
793
+ onJobStart: (name, _id, meta) => {
794
+ if (name === "test:job:failing-job") starts.push(meta);
795
+ },
796
+ onJobFailed: (jobName, _id, error) => {
797
+ if (jobName === "test:job:failing-job") failures.push({ jobName, error });
798
+ },
799
+ },
800
+ );
801
+ });
802
+
803
+ test("perTenant without getActiveTenantIds fails wrapper; worker stays alive", async () => {
804
+ clearLog();
805
+ await withRunner(async (runner) => {
806
+ await runner.dispatch("test:job:per-tenant-fanout", { n: 1 });
807
+ // Wrapper throws before fan-out (and before onJobFailed) — settle, then
808
+ // prove the worker still consumes.
809
+ await sleep(300);
810
+ expect(jobLog.filter((e) => e.name === "test:job:per-tenant-fanout")).toHaveLength(0);
811
+
812
+ await runner.dispatch("test:job:manual-report", { after: "perTenant-miss" });
813
+ await waitFor(() => {
814
+ expect(jobLog.some((e) => e.name === "test:job:manual-report")).toBe(true);
815
+ });
816
+ });
817
+ });
818
+
819
+ test("perTenant with getActiveTenantIds fans out one run per tenant", async () => {
820
+ clearLog();
821
+ // @cast-boundary test fixture: TenantId is a branded string.
822
+ const tenants = ["per-tenant-fanout-a", "per-tenant-fanout-b"] as TenantId[];
823
+ await withRunner(
824
+ async (runner) => {
825
+ await runner.dispatch("test:job:per-tenant-fanout", { n: 7 });
826
+ await waitFor(() => {
827
+ const runs = jobLog.filter((e) => e.name === "test:job:per-tenant-fanout");
828
+ expect(runs.length).toBe(2);
829
+ expect(runs.every((e) => e.payload["n"] === 7)).toBe(true);
830
+ });
831
+ },
832
+ { getActiveTenantIds: async () => tenants },
833
+ );
834
+ });
835
+ });
836
+
837
+ describe("handleEvent maxPerTenant", () => {
838
+ test("skips enqueue when tenant is already at the cap", async () => {
839
+ clearLog();
840
+ await withRunner(async (runner) => {
841
+ const user = { ...TestUsers.admin, tenantId: "cap-tenant-evt" };
842
+ await runner.handleEvent("test:write:capped-event", { n: 1 }, user);
843
+ await runner.handleEvent("test:write:capped-event", { n: 2 }, user);
844
+ await waitFor(() => {
845
+ const started = jobLog.filter((e) => e.name === "test:job:per-tenant-limited-event");
846
+ expect(started.length).toBe(2);
847
+ });
848
+ // Third must be skipped by the maxPerTenant guard. handleEvent's cap
849
+ // check runs synchronously before this call resolves, but a wrongly
850
+ // enqueued job still needs the worker to pick it up and push its
851
+ // jobLog entry — polling several times (failing at the first sign of
852
+ // a 3rd start) is more CI-load-tolerant than a single fixed sleep(200)
853
+ // racing against the handler's own 500ms sleep, which could land
854
+ // outside the window on a starved runner and silently pass.
855
+ await runner.handleEvent("test:write:capped-event", { n: 3 }, user);
856
+ for (const delayMs of [50, 100, 200, 400]) {
857
+ await sleep(delayMs);
858
+ const startedNow = jobLog.filter((e) => e.name === "test:job:per-tenant-limited-event");
859
+ expect(startedNow.length).toBe(2);
860
+ }
861
+ });
862
+ });
537
863
  });
538
864
 
539
865
  // --- Registry ---
@@ -1,7 +1,8 @@
1
1
  import { type Job, Queue, Worker } from "bullmq";
2
2
  import { Redis } from "ioredis";
3
3
  import { requestContext } from "../api/request-context";
4
- import type { DbRow } from "../db/connection";
4
+ import type { DbConnection, DbRow } from "../db/connection";
5
+ import { createTenantDb } from "../db/tenant-db";
5
6
  import { createSystemUser } from "../engine/system-user";
6
7
  import {
7
8
  type AppContext,
@@ -9,9 +10,17 @@ import {
9
10
  type Registry,
10
11
  type SessionUser,
11
12
  SYSTEM_TENANT_ID,
13
+ type TenantId,
12
14
  } from "../engine/types";
15
+ import { createFileContext } from "../files/file-handle";
13
16
  import type { Logger } from "../logging/types";
14
- import { getFallbackTracer, type SerializedTraceContext, type Tracer } from "../observability";
17
+ import {
18
+ emitJobQueueDepth,
19
+ getFallbackTracer,
20
+ type Meter,
21
+ type SerializedTraceContext,
22
+ type Tracer,
23
+ } from "../observability";
15
24
  import { createDistributedLock, type DistributedLock } from "../pipeline/distributed-lock";
16
25
  import { RedisKeys } from "../pipeline/redis-keys";
17
26
 
@@ -31,9 +40,10 @@ export type JobLogEntry = {
31
40
  timestamp: Temporal.Instant;
32
41
  };
33
42
 
34
- function createJobLogger(logs: JobLogEntry[]): Logger {
43
+ function createJobLogger(logs: JobLogEntry[], baseContext: Record<string, unknown> = {}): Logger {
35
44
  function push(level: "info" | "warn" | "error", msg: string, data?: Record<string, unknown>) {
36
- const message = data ? `${msg} ${JSON.stringify(data)}` : msg;
45
+ const merged = { ...baseContext, ...data };
46
+ const message = Object.keys(merged).length > 0 ? `${msg} ${JSON.stringify(merged)}` : msg;
37
47
  logs.push({ level, message, timestamp: Temporal.Now.instant() });
38
48
  }
39
49
  const logger: Logger = {
@@ -46,9 +56,13 @@ function createJobLogger(logs: JobLogEntry[]): Logger {
46
56
  error(msg, data) {
47
57
  push("error", msg, data);
48
58
  },
59
+ // No-op by design, not missing coverage: JobLogEntry.level (and the
60
+ // persisted run-completed/-failed event schema in bundled-features/
61
+ // jobs/events.ts) only has info|warn|error — a job's debug() calls have
62
+ // nowhere durable to land.
49
63
  debug() {},
50
- child() {
51
- return logger;
64
+ child(context) {
65
+ return createJobLogger(logs, { ...baseContext, ...context });
52
66
  },
53
67
  };
54
68
  return logger;
@@ -94,7 +108,7 @@ export type JobRunnerOptions = {
94
108
  // Tests set a unique prefix (e.g. `"test-${Date.now()}"`) for isolation —
95
109
  // two parallel test-runners never see each other's jobs.
96
110
  queueNamePrefix?: string | undefined;
97
- getActiveTenantIds?: () => Promise<number[]>;
111
+ getActiveTenantIds?: () => Promise<TenantId[]>;
98
112
  onJobStart?: (jobName: string, jobId: string, meta: JobMeta) => void;
99
113
  onJobComplete?: (jobName: string, jobId: string, duration: number, logs: JobLogEntry[]) => void;
100
114
  onJobFailed?: (jobName: string, jobId: string, error: string, logs: JobLogEntry[]) => void;
@@ -119,6 +133,29 @@ function captureTraceContext(tracer: Tracer): SerializedTraceContext | undefined
119
133
  return { traceId: span.traceId, spanId: span.spanId };
120
134
  }
121
135
 
136
+ const QUEUE_DEPTH_POLL_INTERVAL_MS = 15_000;
137
+
138
+ // kumiko_job_queue_depth: only the lane's own consumer polls — the count is
139
+ // a global Redis-backed value (BullMQ, not per-process), so one reporter per
140
+ // lane is enough. Fires once immediately (metrics exist before the first
141
+ // poll tick) then on an interval; the caller stores + clears the handle.
142
+ async function startQueueDepthPolling(
143
+ queue: Queue,
144
+ lane: JobRunIn,
145
+ meter: Meter,
146
+ ): Promise<ReturnType<typeof setInterval>> {
147
+ const pollQueueDepth = async (): Promise<void> => {
148
+ try {
149
+ const counts = await queue.getJobCounts("waiting", "active", "delayed", "failed", "paused");
150
+ emitJobQueueDepth(meter, lane, counts);
151
+ } catch {
152
+ // skip: transient Redis hiccup — next poll retries
153
+ }
154
+ };
155
+ await pollQueueDepth();
156
+ return setInterval(() => void pollQueueDepth(), QUEUE_DEPTH_POLL_INTERVAL_MS);
157
+ }
158
+
122
159
  function parseRedisOpts(url: string): { host: string; port: number; db?: number | undefined } {
123
160
  const parsed = new URL(url);
124
161
  const result: { host: string; port: number; db?: number | undefined } = {
@@ -181,6 +218,7 @@ export function createJobRunner(options: JobRunnerOptions): JobRunner {
181
218
  worker: new Queue(queueNameFor(queueNamePrefix, "worker"), { connection: redisOpts }),
182
219
  };
183
220
  let worker: Worker | null = null;
221
+ let queueDepthTimer: ReturnType<typeof setInterval> | null = null;
184
222
  // Forward reference to the runner's own API, exposed on the job-handler ctx
185
223
  // so a handler can dispatch a follow-up job (job→job chaining, e.g.
186
224
  // delivery.render → delivery.send). Assigned just before return; reads happen
@@ -301,13 +339,35 @@ export function createJobRunner(options: JobRunnerOptions): JobRunner {
301
339
  SYSTEM_TENANT_ID;
302
340
  const triggeredById = (rawData["_triggeredById"] as string | undefined) ?? null; // @cast-boundary dynamic-key
303
341
 
304
- // _triggerName aus rawData übernehmen falls gesetzt — handleEvent
305
- // packt das beim Multi-Trigger-Dispatch rein (siehe unten). Über
306
- // jobContext.triggerName freigegeben damit der Handler nicht selbst
307
- // im rohen Payload kramen muss.
342
+ // Carry `_triggerName` from rawData when set — handleEvent injects it on
343
+ // multi-trigger dispatch; exposed as jobContext.triggerName so handlers
344
+ // don't dig through the raw payload themselves.
308
345
  const triggerName = rawData["_triggerName"] as string | undefined; // @cast-boundary dynamic-key
346
+ // Mirror dispatch-shared.ts buildHandlerContext: ctx.files must resolve
347
+ // through the same _fileProviderResolver for jobs as for write-handlers,
348
+ // otherwise event-triggered jobs silently get an unresolved ctx.files.
349
+ const fileResolver = context._fileProviderResolver;
350
+ const files = fileResolver ? createFileContext(() => fileResolver(tenantId)) : context.files;
351
+ const jobSystemUser = createSystemUser(tenantId);
352
+ // Same buildHandlerContext parity as ctx.files above: ctx.notify/ctx.config
353
+ // must resolve for jobs the same way they do for write-handlers, or an
354
+ // app-author job calling ctx.notify(...)/ctx.config(...) hits a TypeError
355
+ // at runtime the write-handler path never would (framework#1532).
356
+ const notify = context._notifyFactory?.(jobSystemUser, tenantId);
357
+ const configDb = context.db as DbConnection | undefined; // @cast-boundary db-operator
358
+ const config =
359
+ context._configAccessorFactory && configDb
360
+ ? context._configAccessorFactory({
361
+ user: { id: jobSystemUser.id, tenantId },
362
+ db: createTenantDb(configDb, tenantId, "system"),
363
+ secrets: context.secrets,
364
+ })
365
+ : undefined;
309
366
  const jobContext: AppContext = {
310
367
  ...context,
368
+ files,
369
+ ...(notify !== undefined && { notify }),
370
+ ...(config !== undefined && { config }),
311
371
  // The runner owns the registry it resolved this job from — expose it so
312
372
  // workers can reach projections/jobs without the app author duplicating
313
373
  // it into `context` (the JobContext contract guarantees `registry`).
@@ -315,7 +375,7 @@ export function createJobRunner(options: JobRunnerOptions): JobRunner {
315
375
  // Expose the runner so handlers can chain a follow-up job. Symmetric with
316
376
  // how the command-dispatcher hands write-handlers their jobRunner.
317
377
  ...(selfRunner !== undefined && { jobRunner: selfRunner }),
318
- systemUser: createSystemUser(tenantId),
378
+ systemUser: jobSystemUser,
319
379
  triggeredBy: triggeredById !== null ? { id: triggeredById, tenantId } : null,
320
380
  log: createJobLogger(logs),
321
381
  ...(triggerName !== undefined && { triggerName }),
@@ -431,9 +491,19 @@ export function createJobRunner(options: JobRunnerOptions): JobRunner {
431
491
  await consumerQueue.add(bootName, {}, { jobId: `boot-${name.replace(/\./g, "-")}` });
432
492
  }
433
493
  }
494
+
495
+ // Skipped when no meter is wired (context.meter is optional, e.g. in
496
+ // tests without an observability provider).
497
+ if (context.meter) {
498
+ queueDepthTimer = await startQueueDepthPolling(consumerQueue, consumerLane, context.meter);
499
+ }
434
500
  },
435
501
 
436
502
  async stop(): Promise<void> {
503
+ if (queueDepthTimer) {
504
+ clearInterval(queueDepthTimer);
505
+ queueDepthTimer = null;
506
+ }
437
507
  if (worker) {
438
508
  await worker.close();
439
509
  worker = null;
@@ -1,7 +1 @@
1
- export type Logger = {
2
- info(msg: string, data?: Record<string, unknown>): void;
3
- warn(msg: string, data?: Record<string, unknown>): void;
4
- error(msg: string, data?: Record<string, unknown>): void;
5
- debug(msg: string, data?: Record<string, unknown>): void;
6
- child(context: Record<string, unknown>): Logger;
7
- };
1
+ export type * from "@cosmicdrift/kumiko-types/logger";
@@ -200,7 +200,7 @@ export type EnqueueProjectionRebuildDeps = {
200
200
  readonly db: DbConnection;
201
201
  readonly registry: Registry;
202
202
  // Present + projection-rebuild job registered (jobs composed) → tracked,
203
- // retryable job (read_job_runs + read_job_run_logs). Absent → inline rebuild.
203
+ // retryable job (read_job_runs + store_job_run_logs). Absent → inline rebuild.
204
204
  readonly jobRunner?: JobRunner;
205
205
  };
206
206
 
@@ -1,5 +1,6 @@
1
1
  import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from "bun:test";
2
2
  import { z } from "zod";
3
+ import type { TableColumns } from "../../db/dialect";
3
4
  import { buildEntityTable } from "../../db/table-builder";
4
5
  import { createRegistry, defineFeature } from "../../engine";
5
6
  import type { AppContext, SaveContext } from "../../engine/types";
@@ -65,7 +66,9 @@ const todoEntity = {
65
66
 
66
67
  let postSaveInvocations = 0;
67
68
  const todoFeature = defineFeature("todo", (r) => {
68
- const todoTable = buildEntityTable("todo", todoEntity);
69
+ // Brand (#742) is compile-time-only; the unbranded TableColumns view keeps the
70
+ // method-form insert (and its observability span) working — runtime shape is identical.
71
+ const todoTable: TableColumns = buildEntityTable("todo", todoEntity);
69
72
  r.entity("todo", todoEntity);
70
73
 
71
74
  r.writeHandler(
@@ -87,11 +87,13 @@ describe("RecordingTracer", () => {
87
87
  expect(recorded).toHaveLength(1);
88
88
  });
89
89
 
90
- it("startSpanFromContext continues an upstream trace", () => {
90
+ it("startSpan with parent context continues an upstream trace", () => {
91
91
  const { tracer, recorded } = makeTracer();
92
- const span = tracer.startSpanFromContext("child", {
93
- traceId: "aabbccddeeff00112233445566778899",
94
- spanId: "1122334455667788",
92
+ const span = tracer.startSpan("child", {
93
+ parent: {
94
+ traceId: "aabbccddeeff00112233445566778899",
95
+ spanId: "1122334455667788",
96
+ },
95
97
  });
96
98
  span.end();
97
99
  expect(recorded[0]?.traceId).toBe("aabbccddeeff00112233445566778899");
@@ -50,8 +50,10 @@ export {
50
50
  emitDispatcherHandler,
51
51
  emitEventConsumerLag,
52
52
  emitEventConsumerPassOutcome,
53
+ emitEventConsumerRearmExhausted,
53
54
  emitEventDispatcherListenConnected,
54
55
  emitHttpRequest,
56
+ emitJobQueueDepth,
55
57
  registerStandardMetrics,
56
58
  STANDARD_METRIC_DEFS,
57
59
  } from "./standard-metrics";
@@ -5,7 +5,6 @@ import type {
5
5
  Meter,
6
6
  MetricDefinition,
7
7
  ObservabilityProvider,
8
- SerializedTraceContext,
9
8
  Span,
10
9
  SpanStatus,
11
10
  StartSpanOptions,
@@ -68,14 +67,6 @@ class NoopTracer implements Tracer {
68
67
  getActiveSpan(): Span | undefined {
69
68
  return undefined;
70
69
  }
71
-
72
- startSpanFromContext(
73
- name: string,
74
- _context: SerializedTraceContext,
75
- _options?: StartSpanOptions,
76
- ): Span {
77
- return new NoopSpan(name, undefined);
78
- }
79
70
  }
80
71
 
81
72
  class NoopCounter implements Counter {
@@ -175,18 +175,6 @@ export class RecordingTracer implements Tracer {
175
175
  getActiveSpan(): Span | undefined {
176
176
  return observabilityContext.getActiveSpan();
177
177
  }
178
-
179
- /**
180
- * @deprecated Prefer `startSpan(name, { parent: context })`. Retained as a
181
- * thin alias for call-sites that pre-date the unified StartSpanOptions.
182
- */
183
- startSpanFromContext(
184
- name: string,
185
- context: SerializedTraceContext,
186
- options?: StartSpanOptions,
187
- ): Span {
188
- return this.startSpan(name, { ...options, parent: context });
189
- }
190
178
  }
191
179
 
192
180
  // Helper to serialize an active Span into the cross-process format.