@cosmicdrift/kumiko-framework 0.387.0 → 0.389.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 (375) hide show
  1. package/AGENTS.md +9 -1
  2. package/agents/examples/anonymous-access.md +1 -1
  3. package/agents/examples/apex-landing.md +204 -0
  4. package/agents/examples/api-worker-split.md +64 -0
  5. package/agents/examples/app-config.md +14 -0
  6. package/agents/examples/auth-mfa.md +115 -0
  7. package/agents/examples/env-composition.md +28 -1
  8. package/agents/examples/golden-path.md +13 -1
  9. package/agents/examples/legal-pages.md +1 -1
  10. package/agents/examples/lifecycle-hooks.md +55 -2
  11. package/agents/examples/list-table-options.md +466 -0
  12. package/agents/examples/pdf-audit-export.md +4 -2
  13. package/agents/examples/prod-boot-options.md +16 -0
  14. package/agents/examples/rate-limiting.md +48 -0
  15. package/agents/examples/record-detail-layout.md +692 -0
  16. package/agents/examples/seed-migration.md +212 -0
  17. package/agents/examples/state-machine.md +3 -2
  18. package/agents/examples/tenant-isolation.md +73 -1
  19. package/agents/examples/wizard-form.md +941 -0
  20. package/agents/examples/writeform-section.md +548 -0
  21. package/agents/examples.md +1 -0
  22. package/agents/features.md +19 -2
  23. package/agents/widgets.md +2 -0
  24. package/dist/api/api-constants.d.ts +1 -0
  25. package/dist/api/api-constants.js +2 -0
  26. package/dist/api/auth-routes-shared.d.ts +2 -0
  27. package/dist/api/auth-routes-shared.js +5 -0
  28. package/dist/api/client-error-route.js +10 -5
  29. package/dist/api/entity-read-access.d.ts +2 -1
  30. package/dist/api/entity-read-access.js +14 -0
  31. package/dist/api/export/export-cells-reference.d.ts +9 -0
  32. package/dist/api/export/export-cells-reference.js +21 -0
  33. package/dist/api/export/export-cells.d.ts +11 -0
  34. package/dist/api/export/export-cells.js +136 -0
  35. package/dist/api/export/export-ciphertext-guard.d.ts +2 -0
  36. package/dist/api/export/export-ciphertext-guard.js +27 -0
  37. package/dist/api/export/export-columns.d.ts +29 -0
  38. package/dist/api/export/export-columns.js +85 -0
  39. package/dist/api/export/export-errors.d.ts +4 -0
  40. package/dist/api/export/export-errors.js +14 -0
  41. package/dist/api/export/export-route.d.ts +4 -0
  42. package/dist/api/export/export-route.js +74 -0
  43. package/dist/api/export/export-rows.d.ts +13 -0
  44. package/dist/api/export/export-rows.js +62 -0
  45. package/dist/api/export/export-screen.d.ts +12 -0
  46. package/dist/api/export/export-screen.js +47 -0
  47. package/dist/api/export/export-xlsx-writer.d.ts +4 -0
  48. package/dist/api/export/export-xlsx-writer.js +21 -0
  49. package/dist/api/extra-route.d.ts +4 -0
  50. package/dist/api/index.d.ts +3 -1
  51. package/dist/api/index.js +3 -1
  52. package/dist/api/pii-leak-guard.d.ts +3 -0
  53. package/dist/api/pii-leak-guard.js +3 -2
  54. package/dist/api/request-context.d.ts +1 -1
  55. package/dist/api/request-context.js +1 -1
  56. package/dist/api/routes.d.ts +16 -0
  57. package/dist/api/routes.js +2 -2
  58. package/dist/api/server-boot-checks.d.ts +10 -0
  59. package/dist/api/server-boot-checks.js +157 -0
  60. package/dist/api/server-event-dispatcher.d.ts +26 -0
  61. package/dist/api/server-event-dispatcher.js +208 -0
  62. package/dist/api/server-extra-routes.d.ts +11 -0
  63. package/dist/api/server-extra-routes.js +206 -0
  64. package/dist/api/server-middleware.d.ts +20 -0
  65. package/dist/api/server-middleware.js +211 -0
  66. package/dist/api/server-routes.d.ts +19 -0
  67. package/dist/api/server-routes.js +299 -0
  68. package/dist/api/server-websocket-routes.d.ts +4 -0
  69. package/dist/api/server-websocket-routes.js +108 -0
  70. package/dist/api/server-wiring.d.ts +32 -0
  71. package/dist/api/server-wiring.js +238 -0
  72. package/dist/api/server.d.ts +10 -15
  73. package/dist/api/server.js +48 -1251
  74. package/dist/api/session-cookies.js +4 -4
  75. package/dist/api/sse-route.d.ts +2 -1
  76. package/dist/api/sse-route.js +27 -13
  77. package/dist/config/index.d.ts +3 -1
  78. package/dist/crypto/owner-transition-rekey.d.ts +5 -1
  79. package/dist/crypto/owner-transition-rekey.js +1 -1
  80. package/dist/crypto/pg-kms-adapter.d.ts +1 -0
  81. package/dist/crypto/pg-kms-adapter.js +12 -18
  82. package/dist/crypto/pii-field-encryption.d.ts +5 -1
  83. package/dist/crypto/pii-field-encryption.js +3 -1
  84. package/dist/db/aggregate-sql.d.ts +23 -0
  85. package/dist/db/aggregate-sql.js +165 -0
  86. package/dist/db/closed-connection.d.ts +9 -0
  87. package/dist/db/closed-connection.js +88 -0
  88. package/dist/db/connection.d.ts +4 -5
  89. package/dist/db/connection.js +29 -20
  90. package/dist/db/dialect.js +1 -1
  91. package/dist/db/distinct-on.d.ts +1 -1
  92. package/dist/db/driver-row-coercion.d.ts +2 -0
  93. package/dist/db/driver-row-coercion.js +186 -0
  94. package/dist/db/driver-value-preparation.d.ts +16 -0
  95. package/dist/db/driver-value-preparation.js +101 -0
  96. package/dist/db/entity-field-encryption.d.ts +1 -0
  97. package/dist/db/entity-field-encryption.js +1 -1
  98. package/dist/db/entity-table-meta.js +103 -110
  99. package/dist/db/event-store-executor-context.d.ts +2 -1
  100. package/dist/db/event-store-executor-read-aggregate-currencies.d.ts +17 -0
  101. package/dist/db/event-store-executor-read-aggregate-currencies.js +40 -0
  102. package/dist/db/event-store-executor-read-aggregate.d.ts +8 -0
  103. package/dist/db/event-store-executor-read-aggregate.js +143 -0
  104. package/dist/db/event-store-executor-read-filter.d.ts +31 -0
  105. package/dist/db/event-store-executor-read-filter.js +206 -0
  106. package/dist/db/event-store-executor-read-paging.d.ts +14 -0
  107. package/dist/db/event-store-executor-read-paging.js +99 -0
  108. package/dist/db/event-store-executor-read-rows.d.ts +6 -0
  109. package/dist/db/event-store-executor-read-rows.js +32 -0
  110. package/dist/db/event-store-executor-read-search.d.ts +28 -0
  111. package/dist/db/event-store-executor-read-search.js +221 -0
  112. package/dist/db/event-store-executor-read-sort.d.ts +41 -0
  113. package/dist/db/event-store-executor-read-sort.js +130 -0
  114. package/dist/db/event-store-executor-read-where.d.ts +24 -0
  115. package/dist/db/event-store-executor-read-where.js +81 -0
  116. package/dist/db/event-store-executor-read.d.ts +2 -9
  117. package/dist/db/event-store-executor-read.js +48 -701
  118. package/dist/db/event-store-executor-write-atomic.d.ts +4 -0
  119. package/dist/db/event-store-executor-write-atomic.js +41 -0
  120. package/dist/db/event-store-executor-write-steps.d.ts +17 -0
  121. package/dist/db/event-store-executor-write-steps.js +95 -0
  122. package/dist/db/event-store-executor-write.js +405 -509
  123. package/dist/db/event-store-executor.js +2 -1
  124. package/dist/db/executor-managed-tables.js +1 -1
  125. package/dist/db/index.d.ts +7 -3
  126. package/dist/db/index.js +6 -2
  127. package/dist/db/list-aggregate-labels.d.ts +14 -0
  128. package/dist/db/list-aggregate-labels.js +26 -0
  129. package/dist/db/migrate-generator.js +71 -86
  130. package/dist/db/migrate-runner.js +7 -4
  131. package/dist/db/pg-error.js +26 -21
  132. package/dist/db/queries/backfill-pii.js +3 -6
  133. package/dist/db/queries/database-now.js +2 -1
  134. package/dist/db/queries/shadow-swap.js +2 -3
  135. package/dist/db/query.d.ts +16 -15
  136. package/dist/db/query.js +76 -804
  137. package/dist/db/reference-data.js +19 -2
  138. package/dist/db/statement-timeout.js +2 -1
  139. package/dist/db/table-builder.js +113 -166
  140. package/dist/db/table-info.d.ts +10 -0
  141. package/dist/db/table-info.js +45 -0
  142. package/dist/db/temporal-value-guards.d.ts +6 -0
  143. package/dist/db/temporal-value-guards.js +15 -0
  144. package/dist/db/tenant-db-runner.d.ts +1 -0
  145. package/dist/db/tenant-db-runner.js +3 -0
  146. package/dist/db/tenant-db-upsert.d.ts +5 -0
  147. package/dist/db/tenant-db-upsert.js +91 -0
  148. package/dist/db/tenant-db.js +50 -10
  149. package/dist/db/where-clause.d.ts +6 -0
  150. package/dist/db/where-clause.js +236 -0
  151. package/dist/derivatives/delete-stored-file.js +2 -1
  152. package/dist/engine/boot-validator/boot-check.js +1 -1
  153. package/dist/engine/boot-validator/bundled-lists-table.d.ts +2 -0
  154. package/dist/engine/boot-validator/bundled-lists-table.js +36 -0
  155. package/dist/engine/boot-validator/detail-screens.js +2 -2
  156. package/dist/engine/boot-validator/entity-handler.d.ts +1 -0
  157. package/dist/engine/boot-validator/entity-handler.js +17 -0
  158. package/dist/engine/boot-validator/entity-list-aggregate-options.d.ts +3 -0
  159. package/dist/engine/boot-validator/entity-list-aggregate-options.js +23 -0
  160. package/dist/engine/boot-validator/form-derivations.js +35 -1
  161. package/dist/engine/boot-validator/i18n-keys.js +5 -3
  162. package/dist/engine/boot-validator/index.js +2 -0
  163. package/dist/engine/boot-validator/list-bulk-actions.d.ts +3 -0
  164. package/dist/engine/boot-validator/list-bulk-actions.js +49 -0
  165. package/dist/engine/boot-validator/list-column-layout.d.ts +8 -0
  166. package/dist/engine/boot-validator/list-column-layout.js +41 -0
  167. package/dist/engine/boot-validator/list-table-aggregate-options.d.ts +24 -0
  168. package/dist/engine/boot-validator/list-table-aggregate-options.js +145 -0
  169. package/dist/engine/boot-validator/list-table-aggregate-query.d.ts +10 -0
  170. package/dist/engine/boot-validator/list-table-aggregate-query.js +31 -0
  171. package/dist/engine/boot-validator/list-table-export-options.d.ts +1 -0
  172. package/dist/engine/boot-validator/list-table-export-options.js +27 -0
  173. package/dist/engine/boot-validator/list-table-options.d.ts +3 -0
  174. package/dist/engine/boot-validator/list-table-options.js +77 -0
  175. package/dist/engine/boot-validator/list-table-reject-unknown-keys.d.ts +1 -0
  176. package/dist/engine/boot-validator/list-table-reject-unknown-keys.js +7 -0
  177. package/dist/engine/boot-validator/pii-retention.js +233 -155
  178. package/dist/engine/boot-validator/projection-list-screens.d.ts +1 -1
  179. package/dist/engine/boot-validator/projection-list-screens.js +3 -13
  180. package/dist/engine/boot-validator/related-list-table-rules.d.ts +4 -0
  181. package/dist/engine/boot-validator/related-list-table-rules.js +42 -0
  182. package/dist/engine/boot-validator/screens-actions.d.ts +1 -0
  183. package/dist/engine/boot-validator/screens-actions.js +8 -1
  184. package/dist/engine/boot-validator/screens-dashboard.js +2 -5
  185. package/dist/engine/boot-validator/screens-forms.js +2 -6
  186. package/dist/engine/boot-validator/screens-kind-config-edit.d.ts +3 -0
  187. package/dist/engine/boot-validator/screens-kind-config-edit.js +88 -0
  188. package/dist/engine/boot-validator/screens-kind-entity-edit.d.ts +3 -0
  189. package/dist/engine/boot-validator/screens-kind-entity-edit.js +55 -0
  190. package/dist/engine/boot-validator/screens-kind-entity-list.d.ts +3 -0
  191. package/dist/engine/boot-validator/screens-kind-entity-list.js +235 -0
  192. package/dist/engine/boot-validator/screens-kind-projection-detail.d.ts +3 -0
  193. package/dist/engine/boot-validator/screens-kind-projection-detail.js +235 -0
  194. package/dist/engine/boot-validator/screens-kind-projection-list.d.ts +3 -0
  195. package/dist/engine/boot-validator/screens-kind-projection-list.js +56 -0
  196. package/dist/engine/boot-validator/screens-kind-secret-mint.d.ts +3 -0
  197. package/dist/engine/boot-validator/screens-kind-secret-mint.js +28 -0
  198. package/dist/engine/boot-validator/screens-kind-shared.d.ts +37 -0
  199. package/dist/engine/boot-validator/screens-kind-shared.js +142 -0
  200. package/dist/engine/boot-validator/screens-lists.d.ts +2 -1
  201. package/dist/engine/boot-validator/screens-lists.js +24 -11
  202. package/dist/engine/boot-validator/screens.d.ts +0 -2
  203. package/dist/engine/boot-validator/screens.js +99 -860
  204. package/dist/engine/build-app-schema.d.ts +2 -1
  205. package/dist/engine/build-app-schema.js +65 -97
  206. package/dist/engine/build-config-feature-schema.js +6 -1
  207. package/dist/engine/define-feature.js +13 -2
  208. package/dist/engine/define-handler.js +1 -0
  209. package/dist/engine/entity-handlers.d.ts +13 -0
  210. package/dist/engine/entity-handlers.js +10 -21
  211. package/dist/engine/entity-list-runtime-options.d.ts +8 -0
  212. package/dist/engine/entity-list-runtime-options.js +18 -0
  213. package/dist/engine/feature-ast/extractors/handlers.js +1 -0
  214. package/dist/engine/feature-ast/extractors/index.d.ts +1 -1
  215. package/dist/engine/feature-ast/extractors/index.js +1 -1
  216. package/dist/engine/feature-ast/extractors/round1.d.ts +2 -1
  217. package/dist/engine/feature-ast/extractors/round1.js +6 -0
  218. package/dist/engine/feature-ast/extractors/round2.js +25 -6
  219. package/dist/engine/feature-ast/index.d.ts +1 -1
  220. package/dist/engine/feature-ast/parse.js +3 -1
  221. package/dist/engine/feature-ast/patch.d.ts +2 -0
  222. package/dist/engine/feature-ast/patch.js +5 -182
  223. package/dist/engine/feature-ast/pattern-change-schema.d.ts +10 -245
  224. package/dist/engine/feature-ast/pattern-change-schema.js +10 -163
  225. package/dist/engine/feature-ast/pattern-id-matchers.d.ts +7 -0
  226. package/dist/engine/feature-ast/pattern-id-matchers.js +211 -0
  227. package/dist/engine/feature-ast/pattern-id-schema.d.ts +251 -0
  228. package/dist/engine/feature-ast/pattern-id-schema.js +166 -0
  229. package/dist/engine/feature-ast/patterns.d.ts +14 -1
  230. package/dist/engine/feature-ast/patterns.js +44 -51
  231. package/dist/engine/feature-ast/render.js +52 -87
  232. package/dist/engine/feature-builder-state.d.ts +1 -0
  233. package/dist/engine/feature-builder-state.js +1 -0
  234. package/dist/engine/feature-entity-handlers.js +3 -0
  235. package/dist/engine/feature-manifest.d.ts +1 -0
  236. package/dist/engine/feature-manifest.js +1 -0
  237. package/dist/engine/field-access.d.ts +1 -0
  238. package/dist/engine/field-access.js +12 -0
  239. package/dist/engine/form-derivations.js +18 -7
  240. package/dist/engine/handler-field-references.js +1 -1
  241. package/dist/engine/index.d.ts +2 -2
  242. package/dist/engine/index.js +1 -1
  243. package/dist/engine/pattern-library/library.js +2 -1
  244. package/dist/engine/pattern-library/static-schemas.d.ts +1 -0
  245. package/dist/engine/pattern-library/static-schemas.js +9 -0
  246. package/dist/engine/project-app-schema-for-roles.d.ts +2 -0
  247. package/dist/engine/project-app-schema-for-roles.js +6 -4
  248. package/dist/engine/registry-ingest.js +1 -1
  249. package/dist/engine/schema-builder.js +228 -212
  250. package/dist/engine/screen-filter-ops.js +2 -0
  251. package/dist/engine/screen-helpers.js +1 -1
  252. package/dist/engine/screen-kind.d.ts +7 -0
  253. package/dist/engine/screen-kind.js +1 -0
  254. package/dist/engine/strip-disabled-features-from-app-schema.js +58 -17
  255. package/dist/engine/types/feature.d.ts +1 -0
  256. package/dist/engine/types/index.d.ts +4 -3
  257. package/dist/engine/types/screen.d.ts +1 -1
  258. package/dist/engine/types/screen.js +1 -1
  259. package/dist/engine/validate-projection-allowlist.js +2 -1
  260. package/dist/entrypoint/index.d.ts +6 -1
  261. package/dist/entrypoint/index.js +10 -1
  262. package/dist/errors/docs-url.d.ts +1 -1
  263. package/dist/errors/docs-url.js +22 -2
  264. package/dist/es-ops/context.js +1 -1
  265. package/dist/event-store/admin-api.js +1 -1
  266. package/dist/event-store/event-store.d.ts +1 -0
  267. package/dist/event-store/event-store.js +17 -8
  268. package/dist/event-store/provenance-append.js +1 -1
  269. package/dist/files/bind-pending-file-refs.js +3 -3
  270. package/dist/files/create-file-ref-from-bytes.js +7 -1
  271. package/dist/files/create-file-ref.d.ts +3 -0
  272. package/dist/files/create-file-ref.js +11 -2
  273. package/dist/files/file-handle.js +2 -3
  274. package/dist/files/file-routes.d.ts +2 -0
  275. package/dist/files/file-routes.js +40 -10
  276. package/dist/files/provider-resolver.d.ts +1 -0
  277. package/dist/files/provider-resolver.js +1 -1
  278. package/dist/files/record-write-check.d.ts +6 -0
  279. package/dist/files/record-write-check.js +13 -0
  280. package/dist/files/resolve-content-type.js +43 -0
  281. package/dist/files/upload-ledger.d.ts +2 -2
  282. package/dist/files/upload-ledger.js +11 -4
  283. package/dist/i18n/required-surface-keys.js +189 -208
  284. package/dist/internal/stack-support.d.ts +2 -1
  285. package/dist/internal/stack-support.js +1 -1
  286. package/dist/internal/testing.js +1 -1
  287. package/dist/jobs/job-runner-context.d.ts +32 -0
  288. package/dist/jobs/job-runner-context.js +305 -0
  289. package/dist/jobs/job-runner-execution.d.ts +7 -0
  290. package/dist/jobs/job-runner-execution.js +160 -0
  291. package/dist/jobs/job-runner-gates.d.ts +19 -0
  292. package/dist/jobs/job-runner-gates.js +198 -0
  293. package/dist/jobs/job-runner-helpers.d.ts +17 -3
  294. package/dist/jobs/job-runner-helpers.js +61 -2
  295. package/dist/jobs/job-runner-scope.d.ts +44 -0
  296. package/dist/jobs/job-runner-scope.js +102 -0
  297. package/dist/jobs/job-runner-types.d.ts +12 -0
  298. package/dist/jobs/job-runner.d.ts +0 -1
  299. package/dist/jobs/job-runner.js +50 -657
  300. package/dist/observability/escape-hatch-report.d.ts +3 -0
  301. package/dist/observability/escape-hatch-report.js +21 -3
  302. package/dist/pipeline/after-rollback-sink.d.ts +6 -0
  303. package/dist/pipeline/after-rollback-sink.js +32 -0
  304. package/dist/pipeline/cascade-handler.js +165 -138
  305. package/dist/pipeline/dispatch-batch.js +8 -1
  306. package/dist/pipeline/dispatch-query.js +10 -3
  307. package/dist/pipeline/dispatch-shared.d.ts +13 -3
  308. package/dist/pipeline/dispatch-shared.js +62 -710
  309. package/dist/pipeline/dispatch-write.js +296 -189
  310. package/dist/pipeline/event-dispatcher-admin.js +2 -1
  311. package/dist/pipeline/event-dispatcher-delivery.js +6 -6
  312. package/dist/pipeline/event-dispatcher.d.ts +1 -0
  313. package/dist/pipeline/event-dispatcher.js +6 -9
  314. package/dist/pipeline/handler-context-auth.d.ts +9 -0
  315. package/dist/pipeline/handler-context-auth.js +54 -0
  316. package/dist/pipeline/handler-context-config.d.ts +14 -0
  317. package/dist/pipeline/handler-context-config.js +113 -0
  318. package/dist/pipeline/handler-context-db.d.ts +24 -0
  319. package/dist/pipeline/handler-context-db.js +149 -0
  320. package/dist/pipeline/handler-context-dispatch.d.ts +23 -0
  321. package/dist/pipeline/handler-context-dispatch.js +101 -0
  322. package/dist/pipeline/handler-context-events.d.ts +41 -0
  323. package/dist/pipeline/handler-context-events.js +239 -0
  324. package/dist/pipeline/handler-context-notify.d.ts +12 -0
  325. package/dist/pipeline/handler-context-notify.js +55 -0
  326. package/dist/pipeline/handler-context-observability.d.ts +7 -0
  327. package/dist/pipeline/handler-context-observability.js +21 -0
  328. package/dist/pipeline/handler-context-scope.d.ts +48 -0
  329. package/dist/pipeline/handler-context-scope.js +88 -0
  330. package/dist/pipeline/notify-scope.js +12 -1
  331. package/dist/pipeline/pre-handler-gates.js +20 -4
  332. package/dist/pipeline/pre-transaction-reservations.d.ts +1 -0
  333. package/dist/pipeline/pre-transaction-reservations.js +9 -3
  334. package/dist/pipeline/projection-rebuild.js +2 -1
  335. package/dist/pipeline/system-hooks.d.ts +3 -1
  336. package/dist/pipeline/system-hooks.js +55 -6
  337. package/dist/pipeline/write-result-masking.js +13 -10
  338. package/dist/schema-cli/apply-command.d.ts +2 -0
  339. package/dist/schema-cli/apply-command.js +97 -0
  340. package/dist/schema-cli/baseline-command.d.ts +2 -0
  341. package/dist/schema-cli/baseline-command.js +33 -0
  342. package/dist/schema-cli/generate-command.d.ts +2 -0
  343. package/dist/schema-cli/generate-command.js +90 -0
  344. package/dist/schema-cli/shared.d.ts +39 -0
  345. package/dist/schema-cli/shared.js +95 -0
  346. package/dist/schema-cli/status-command.d.ts +2 -0
  347. package/dist/schema-cli/status-command.js +73 -0
  348. package/dist/schema-cli/validate-boot-input.d.ts +5 -0
  349. package/dist/schema-cli/validate-boot-input.js +130 -0
  350. package/dist/schema-cli/validate-command.d.ts +2 -0
  351. package/dist/schema-cli/validate-command.js +130 -0
  352. package/dist/schema-cli.d.ts +2 -18
  353. package/dist/schema-cli.js +47 -604
  354. package/dist/search/meilisearch-adapter.js +2 -1
  355. package/dist/search/reindex-entity.js +15 -5
  356. package/dist/ui-types/index.d.ts +2 -2
  357. package/dist/ui-types/index.js +1 -1
  358. package/dist/upgrade-changelog.d.ts +2 -0
  359. package/dist/upgrade-changelog.js +14 -0
  360. package/dist/upgrade-cli.d.ts +3 -12
  361. package/dist/upgrade-cli.js +171 -197
  362. package/dist/upgrade-marker-parse.d.ts +32 -0
  363. package/dist/upgrade-marker-parse.js +73 -0
  364. package/package.json +9 -6
  365. package/src/changes.json +555 -0
  366. package/src/scripts/codemod/codemod-support.ts +1 -1
  367. package/src/scripts/codemod/db-factory.ts +1 -1
  368. package/src/scripts/codemod/feature-layout.ts +10 -2
  369. package/src/scripts/codemod/object-form.ts +41 -4
  370. package/src/scripts/codemod/package-merge.ts +58 -8
  371. package/src/scripts/codemod/pii-personal-migration.ts +175 -145
  372. package/src/scripts/codemod/testing-injection.ts +122 -42
  373. package/src/scripts/codemod/testing-move.ts +29 -7
  374. package/dist/engine/provider-plugin-context.d.ts +0 -3
  375. package/dist/engine/provider-plugin-context.js +0 -28
@@ -1,27 +1,21 @@
1
- /// <reference types="temporal-polyfill/global" preserve="true" />
2
- import { crossTenantReadsGrantFor, escapeHatchFor } from "@cosmicdrift/kumiko-types/handlers";
3
- import { DelayedError, Queue, UnrecoverableError, Worker } from "bullmq";
1
+ import { DelayedError, Queue, Worker } from "bullmq";
4
2
  import { Redis } from "ioredis";
5
3
  import { requestContext } from "../api/request-context.js";
6
- import { createCrossTenantReads } from "../db/cross-tenant-reads.js";
7
4
  import { createTenantDb, createUncheckedSystemDb } from "../db/tenant-db.js";
8
- import { createDerivativesContext } from "../derivatives/derivatives-context.js";
9
5
  import { bridgeStub } from "../engine/bridge-stub.js";
10
- import { qnScope } from "../engine/qualified-name.js";
11
6
  import { createSystemUser } from "../engine/system-user.js";
12
7
  import { SYSTEM_TENANT_ID } from "../engine/types/index.js";
13
8
  import { InternalError } from "../errors/index.js";
14
- import { createTenantFileContext } from "../files/file-handle.js";
15
- import { createFallbackLogger, scrubLogText } from "../logging/index.js";
16
- import { createEscapeHatchProcessDedup, createEscapeHatchReporter, createEscapeHatchReportWindow, createMetricsForFactory, emitJobLastSuccess, flushEscapeHatchAudits, getFallbackTracer, registerStandardMetrics, withEscapeHatchAuditScope, } from "../observability/index.js";
17
- import { configAccessorFactoryOf } from "../pipeline/config-accessor-factory.js";
9
+ import { createFallbackLogger } from "../logging/index.js";
10
+ import { createEscapeHatchProcessDedup, createEscapeHatchReporter, createEscapeHatchReportWindow, flushEscapeHatchAudits, getFallbackTracer, registerStandardMetrics, } from "../observability/index.js";
18
11
  import { createDistributedLock } from "../pipeline/distributed-lock.js";
19
12
  import { RedisKeys } from "../pipeline/redis-keys.js";
20
- import { createGatedIdentitySwitch } from "../pipeline/system-identity-switch.js";
21
- import { buildPersonalDataGate, parseWriteOrigin } from "../pipeline/write-origin.js";
22
13
  import { cronRepeatOptions } from "./cron-repeat-options.js";
23
- import { BOOT_GATE_SLOW_WARN_MS, BOOT_REDIS_TIMEOUT_MS, buildRetryBullOpts, COMPLETED_JOB_RETENTION_AGE_SEC, captureTraceContext, createJobLogger, DEFAULT_MAX_DEFERS, DEFER_BACKOFF_MAX_DOUBLINGS, DEFER_COUNT_KEY, DEFER_DELAY_CAP_MS, FAILED_JOB_RETENTION_AGE_SEC, HANDLER_DEFER_COUNT_KEY, isJobTriggerEvent, jobSubjectKey, maxRetryWindowMs, missingDbProxy, parseRedisOpts, payloadMatchesTriggerFilter, priorAttemptsOf, readTraceContext, resolveJobTz, STOP_QUEUE_READY_TIMEOUT_MS, stampGatedWriteOrigin, startQueueDepthPolling, TRACE_CONTEXT_KEY, tenantFailureMessageKey, timeoutReject, } from "./job-runner-helpers.js";
24
- import { ACTIVE_TENANT_IDS_QUERY_NAME, bootJobIdForJobName, DEFAULT_QUEUE_NAME_PREFIX, markSchedulersSeen, ORPHAN_SCHEDULER_GRACE_MS, perTenantChildJobId, queueNameFor, removeOrphanJobSchedulers, schedulerIdForJobName, } from "./job-runner-schedulers.js";
14
+ import { applyHandlerDeferral, executeJobInSpan } from "./job-runner-execution.js";
15
+ import { claimSequentialSlot, deferOverTenantLimit, fanOutPerTenantJob, PER_TENANT_PREFIX, perTenantTargetOf, releaseSequentialSlot, resolveJobDefinitionOrFail, startJobRun, } from "./job-runner-gates.js";
16
+ import { BOOT_GATE_SLOW_WARN_MS, BOOT_REDIS_TIMEOUT_MS, buildRetryBullOpts, COMPLETED_JOB_RETENTION_AGE_SEC, captureTraceContext, FAILED_JOB_RETENTION_AGE_SEC, maxRetryWindowMs, parseRedisOpts, payloadMatchesTriggerFilter, STOP_QUEUE_READY_TIMEOUT_MS, stampGatedWriteOrigin, startQueueDepthPolling, TRACE_CONTEXT_KEY, timeoutReject, } from "./job-runner-helpers.js";
17
+ import { ACTIVE_TENANT_IDS_QUERY_NAME, bootJobIdForJobName, DEFAULT_QUEUE_NAME_PREFIX, markSchedulersSeen, ORPHAN_SCHEDULER_GRACE_MS, queueNameFor, removeOrphanJobSchedulers, schedulerIdForJobName, } from "./job-runner-schedulers.js";
18
+ import { announceDropped, announceQueued, isOverPerTenantLimit, laneForJob, } from "./job-runner-scope.js";
25
19
  import { resolveMaxPerTenant } from "./max-per-tenant.js";
26
20
  export { serializeJobSubject } from "./job-runner-helpers.js";
27
21
  export { bootJobIdForJobName, ORPHAN_SCHEDULER_GRACE_MS, schedulerIdForJobName, } from "./job-runner-schedulers.js";
@@ -120,12 +114,6 @@ export function createJobRunner(options) {
120
114
  "jobRetention.completedAgeSec.");
121
115
  }
122
116
  }
123
- // Resolve the lane for a job — "worker" is the default because that's the
124
- // sensible prod lane (heavy async off the request path). Jobs that opted
125
- // into "api" must have been validated at registry boot already.
126
- function laneForJob(def) {
127
- return def.runIn ?? "worker";
128
- }
129
117
  // Sequential coordination: BullMQ OSS has no `group`, so we serialise
130
118
  // same-name jobs ourselves with a per-name Redis lock. Only built when at
131
119
  // least one job actually requested it — keeps the no-sequential boot path
@@ -149,14 +137,6 @@ export function createJobRunner(options) {
149
137
  // isolated by prefix on a shared Redis don't contend for the same lock.
150
138
  sequentialLock = createDistributedLock(lockRedis, `${RedisKeys.lock}seq:${queueNamePrefix}:${lockScope}:`);
151
139
  }
152
- // Default lock-TTL for sequential jobs that didn't declare a timeout.
153
- // 5 minutes matches BullMQ's default stalledInterval — long enough for
154
- // any reasonable handler, short enough that a crashed worker recovers
155
- // without manual intervention.
156
- const SEQUENTIAL_DEFAULT_TTL_SEC = 305;
157
- // How long to wait before re-trying a busy sequential lock. Short enough
158
- // to feel responsive, long enough that we don't hammer Redis.
159
- const SEQUENTIAL_RETRY_DELAY_MS = 200;
160
140
  // Two queue-clients — one per lane. Every runner holds both, regardless of
161
141
  // its own consumerLane, so dispatch()/handleEvent() always write to the
162
142
  // queue matching the target job's runIn. Client-creation is cheap (shared
@@ -202,638 +182,47 @@ export function createJobRunner(options) {
202
182
  // Set by attachDispatcher() once the boot-level dispatcher exists.
203
183
  // JobContext.write/writeAs/queryAs throw until this is set — see JobContext doc.
204
184
  let dispatchWriteRef;
205
- // Counts active + waiting jobs with this name for this tenant across
206
- // BOTH lane queues. Jobs with the same name should only live in one
207
- // lane (jobDef.runIn is static), but walking both is cheap and avoids
208
- // a subtle bug if someone ever reassigns a job to a different lane
209
- // between deploys while old queue contents are still draining.
210
- // `activeOnly` + `self` serve the defer check, which runs inside the job being
211
- // counted: it must not see waiting jobs or itself, and only counts jobs that
212
- // became active before it. Without that order, workers that fetch a burst of
213
- // jobs together all see each other as active and all defer, again and again.
214
- async function isOverPerTenantLimit(jobName, tenantId, max, scope = {}) {
215
- const results = await Promise.all([
216
- queues.api.getActive(),
217
- queues.worker.getActive(),
218
- ...(scope.activeOnly ? [] : [queues.api.getWaiting(), queues.worker.getWaiting()]),
219
- ]);
220
- let count = 0;
221
- for (const list of results) {
222
- for (const j of list) {
223
- if (j.name !== jobName)
224
- continue;
225
- if (scope.self && !isActiveBefore(j, scope.self))
226
- continue;
227
- const t = j.data?._tenantId; // @cast-boundary dynamic-key
228
- if (t === tenantId) {
229
- count += 1;
230
- if (count >= max)
231
- return true;
232
- }
233
- }
234
- }
235
- return false;
236
- }
237
- function isActiveBefore(other, self) {
238
- if (other.id === self.id)
239
- return false;
240
- if (self.processedOn === undefined)
241
- return true;
242
- const otherProcessedOn = other.processedOn ?? 0;
243
- if (otherProcessedOn !== self.processedOn)
244
- return otherProcessedOn < self.processedOn;
245
- return (other.id ?? "") < (self.id ?? "");
246
- }
247
- // Growing delay bounds Redis and worker load while one tenant has a large
248
- // backlog: every deferred job would otherwise be re-checked every deferMs.
249
- function deferDelayMs(deferMs, deferCount) {
250
- const grown = Math.min(deferMs * 2 ** Math.min(deferCount, DEFER_BACKOFF_MAX_DOUBLINGS), DEFER_DELAY_CAP_MS);
251
- return grown + Math.floor(Math.random() * deferMs);
252
- }
253
- function isJobDeferral(value) {
254
- return (typeof value === "object" &&
255
- value !== null &&
256
- "kind" in value &&
257
- value.kind === "job-deferral");
258
- }
259
- function priorDeferCountOf(data, key) {
260
- const prior = data[key];
261
- return typeof prior === "number" ? prior : 0;
262
- }
263
- // Only err.message, never payload or the logs array: job payloads can carry PII.
264
- function logFailedAttempt(entry) {
265
- errorLogger.error("job failed", {
266
- job: entry.job,
267
- jobId: entry.jobId,
268
- attempt: entry.attempt,
269
- // BullMQ never retries an UnrecoverableError, whatever attempts remain.
270
- final: entry.finalAttempt || entry.err instanceof UnrecoverableError,
271
- error: scrubLogText(entry.err instanceof Error ? entry.err.message : String(entry.err)),
272
- ...(entry.err instanceof Error &&
273
- entry.err.stack && { stack: scrubLogText(entry.err.stack) }),
274
- });
275
- }
276
- async function reportPreRunFailure(jobName, bullJob, err) {
277
- const message = err instanceof Error ? err.message : String(err);
278
- await options.onJobFailed?.(jobName, bullJob.id ?? "unknown", message, [
279
- { level: "error", message, timestamp: Temporal.Now.instant() },
280
- ]);
281
- }
282
- function handlerPayloadOf(data) {
283
- const payload = {};
284
- for (const [k, v] of Object.entries(data)) {
285
- if (!k.startsWith("_"))
286
- payload[k] = v;
287
- }
288
- return payload;
289
- }
290
- function tenantIdOfJobData(data) {
291
- return (data["_tenantId"] ?? // @cast-boundary dynamic-key
292
- data["tenantId"] ?? // @cast-boundary dynamic-key
293
- SYSTEM_TENANT_ID);
294
- }
295
- // Tenant-visible queued state. A callback or subject problem must never fail
296
- // the enqueue; a bad subject config surfaces as a failed run instead.
297
- async function announceQueued(jobName, jobDef, bullJobId, data) {
298
- const declaration = jobDef.tenantVisibleRun;
299
- // skip: job did not opt in, no logger wired, or BullMQ returned no id
300
- if (!declaration || !options.onJobQueued || bullJobId === undefined)
301
- return;
302
- const tenantId = tenantIdOfJobData(data);
303
- // skip: tenant-less run — no tenant-scoped query could ever read it
304
- if (tenantId === SYSTEM_TENANT_ID)
305
- return;
306
- try {
307
- const subject = jobSubjectKey(jobName, handlerPayloadOf(data), declaration.subjectFields, "tenantVisibleRun");
308
- await options.onJobQueued(jobName, bullJobId, { tenantId, subject });
309
- }
310
- catch (err) {
311
- errorLogger.warn("tenant-visible queued state not recorded", {
312
- job: jobName,
313
- error: err instanceof Error ? err.message : String(err),
314
- });
315
- }
316
- }
317
- async function announceDropped(jobName, jobDef, bullJobId, data) {
318
- // skip: job did not opt in or no logger wired
319
- if (!jobDef.tenantVisibleRun || !options.onJobDropped)
320
- return;
321
- const tenantId = tenantIdOfJobData(data);
322
- // skip: tenant-less run — announceQueued never recorded a row for it
323
- if (tenantId === SYSTEM_TENANT_ID)
324
- return;
325
- try {
326
- await options.onJobDropped(jobName, bullJobId, { tenantId });
327
- }
328
- catch (err) {
329
- errorLogger.warn("tenant-visible queued state not cleared", {
330
- job: jobName,
331
- error: err instanceof Error ? err.message : String(err),
332
- });
333
- }
334
- }
185
+ const scope = {
186
+ registry,
187
+ context,
188
+ options,
189
+ allJobs,
190
+ queues,
191
+ consumerLane,
192
+ tracer,
193
+ errorLogger,
194
+ sequentialLock,
195
+ getActiveTenantIds,
196
+ escapeHatchReportWindow,
197
+ systemCronEscapeHatchDedup,
198
+ getSelfRunner: () => selfRunner,
199
+ getDispatchWriteRef: () => dispatchWriteRef,
200
+ };
335
201
  async function handleJob(bullJob, token) {
336
- const rawName = bullJob.name;
337
- // Handle perTenant dispatch jobs — fan out to one job per tenant. The
338
- // fan-out re-enqueues into the lane the actual job is assigned to;
339
- // the _perTenant wrapper itself always lives in the consumer-lane
340
- // (it's picked up by this runner's own worker).
341
- if (rawName.startsWith("_perTenant:")) {
342
- const actualName = rawName.slice("_perTenant:".length);
343
- // These throws happen before runInSpan, so its onJobFailed never sees them
344
- // and a drained test stack would stay green over a fan-out that never ran.
345
- try {
346
- if (!getActiveTenantIds) {
347
- if (registry.getQueryHandler(ACTIVE_TENANT_IDS_QUERY_NAME)) {
348
- throw new Error(`perTenant job "${actualName}": the tenant feature is mounted but the job runner has no ` +
349
- "`context.db`: pass a DbConnection in the runner context or supply options.getActiveTenantIds");
350
- }
351
- throw new Error(`perTenant job "${actualName}" requires either options.getActiveTenantIds or the ` +
352
- `tenant feature mounted (it registers "${ACTIVE_TENANT_IDS_QUERY_NAME}", which the ` +
353
- "framework uses to resolve active tenants on its own)");
354
- }
355
- const actualDef = allJobs.get(actualName);
356
- if (!actualDef) {
357
- throw new Error(`Unknown job: ${actualName}`);
358
- }
359
- const tenantIds = await getActiveTenantIds();
360
- const targetQueue = queues[laneForJob(actualDef)];
361
- // wrapperJobId is only absent for a Job BullMQ somehow never assigned
362
- // an id to (not observed in practice) — falling back to a constant
363
- // there would collide every such run's children onto the same ids
364
- // and silently swallow later runs as "duplicates", which is worse
365
- // than the duplicate this is meant to prevent. Skip id-based dedup
366
- // for that edge case instead: enqueue plainly, same as before.
367
- const wrapperJobId = bullJob.id;
368
- for (const tenantId of tenantIds) {
369
- const childData = { ...bullJob.data, _tenantId: tenantId };
370
- const child = await targetQueue.add(actualName, childData, {
371
- ...buildRetryBullOpts(actualDef),
372
- // Dedup over wrapper retries only holds as long as the children
373
- // stay in Redis for the wrapper's whole retry window — see the
374
- // COMPLETED_JOB_RETENTION_AGE_SEC invariant check above.
375
- ...(wrapperJobId !== undefined
376
- ? { jobId: perTenantChildJobId(wrapperJobId, tenantId) }
377
- : {}),
378
- });
379
- await announceQueued(actualName, actualDef, child.id, childData);
380
- }
381
- }
382
- catch (err) {
383
- // A retry may still succeed, and a succeeding wrapper reports no completion
384
- // that could clear an earlier failure, so only the final attempt is reported.
385
- const retries = allJobs.get(actualName)?.retries ?? 0;
386
- const finalAttempt = bullJob.attemptsMade + 1 >= retries + 1;
387
- logFailedAttempt({
388
- job: rawName,
389
- jobId: bullJob.id ?? "unknown",
390
- attempt: bullJob.attemptsMade + 1,
391
- finalAttempt,
392
- err,
393
- });
394
- if (finalAttempt) {
395
- await reportPreRunFailure(rawName, bullJob, err);
396
- }
397
- throw err;
398
- }
202
+ const perTenantTarget = perTenantTargetOf(bullJob.name);
203
+ if (perTenantTarget !== undefined) {
204
+ await fanOutPerTenantJob(scope, bullJob, perTenantTarget);
399
205
  // skip: fan-out dispatcher job, per-tenant children enqueued
400
206
  return;
401
207
  }
402
- const jobName = rawName;
403
- const jobDef = allJobs.get(jobName);
404
- if (!jobDef) {
405
- const err = new Error(`Unknown job: ${jobName}`);
406
- // Final regardless of attempts left: a retry cannot make the job exist.
407
- logFailedAttempt({
408
- job: jobName,
409
- jobId: bullJob.id ?? "unknown",
410
- attempt: bullJob.attemptsMade + 1,
411
- finalAttempt: true,
412
- err,
413
- });
414
- await reportPreRunFailure(jobName, bullJob, err);
415
- throw err;
416
- }
417
- // maxPerTenant defer: checked before anything is recorded for this run, so a
418
- // deferred pickup leaves no span, run-log entry or failure trace and does not
419
- // consume an attempt (moveToDelayed + DelayedError, not a retry).
420
- const maxPerTenant = resolveMaxPerTenant(jobDef);
421
- const deferTenantId = bullJob.data["_tenantId"];
422
- if (maxPerTenant?.onLimit === "defer" &&
423
- typeof deferTenantId === "string" &&
424
- token !== undefined &&
425
- bullJob.moveToDelayed &&
426
- (await isOverPerTenantLimit(jobName, deferTenantId, maxPerTenant.limit, {
427
- activeOnly: true,
428
- self: { id: bullJob.id, processedOn: bullJob.processedOn },
429
- }))) {
430
- const deferCount = priorDeferCountOf(bullJob.data, DEFER_COUNT_KEY);
431
- await bullJob.updateData?.({ ...bullJob.data, [DEFER_COUNT_KEY]: deferCount + 1 });
432
- await bullJob.moveToDelayed(Date.now() + deferDelayMs(maxPerTenant.deferMs, deferCount), token);
208
+ const jobDef = await resolveJobDefinitionOrFail(scope, bullJob);
209
+ if ((await deferOverTenantLimit(scope, jobDef, bullJob, token)) === "deferred") {
433
210
  throw new DelayedError();
434
211
  }
435
- // Sequential gate: try to claim the per-name lock. If another worker
436
- // (or this worker on a different bullJob) holds it, re-enqueue with a
437
- // small delay and exit *successfully* — using throw would burn the
438
- // job's retry budget and pollute failure metrics, but a re-enqueue
439
- // looks like an ordinary handoff to BullMQ.
440
- let sequentialToken = null;
441
- if (jobDef.concurrency === "sequential" && sequentialLock) {
442
- const ttlSec = jobDef.timeout
443
- ? Math.ceil(jobDef.timeout / 1000) + 5
444
- : SEQUENTIAL_DEFAULT_TTL_SEC;
445
- sequentialToken = await sequentialLock.acquire(jobName, { ttlSeconds: ttlSec });
446
- if (!sequentialToken) {
447
- // Re-enqueue onto the job's own lane-queue. In practice that's the
448
- // same queue the worker just picked from (since only the consuming
449
- // lane runs handleJob at all), but route explicitly — no implicit
450
- // coupling to "whichever queue the caller happened to be on".
451
- // Attempts already spent travel in _priorAttempts so a lock conflict
452
- // on a retry cannot hand out a fresh budget and exceed `retries`.
453
- const priorAttempts = priorAttemptsOf(bullJob.data) + bullJob.attemptsMade;
454
- const requeuedData = { ...bullJob.data, _priorAttempts: priorAttempts };
455
- const requeued = await queues[laneForJob(jobDef)].add(jobName, requeuedData, {
456
- delay: SEQUENTIAL_RETRY_DELAY_MS,
457
- ...buildRetryBullOpts(jobDef, priorAttempts),
458
- });
459
- if (bullJob.id !== undefined && bullJob.id !== requeued.id) {
460
- await announceDropped(jobName, jobDef, bullJob.id, bullJob.data);
461
- }
462
- await announceQueued(jobName, jobDef, requeued.id, requeuedData);
463
- // skip: lock taken, work re-enqueued with delay, current invocation done
464
- return;
465
- }
466
- }
467
- const jobId = bullJob.id ?? "unknown";
468
- const startTime = Date.now();
469
- const logs = [];
470
- // Extract meta from job data. `attempt` is BullMQ's own counter
471
- // (1-based on the first run, incremented on each retry) — threading
472
- // it through lets the logger tag the run-started event with the
473
- // retry number, so audit queries distinguish fresh from retry runs
474
- // without peeking at BullMQ internals.
475
- const rawData = bullJob.data;
476
- const attempt = bullJob.attemptsMade + 1 + priorAttemptsOf(rawData);
477
- const meta = {
478
- triggeredById: rawData["_triggeredById"], // @cast-boundary dynamic-key
479
- payload: rawData["_payload"], // @cast-boundary dynamic-key
480
- attempt,
481
- };
482
- // Build handler payload (without internal meta fields)
483
- const payload = handlerPayloadOf(rawData);
484
- // Determine tenantId and triggeredBy from meta
485
- const tenantId = tenantIdOfJobData(rawData);
486
- const triggeredById = rawData["_triggeredById"] ?? null; // @cast-boundary dynamic-key
487
- // Tenant-visible failure record (JobDefinition.tenantVisibleFailure). The
488
- // subject is read from the handler payload, so it is resolved here where
489
- // the payload is built, not in the callbacks.
490
- const tenantVisibleDecl = jobDef.tenantVisibleFailure;
491
- const tenantVisibleRunDecl = jobDef.tenantVisibleRun;
492
- // A bad subject config must surface as a failed run, so it is thrown from
493
- // inside runInSpan below instead of here, before any run record exists.
494
- let tenantVisibleSubject = null;
495
- let tenantVisibleRunSubject = null;
496
- let subjectConfigError;
497
- if (tenantVisibleRunDecl) {
498
- try {
499
- tenantVisibleRunSubject = jobSubjectKey(jobName, payload, tenantVisibleRunDecl.subjectFields, "tenantVisibleRun");
500
- }
501
- catch (err) {
502
- subjectConfigError = err instanceof Error ? err : new Error(String(err));
503
- }
504
- }
505
- if (tenantVisibleDecl && !subjectConfigError) {
506
- try {
507
- tenantVisibleSubject = jobSubjectKey(jobName, payload, tenantVisibleDecl.subjectFields, "tenantVisibleFailure");
508
- }
509
- catch (err) {
510
- subjectConfigError = err instanceof Error ? err : new Error(String(err));
511
- }
512
- }
513
- // BullMQ stops retrying once attemptsMade reaches the configured attempts
514
- // (`retries + 1`), so this is the attempt whose failure is final.
515
- const finalAttempt = bullJob.retryable === false || attempt >= (jobDef.retries ?? 0) + 1;
516
- const outcomeMeta = (messageKey, unrecoverable = false) => ({
517
- tenantId,
518
- // BullMQ never retries an UnrecoverableError, whatever attempts are left.
519
- finalAttempt: finalAttempt || unrecoverable,
520
- ...(tenantVisibleDecl &&
521
- !subjectConfigError && {
522
- tenantVisible: { subject: tenantVisibleSubject, messageKey },
523
- }),
524
- ...(tenantVisibleRunDecl &&
525
- !subjectConfigError && { tenantVisibleRun: { subject: tenantVisibleRunSubject } }),
526
- });
527
- // Carry `_triggerName` from rawData when set — handleEvent injects it on
528
- // multi-trigger dispatch; exposed as jobContext.triggerName so handlers
529
- // don't dig through the raw payload themselves.
530
- const triggerName = rawData["_triggerName"]; // @cast-boundary dynamic-key
531
- // Same carry-through for `_triggerEvent` — handleEvent injects it when
532
- // the job-trigger event consumer supplied the stored event that fired
533
- // this run (see createJobTriggerEventConsumer).
534
- const rawTriggerEvent = rawData["_triggerEvent"];
535
- const triggerEvent = isJobTriggerEvent(rawTriggerEvent) ? rawTriggerEvent : undefined;
536
- // Absent = legacy or ungated root; present but invalid fails the run closed.
537
- const rawWriteOrigin = rawData["_writeOrigin"]; // @cast-boundary dynamic-key
538
- let jobOrigin;
539
- let writeOriginInvalid = false;
540
- if (rawWriteOrigin !== undefined) {
541
- const parsed = parseWriteOrigin(rawWriteOrigin);
542
- if (parsed) {
543
- jobOrigin = { ...parsed, viaJob: jobName };
544
- }
545
- else {
546
- writeOriginInvalid = true;
547
- }
548
- }
549
- const jobPersonalDataGate = jobOrigin ? buildPersonalDataGate(registry, jobOrigin) : undefined;
550
- // Mirror dispatch-shared.ts buildHandlerContext: ctx.files must resolve
551
- // through the same _fileProviderResolver for jobs as for write-handlers,
552
- // otherwise event-triggered jobs silently get an unresolved ctx.files.
553
- const fileResolver = context._fileProviderResolver;
554
- const files = fileResolver ? createTenantFileContext(fileResolver, tenantId) : context.files;
555
- const jobSystemUser = createSystemUser(tenantId);
556
- // Same buildHandlerContext parity as ctx.files above: ctx.notify/ctx.config
557
- // must resolve for jobs the same way they do for write-handlers, or an
558
- // app-author job calling ctx.notify(...)/ctx.config(...) hits a TypeError
559
- // at runtime the write-handler path never would.
560
- const notify = context._notifyFactory?.(jobSystemUser, tenantId, selfRunner, undefined, context._fileProviderResolver);
561
- const configDb = context.db; // @cast-boundary db-operator
562
- // Shared by the config accessor and ctx.derivatives below — both need the
563
- // same tenant-scoped db, and building it twice would let the two calls
564
- // drift apart. Always "system" mode regardless of the job's own
565
- // systemScope() status (pre-existing, not something this change alters)
566
- // — isSystemJob below is what actually keeps ctx.systemDb off a
567
- // non-system job; it is the only thing standing between this db and an
568
- // unchecked cross-tenant escape hatch for such a job. Gated like jobDb so
569
- // ctx.systemDb, built from it, is gated too.
570
- const tenantScopedDb = configDb
571
- ? createTenantDb({
572
- db: configDb,
573
- tenantId,
574
- mode: "system",
575
- grants: {
576
- projectionRegistry: registry,
577
- ...(jobPersonalDataGate && { personalDataGate: jobPersonalDataGate }),
578
- },
579
- })
580
- : undefined;
581
- const isSystemJob = registry.isJobSystemScoped(jobName);
582
- // One reporter for ctx.systemDb and ctx.db.unsafeRaw() so both dedupe in the same window.
583
- const isSystemCronRun = triggeredById === null && jobDef.escapeHatch !== undefined && "cron" in jobDef.trigger;
584
- const reportEscapeHatch = createEscapeHatchReporter({
585
- handler: jobName,
586
- tenantId,
587
- actor: jobSystemUser.id,
588
- sink: context._escapeHatchAuditSink,
589
- log: errorLogger,
590
- window: escapeHatchReportWindow,
591
- ...(triggeredById !== null && { caller: triggeredById }),
592
- ...(isSystemCronRun && { processDedup: systemCronEscapeHatchDedup }),
593
- ...(context.meter && { meter: context.meter }),
594
- });
595
- const systemDb = isSystemJob && tenantScopedDb
596
- ? createUncheckedSystemDb(tenantScopedDb, undefined, reportEscapeHatch)
597
- : undefined;
598
- const jobDb = configDb
599
- ? createTenantDb({
600
- db: configDb,
601
- tenantId,
602
- mode: "tenant",
603
- tracer: context.tracer,
604
- meter: context.meter,
605
- grants: {
606
- unsafeRaw: escapeHatchFor(jobDef.escapeHatch, "unsafeRaw"),
607
- globalWrites: escapeHatchFor(jobDef.escapeHatch, "globalWrites"),
608
- report: reportEscapeHatch,
609
- projectionRegistry: registry,
610
- ...(jobPersonalDataGate && { personalDataGate: jobPersonalDataGate }),
611
- },
612
- })
613
- : undefined;
614
- const crossTenantReadsReason = crossTenantReadsGrantFor(jobDef.escapeHatch)?.reason;
615
- const crossTenantReads = tenantScopedDb && crossTenantReadsReason !== undefined
616
- ? createCrossTenantReads(tenantScopedDb, crossTenantReadsReason, reportEscapeHatch)
617
- : undefined;
618
- const configFactory = configAccessorFactoryOf(context);
619
- const config = configFactory && tenantScopedDb
620
- ? configFactory({
621
- user: { id: jobSystemUser.id, tenantId },
622
- db: tenantScopedDb,
623
- secrets: context.secrets,
624
- })
625
- : undefined;
626
- // Mirror dispatch-shared.ts: ctx.derivatives needs files+db, same
627
- // tenant-scoped db the config accessor above uses.
628
- const derivatives = files && tenantScopedDb
629
- ? createDerivativesContext({
630
- files,
631
- registry,
632
- db: tenantScopedDb,
633
- tenantId,
634
- })
635
- : context.derivatives;
636
- // Per-job-run reader, created lazily on queryAsMember's first call and
637
- // cached for the rest of this run.
638
- let memberReader;
639
- const identitySwitchHatch = escapeHatchFor(jobDef.escapeHatch, "systemIdentity");
640
- const identitySwitchGrantReason = identitySwitchHatch?.reason ??
641
- (isSystemJob ? `r.systemScope() feature of job "${jobName}"` : undefined);
642
- const ungatedQueryAs = (user, qn, payload) => {
643
- if (!dispatchWriteRef) {
644
- throw new Error("JobContext.queryAs called before dispatcher attached — call attachDispatcher() first");
645
- }
646
- return dispatchWriteRef.queryAs(user, qn, payload, jobOrigin);
647
- };
648
- const gatedIdentitySwitch = createGatedIdentitySwitch(`job "${jobName}"`, jobSystemUser, isSystemJob || identitySwitchHatch !== undefined, {
649
- queryAs: ungatedQueryAs,
650
- writeAs: async (user, qn, payload) => {
651
- if (!dispatchWriteRef) {
652
- throw new Error("JobContext.writeAs called before dispatcher attached — call attachDispatcher() first");
653
- }
654
- return dispatchWriteRef.write(user, qn, payload, jobOrigin);
655
- },
656
- }, { reason: identitySwitchGrantReason, report: reportEscapeHatch });
657
- const jobContextWithoutTz = {
658
- defer: (delayMs) => {
659
- if (!Number.isInteger(delayMs) || delayMs <= 0) {
660
- throw new RangeError(`Job "${jobName}": ctx.defer needs a positive integer delay in ms (got ${delayMs})`);
661
- }
662
- if (token === undefined || !bullJob.moveToDelayed) {
663
- throw new InternalError({
664
- message: `Job "${jobName}": ctx.defer needs a queued run; it is not available inline (boot gate).`,
665
- });
666
- }
667
- return { kind: "job-deferral", delayMs };
668
- },
669
- ...context,
670
- db: jobDb ?? missingDbProxy(jobName),
671
- files,
672
- derivatives,
673
- ...(notify !== undefined && { notify }),
674
- ...(config !== undefined && { config }),
675
- ...(systemDb && { systemDb }),
676
- ...(crossTenantReads && { crossTenantReads }),
677
- // The runner owns the registry it resolved this job from — expose it so
678
- // workers can reach projections/jobs without the app author duplicating
679
- // it into `context` (the JobContext contract guarantees `registry`).
680
- registry,
681
- // Expose the runner so handlers can chain a follow-up job. Symmetric with
682
- // how the command-dispatcher hands write-handlers their jobRunner.
683
- ...(selfRunner !== undefined && { jobRunner: selfRunner }),
684
- systemUser: jobSystemUser,
685
- triggeredBy: triggeredById !== null ? { id: triggeredById, tenantId } : null,
686
- attempt,
687
- finalAttempt,
688
- log: createJobLogger(logs),
689
- metricsFor: createMetricsForFactory(context.meter),
690
- ...(triggerName !== undefined && { triggerName }),
691
- ...(triggerEvent !== undefined && { triggerEvent }),
692
- write: (qn, payload) => {
693
- if (!dispatchWriteRef) {
694
- throw new Error("JobContext.write called before dispatcher attached — call attachDispatcher() first");
695
- }
696
- return dispatchWriteRef.write(jobSystemUser, qn, payload, jobOrigin);
697
- },
698
- writeAs: gatedIdentitySwitch.writeAs,
699
- queryAs: gatedIdentitySwitch.queryAs,
700
- queryAsMember: (userId, qn, payload) => {
701
- if (!dispatchWriteRef) {
702
- throw new Error("JobContext.queryAsMember called before dispatcher attached — call attachDispatcher() first");
703
- }
704
- // Lazy per-run reader, mirroring the handler-context reader.
705
- memberReader ??= dispatchWriteRef.createMemberReader(tenantId);
706
- return memberReader(userId, qn, payload);
707
- },
708
- };
709
- if (tenantVisibleRunDecl && !subjectConfigError) {
710
- meta.tenantVisibleRun = { tenantId, subject: tenantVisibleRunSubject };
212
+ const slot = await claimSequentialSlot(scope, jobDef, bullJob);
213
+ if (slot.kind === "requeued") {
214
+ // skip: lock taken, work re-enqueued with delay, current invocation done
215
+ return;
711
216
  }
712
- await options.onJobStart?.(jobName, jobId, meta);
713
- // Cross-process trace continuation: if the enqueuing code captured a
714
- // parent span, start the job.execute span as its child. Works for event
715
- // and manual triggers; cron jobs start a fresh root span.
716
- const parentContext = readTraceContext(rawData);
717
- // Correlation propagation: the scheduling request's correlationId was
718
- // packed into _correlationId at dispatch time. Re-enter requestContext.run
719
- // so event writes during this job stamp the same correlation as the
720
- // request that scheduled it. Cron/boot jobs (no scheduler) start fresh
721
- // — correlationId = new requestId, no parent causation.
722
- const inheritedCorrelationId = rawData["_correlationId"] ?? undefined; // @cast-boundary dynamic-key
723
- const jobRequestId = requestContext.generateId();
724
- const jobCorrelationId = inheritedCorrelationId ?? jobRequestId;
725
- const deferred = {};
726
- const runInSpan = async () => {
727
- try {
728
- if (subjectConfigError) {
729
- // Retrying cannot fix a declaration error.
730
- throw new UnrecoverableError(subjectConfigError.message);
731
- }
732
- if (writeOriginInvalid) {
733
- throw new InternalError({
734
- message: `Job "${jobName}" received an unparseable _writeOrigin — refusing to run without ` +
735
- "a trustworthy anonymous-root gate.",
736
- });
737
- }
738
- const handlerResult = await requestContext.run({
739
- requestId: jobRequestId,
740
- correlationId: jobCorrelationId,
741
- // Events a job writes carry the job as their origin.
742
- handler: jobName,
743
- feature: registry.getJobFeature(jobName) ?? qnScope(jobName),
744
- writeOrigin: jobOrigin,
745
- tenantId,
746
- ...(triggeredById !== null && { userId: triggeredById }),
747
- }, async () => jobDef.handler(payload, {
748
- ...jobContextWithoutTz,
749
- tz: await resolveJobTz(config, context),
750
- }));
751
- if (isJobDeferral(handlerResult)) {
752
- const maxDefers = jobDef.maxDefers ?? DEFAULT_MAX_DEFERS;
753
- if (priorDeferCountOf(bullJob.data, HANDLER_DEFER_COUNT_KEY) >= maxDefers) {
754
- // The counter never resets, so a retry would hit the cap again.
755
- throw new UnrecoverableError(`Job "${jobName}" was deferred ${maxDefers} times (maxDefers); giving up.`);
756
- }
757
- deferred.deferral = handlerResult;
758
- // skip: the deferral is applied after the span closes, below
759
- return;
760
- }
761
- // Stamped before the observer hook: a throwing onJobComplete must not
762
- // make a run that actually succeeded look dead to the liveness alert.
763
- if (context.meter) {
764
- try {
765
- emitJobLastSuccess(context.meter, jobName);
766
- }
767
- catch (err) {
768
- errorLogger.warn("job last-success metric failed", {
769
- job: jobName,
770
- error: err instanceof Error ? err.message : String(err),
771
- });
772
- }
773
- }
774
- const duration = Date.now() - startTime;
775
- await options.onJobComplete?.(jobName, jobId, duration, logs, outcomeMeta(null));
776
- }
777
- catch (err) {
778
- const errorMsg = err instanceof Error ? err.message : String(err);
779
- logs.push({ level: "error", message: errorMsg, timestamp: Temporal.Now.instant() });
780
- logFailedAttempt({ job: jobName, jobId, attempt, finalAttempt, err });
781
- await options.onJobFailed?.(jobName, jobId, errorMsg, logs, outcomeMeta(tenantFailureMessageKey(err, tenantVisibleDecl), err instanceof UnrecoverableError));
782
- throw err;
783
- }
784
- };
785
- // Unified span creation: withSpan handles start/end + status/exception
786
- // recording identically for both parent-context and no-parent paths.
787
- // When parentContext is set, the new parent-aware StartSpanOptions
788
- // plumbs it through to startSpan — no manual try/finally needed.
789
217
  try {
790
- await withEscapeHatchAuditScope(() => tracer.withSpan("job.execute", {
791
- attributes: {
792
- "job.name": jobName,
793
- "job.id": jobId,
794
- "job.attempt": attempt,
795
- "kumiko.tenant_id": tenantId,
796
- // Lane-routing attributes. `run_in` is the job's
797
- // declared lane (explicit or default-"worker"); `consumer_lane`
798
- // is which runner actually executed it. They diverge in
799
- // all-in-one (both lanes live in one process) but must match
800
- // in split deploys — a mismatch in prod logs signals a
801
- // misrouted job that slipped past the boot-validator.
802
- "kumiko.job.run_in": laneForJob(jobDef),
803
- // Omit attribute entirely when no consumer (enqueuer-only runner) —
804
- // SpanAttributeValue doesn't accept undefined.
805
- ...(consumerLane !== undefined ? { "kumiko.job.consumer_lane": consumerLane } : {}),
806
- },
807
- ...(parentContext ? { parent: parentContext } : {}),
808
- }, runInSpan));
809
- if (deferred.deferral && token !== undefined && bullJob.moveToDelayed) {
810
- // Run-log bookkeeping must not turn a defer into a failure.
811
- try {
812
- await options.onJobDeferred?.(jobName, jobId, meta);
813
- }
814
- catch (err) {
815
- errorLogger.warn("job deferred hook failed", {
816
- job: jobName,
817
- jobId,
818
- error: scrubLogText(err instanceof Error ? err.message : String(err)),
819
- });
820
- }
821
- await bullJob.updateData?.({
822
- ...bullJob.data,
823
- [HANDLER_DEFER_COUNT_KEY]: priorDeferCountOf(bullJob.data, HANDLER_DEFER_COUNT_KEY) + 1,
824
- });
825
- await bullJob.moveToDelayed(Date.now() + deferred.deferral.delayMs, token);
218
+ const run = await startJobRun(scope, jobDef, bullJob, token);
219
+ const deferral = await executeJobInSpan(scope, run);
220
+ if ((await applyHandlerDeferral(scope, run, bullJob, deferral, token)) === "deferred") {
826
221
  throw new DelayedError();
827
222
  }
828
223
  }
829
224
  finally {
830
- // Release the sequential lock value-matched (Lua compare-and-delete
831
- // inside DistributedLock). A TTL-expired lock that's been claimed by
832
- // a different owner stays put — releasing it would break sequencing
833
- // for the new owner.
834
- if (sequentialToken && sequentialLock) {
835
- await sequentialLock.release(jobName, sequentialToken);
836
- }
225
+ await releaseSequentialSlot(scope, bullJob.name, slot.lockToken);
837
226
  }
838
227
  }
839
228
  // Boot gates run inline and are awaited, so a throwing gate rejects start()
@@ -875,6 +264,9 @@ export function createJobRunner(options) {
875
264
  // a hanging process instead of a non-zero exit.
876
265
  await worker?.close();
877
266
  worker = null;
267
+ // A failed start() may never be followed by stop(); the abandoned gate must not keep
268
+ // writing to DB/Redis while the process shuts down.
269
+ await settleAbandonedBootGates(Math.min(bootRedisTimeoutMs, STOP_QUEUE_READY_TIMEOUT_MS));
878
270
  throw err;
879
271
  }
880
272
  finally {
@@ -899,7 +291,7 @@ export function createJobRunner(options) {
899
291
  continue;
900
292
  if ("cron" in jobDef.trigger) {
901
293
  await queue.upsertJobScheduler(schedulerIdForJobName(name), cronRepeatOptions(jobDef.trigger), {
902
- name: jobDef.perTenant ? `_perTenant:${name}` : name,
294
+ name: jobDef.perTenant ? `${PER_TENANT_PREFIX}${name}` : name,
903
295
  data: {},
904
296
  // Queue-level defaultJobOptions (jobRetentionOpts) covers
905
297
  // retention; a count here would sweep queue-wide again and
@@ -1011,7 +403,6 @@ export function createJobRunner(options) {
1011
403
  });
1012
404
  });
1013
405
  await upsertCronSchedulers(consumerQueue, consumerLane);
1014
- schedulerSeenTimer = startSchedulerSeenRefresh(consumerQueue, consumerLane, expectedSchedulerIds);
1015
406
  await removeOrphanJobSchedulers(consumerQueue, expectedSchedulerIds, consumerLane, {
1016
407
  info: (msg, data) => context.log ? context.log.info(msg, data) : errorLogger.debug(msg, data),
1017
408
  warn: errorLogger.warn,
@@ -1034,7 +425,7 @@ export function createJobRunner(options) {
1034
425
  if (laneForJob(jobDef) !== consumerLane)
1035
426
  continue;
1036
427
  if (jobDef.runOnBoot) {
1037
- const bootName = jobDef.perTenant ? `_perTenant:${name}` : name;
428
+ const bootName = jobDef.perTenant ? `${PER_TENANT_PREFIX}${name}` : name;
1038
429
  const bootJobId = bootJobIdForJobName(name);
1039
430
  const client = await consumerQueue.client;
1040
431
  const alreadyEnqueued = await client.hexists(bootEnqueuedKey, bootJobId);
@@ -1052,6 +443,8 @@ export function createJobRunner(options) {
1052
443
  if (context.meter) {
1053
444
  queueDepthTimer = await startQueueDepthPolling(consumerQueue, consumerLane, context.meter);
1054
445
  }
446
+ // Last on purpose: a start() that fails earlier must not leave a timer stamping for a runner that is not running.
447
+ schedulerSeenTimer = startSchedulerSeenRefresh(consumerQueue, consumerLane, expectedSchedulerIds);
1055
448
  },
1056
449
  async stop() {
1057
450
  stopping = true;
@@ -1111,7 +504,7 @@ export function createJobRunner(options) {
1111
504
  if (jobDef.perTenant) {
1112
505
  const perTenantData = { ...(payload ?? {}) };
1113
506
  stampGatedWriteOrigin(perTenantData);
1114
- const job = await targetQueue.add(`_perTenant:${jobName}`, perTenantData, buildRetryBullOpts(jobDef));
507
+ const job = await targetQueue.add(`${PER_TENANT_PREFIX}${jobName}`, perTenantData, buildRetryBullOpts(jobDef));
1115
508
  return job.id ?? "unknown";
1116
509
  }
1117
510
  // maxPerTenant guard: cap concurrent + waiting jobs of the same name
@@ -1125,7 +518,7 @@ export function createJobRunner(options) {
1125
518
  if (maxPerTenant?.onLimit === "skip") {
1126
519
  const tenantId = payload?._tenantId; // @cast-boundary dynamic-key
1127
520
  if (tenantId !== undefined &&
1128
- (await isOverPerTenantLimit(jobName, tenantId, maxPerTenant.limit))) {
521
+ (await isOverPerTenantLimit(scope, jobName, tenantId, maxPerTenant.limit))) {
1129
522
  return "skipped:max-per-tenant";
1130
523
  }
1131
524
  }
@@ -1146,7 +539,7 @@ export function createJobRunner(options) {
1146
539
  for (const j of waiting) {
1147
540
  if (j.name === jobName && j.id) {
1148
541
  await j.remove();
1149
- await announceDropped(jobName, jobDef, j.id, j.data);
542
+ await announceDropped(scope, jobName, jobDef, j.id, j.data);
1150
543
  }
1151
544
  }
1152
545
  break;
@@ -1189,7 +582,7 @@ export function createJobRunner(options) {
1189
582
  data["_correlationId"] = reqCtx.correlationId;
1190
583
  stampGatedWriteOrigin(data);
1191
584
  const job = await targetQueue.add(jobName, data, bullOpts);
1192
- await announceQueued(jobName, jobDef, job.id, data);
585
+ await announceQueued(scope, jobName, jobDef, job, data);
1193
586
  return job.id ?? "unknown";
1194
587
  },
1195
588
  async handleEvent(eventName, payload, user, triggerEvent) {
@@ -1231,7 +624,7 @@ export function createJobRunner(options) {
1231
624
  // jobs must respect the per-tenant cap or the limit is one-sided.
1232
625
  const maxPerTenant = resolveMaxPerTenant(jobDef);
1233
626
  if (maxPerTenant?.onLimit === "skip" && user?.tenantId !== undefined) {
1234
- if (await isOverPerTenantLimit(name, String(user.tenantId), maxPerTenant.limit)) {
627
+ if (await isOverPerTenantLimit(scope, name, String(user.tenantId), maxPerTenant.limit)) {
1235
628
  continue;
1236
629
  }
1237
630
  }
@@ -1239,7 +632,7 @@ export function createJobRunner(options) {
1239
632
  // Route to the job's declared lane, not a fixed queue — that's
1240
633
  // the whole reason both queues are held.
1241
634
  const job = await queues[laneForJob(jobDef)].add(name, data, buildRetryBullOpts(jobDef));
1242
- await announceQueued(name, jobDef, job.id, data);
635
+ await announceQueued(scope, name, jobDef, job, data);
1243
636
  }
1244
637
  },
1245
638
  attachDispatcher(ref) {