alepha 0.20.5 → 0.20.7

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 (367) hide show
  1. package/AGENTS.md +0 -1
  2. package/CLAUDE.md +0 -1
  3. package/assets/agents-template.md +0 -1
  4. package/dist/api/audits/index.browser.js +1 -0
  5. package/dist/api/audits/index.browser.js.map +1 -1
  6. package/dist/api/audits/index.d.ts +701 -654
  7. package/dist/api/audits/index.d.ts.map +1 -1
  8. package/dist/api/audits/index.js +24 -1
  9. package/dist/api/audits/index.js.map +1 -1
  10. package/dist/api/files/index.browser.js +1 -0
  11. package/dist/api/files/index.browser.js.map +1 -1
  12. package/dist/api/files/index.d.ts +193 -166
  13. package/dist/api/files/index.d.ts.map +1 -1
  14. package/dist/api/files/index.js +52 -0
  15. package/dist/api/files/index.js.map +1 -1
  16. package/dist/api/jobs/index.browser.js +40 -14
  17. package/dist/api/jobs/index.browser.js.map +1 -1
  18. package/dist/api/jobs/index.d.ts +639 -333
  19. package/dist/api/jobs/index.d.ts.map +1 -1
  20. package/dist/api/jobs/index.js +495 -162
  21. package/dist/api/jobs/index.js.map +1 -1
  22. package/dist/api/keys/index.d.ts +222 -188
  23. package/dist/api/keys/index.d.ts.map +1 -1
  24. package/dist/api/keys/index.js +54 -0
  25. package/dist/api/keys/index.js.map +1 -1
  26. package/dist/api/notifications/index.d.ts +265 -236
  27. package/dist/api/notifications/index.d.ts.map +1 -1
  28. package/dist/api/notifications/index.js +55 -13
  29. package/dist/api/notifications/index.js.map +1 -1
  30. package/dist/api/organizations/index.d.ts +100 -97
  31. package/dist/api/organizations/index.d.ts.map +1 -1
  32. package/dist/api/organizations/index.js.map +1 -1
  33. package/dist/api/parameters/index.d.ts +332 -314
  34. package/dist/api/parameters/index.d.ts.map +1 -1
  35. package/dist/api/parameters/index.js +37 -0
  36. package/dist/api/parameters/index.js.map +1 -1
  37. package/dist/api/payments/index.d.ts +431 -376
  38. package/dist/api/payments/index.d.ts.map +1 -1
  39. package/dist/api/payments/index.js +202 -87
  40. package/dist/api/payments/index.js.map +1 -1
  41. package/dist/api/subscriptions/index.d.ts +1695 -0
  42. package/dist/api/subscriptions/index.d.ts.map +1 -0
  43. package/dist/api/subscriptions/index.js +1919 -0
  44. package/dist/api/subscriptions/index.js.map +1 -0
  45. package/dist/api/users/index.d.ts +1001 -844
  46. package/dist/api/users/index.d.ts.map +1 -1
  47. package/dist/api/users/index.js +237 -28
  48. package/dist/api/users/index.js.map +1 -1
  49. package/dist/api/verifications/index.d.ts +123 -122
  50. package/dist/api/verifications/index.d.ts.map +1 -1
  51. package/dist/api/verifications/index.js.map +1 -1
  52. package/dist/batch/index.js.map +1 -1
  53. package/dist/bucket/index.d.ts +21 -2
  54. package/dist/bucket/index.d.ts.map +1 -1
  55. package/dist/bucket/index.js +47 -0
  56. package/dist/bucket/index.js.map +1 -1
  57. package/dist/bucket/index.workerd.js +24 -0
  58. package/dist/bucket/index.workerd.js.map +1 -1
  59. package/dist/cache/core/index.d.ts +134 -7
  60. package/dist/cache/core/index.d.ts.map +1 -1
  61. package/dist/cache/core/index.js +181 -15
  62. package/dist/cache/core/index.js.map +1 -1
  63. package/dist/cache/core/index.workerd.js +181 -15
  64. package/dist/cache/core/index.workerd.js.map +1 -1
  65. package/dist/cache/database/index.d.ts +156 -0
  66. package/dist/cache/database/index.d.ts.map +1 -0
  67. package/dist/cache/database/index.js +266 -0
  68. package/dist/cache/database/index.js.map +1 -0
  69. package/dist/cache/redis/index.d.ts +3 -2
  70. package/dist/cache/redis/index.d.ts.map +1 -1
  71. package/dist/cache/redis/index.js.map +1 -1
  72. package/dist/captcha/index.js.map +1 -1
  73. package/dist/cli/config/index.js.map +1 -1
  74. package/dist/cli/core/index.d.ts +142 -128
  75. package/dist/cli/core/index.d.ts.map +1 -1
  76. package/dist/cli/core/index.js +160 -13
  77. package/dist/cli/core/index.js.map +1 -1
  78. package/dist/cli/devtools/index.d.ts +3 -2
  79. package/dist/cli/devtools/index.d.ts.map +1 -1
  80. package/dist/cli/devtools/index.js.map +1 -1
  81. package/dist/cli/platform/index.d.ts +346 -290
  82. package/dist/cli/platform/index.d.ts.map +1 -1
  83. package/dist/cli/platform/index.js +106 -7
  84. package/dist/cli/platform/index.js.map +1 -1
  85. package/dist/cli/vendor/index.d.ts +12 -11
  86. package/dist/cli/vendor/index.d.ts.map +1 -1
  87. package/dist/cli/vendor/index.js.map +1 -1
  88. package/dist/command/index.d.ts +6 -5
  89. package/dist/command/index.d.ts.map +1 -1
  90. package/dist/command/index.js.map +1 -1
  91. package/dist/core/index.browser.js +1 -1
  92. package/dist/core/index.browser.js.map +1 -1
  93. package/dist/core/index.d.ts +119 -118
  94. package/dist/core/index.d.ts.map +1 -1
  95. package/dist/core/index.js +1 -1
  96. package/dist/core/index.js.map +1 -1
  97. package/dist/core/index.native.js +1 -1
  98. package/dist/core/index.native.js.map +1 -1
  99. package/dist/core/index.workerd.js +1 -1
  100. package/dist/core/index.workerd.js.map +1 -1
  101. package/dist/crypto/index.browser.js.map +1 -1
  102. package/dist/crypto/index.d.ts +3 -2
  103. package/dist/crypto/index.d.ts.map +1 -1
  104. package/dist/crypto/index.js.map +1 -1
  105. package/dist/datetime/index.js.map +1 -1
  106. package/dist/email/brevo/index.js.map +1 -1
  107. package/dist/email/core/index.d.ts +3 -2
  108. package/dist/email/core/index.d.ts.map +1 -1
  109. package/dist/email/core/index.js.map +1 -1
  110. package/dist/email/core/index.workerd.js.map +1 -1
  111. package/dist/email/smtp/index.d.ts +7 -6
  112. package/dist/email/smtp/index.d.ts.map +1 -1
  113. package/dist/email/smtp/index.js.map +1 -1
  114. package/dist/fake/index.js.map +1 -1
  115. package/dist/lock/core/index.d.ts +5 -4
  116. package/dist/lock/core/index.d.ts.map +1 -1
  117. package/dist/lock/core/index.js.map +1 -1
  118. package/dist/lock/redis/index.js.map +1 -1
  119. package/dist/logger/index.d.ts +10 -9
  120. package/dist/logger/index.d.ts.map +1 -1
  121. package/dist/logger/index.js.map +1 -1
  122. package/dist/mcp/index.d.ts +9 -8
  123. package/dist/mcp/index.d.ts.map +1 -1
  124. package/dist/mcp/index.js +1 -1
  125. package/dist/mcp/index.js.map +1 -1
  126. package/dist/orm/core/index.browser.js +9 -3
  127. package/dist/orm/core/index.browser.js.map +1 -1
  128. package/dist/orm/core/index.bun.js +31 -10
  129. package/dist/orm/core/index.bun.js.map +1 -1
  130. package/dist/orm/core/index.d.ts +33 -14
  131. package/dist/orm/core/index.d.ts.map +1 -1
  132. package/dist/orm/core/index.js +31 -10
  133. package/dist/orm/core/index.js.map +1 -1
  134. package/dist/orm/postgres/index.bun.js.map +1 -1
  135. package/dist/orm/postgres/index.d.ts +6 -5
  136. package/dist/orm/postgres/index.d.ts.map +1 -1
  137. package/dist/orm/postgres/index.js.map +1 -1
  138. package/dist/queue/core/index.d.ts +5 -4
  139. package/dist/queue/core/index.d.ts.map +1 -1
  140. package/dist/queue/core/index.js.map +1 -1
  141. package/dist/queue/core/index.workerd.js.map +1 -1
  142. package/dist/queue/redis/index.d.ts +3 -2
  143. package/dist/queue/redis/index.d.ts.map +1 -1
  144. package/dist/queue/redis/index.js.map +1 -1
  145. package/dist/react/auth/index.browser.js.map +1 -1
  146. package/dist/react/auth/index.js.map +1 -1
  147. package/dist/react/core/index.js.map +1 -1
  148. package/dist/react/form/index.d.ts +5 -0
  149. package/dist/react/form/index.d.ts.map +1 -1
  150. package/dist/react/form/index.js +8 -4
  151. package/dist/react/form/index.js.map +1 -1
  152. package/dist/react/head/index.browser.js.map +1 -1
  153. package/dist/react/head/index.js.map +1 -1
  154. package/dist/react/i18n/index.d.ts +2 -1
  155. package/dist/react/i18n/index.d.ts.map +1 -1
  156. package/dist/react/i18n/index.js.map +1 -1
  157. package/dist/react/intro/index.js.map +1 -1
  158. package/dist/react/router/index.browser.js.map +1 -1
  159. package/dist/react/router/index.d.ts +206 -205
  160. package/dist/react/router/index.d.ts.map +1 -1
  161. package/dist/react/router/index.js.map +1 -1
  162. package/dist/react/testing/index.js.map +1 -1
  163. package/dist/react/ui/index.d.ts +11 -11
  164. package/dist/react/ui/index.d.ts.map +1 -1
  165. package/dist/react/ui/index.js.map +1 -1
  166. package/dist/redis/index.bun.js.map +1 -1
  167. package/dist/redis/index.js.map +1 -1
  168. package/dist/retry/index.js.map +1 -1
  169. package/dist/router/index.js.map +1 -1
  170. package/dist/scheduler/index.d.ts +25 -2
  171. package/dist/scheduler/index.d.ts.map +1 -1
  172. package/dist/scheduler/index.js +12 -0
  173. package/dist/scheduler/index.js.map +1 -1
  174. package/dist/scheduler/index.workerd.js +12 -0
  175. package/dist/scheduler/index.workerd.js.map +1 -1
  176. package/dist/security/index.browser.js +29 -1
  177. package/dist/security/index.browser.js.map +1 -1
  178. package/dist/security/index.d.ts +82 -35
  179. package/dist/security/index.d.ts.map +1 -1
  180. package/dist/security/index.js +56 -3
  181. package/dist/security/index.js.map +1 -1
  182. package/dist/server/auth/index.d.ts +163 -158
  183. package/dist/server/auth/index.d.ts.map +1 -1
  184. package/dist/server/auth/index.js +16 -4
  185. package/dist/server/auth/index.js.map +1 -1
  186. package/dist/server/cookies/index.browser.js.map +1 -1
  187. package/dist/server/cookies/index.js.map +1 -1
  188. package/dist/server/core/index.browser.js.map +1 -1
  189. package/dist/server/core/index.d.ts +35 -34
  190. package/dist/server/core/index.d.ts.map +1 -1
  191. package/dist/server/core/index.js.map +1 -1
  192. package/dist/server/cors/index.d.ts +7 -6
  193. package/dist/server/cors/index.d.ts.map +1 -1
  194. package/dist/server/cors/index.js.map +1 -1
  195. package/dist/server/etag/index.js.map +1 -1
  196. package/dist/server/health/index.d.ts +16 -15
  197. package/dist/server/health/index.d.ts.map +1 -1
  198. package/dist/server/health/index.js.map +1 -1
  199. package/dist/server/links/index.browser.js.map +1 -1
  200. package/dist/server/links/index.d.ts +51 -50
  201. package/dist/server/links/index.d.ts.map +1 -1
  202. package/dist/server/links/index.js.map +1 -1
  203. package/dist/server/metrics/index.js.map +1 -1
  204. package/dist/server/proxy/index.js.map +1 -1
  205. package/dist/server/rate-limit/index.d.ts +6 -5
  206. package/dist/server/rate-limit/index.d.ts.map +1 -1
  207. package/dist/server/rate-limit/index.js.map +1 -1
  208. package/dist/server/static/index.js.map +1 -1
  209. package/dist/server/swagger/index.d.ts +2 -1
  210. package/dist/server/swagger/index.d.ts.map +1 -1
  211. package/dist/server/swagger/index.js.map +1 -1
  212. package/dist/sms/index.js.map +1 -1
  213. package/dist/system/index.browser.js.map +1 -1
  214. package/dist/system/index.js.map +1 -1
  215. package/dist/system/index.workerd.js.map +1 -1
  216. package/dist/topic/core/index.js.map +1 -1
  217. package/dist/topic/redis/index.d.ts +3 -2
  218. package/dist/topic/redis/index.d.ts.map +1 -1
  219. package/dist/topic/redis/index.js.map +1 -1
  220. package/package.json +33 -39
  221. package/src/api/audits/controllers/AdminAuditController.ts +29 -0
  222. package/src/api/audits/entities/audits.ts +1 -0
  223. package/src/api/files/controllers/FileController.ts +24 -0
  224. package/src/api/files/entities/files.ts +1 -0
  225. package/src/api/files/services/FileService.ts +41 -0
  226. package/src/api/jobs/__tests__/$job.spec.ts +501 -24
  227. package/src/api/jobs/entities/jobExecutionEntity.ts +4 -3
  228. package/src/api/jobs/index.ts +47 -10
  229. package/src/api/jobs/primitives/$job.ts +22 -9
  230. package/src/api/jobs/providers/DirectJobDispatcher.ts +71 -0
  231. package/src/api/jobs/providers/JobDispatcher.ts +49 -0
  232. package/src/api/jobs/providers/JobProvider.ts +385 -147
  233. package/src/api/jobs/providers/JobQueueProvider.ts +43 -18
  234. package/src/api/jobs/schemas/jobConfigAtom.ts +9 -3
  235. package/src/api/jobs/schemas/jobExecutionResourceSchema.ts +11 -0
  236. package/src/api/jobs/schemas/jobRegistrationSchema.ts +4 -2
  237. package/src/api/jobs/services/JobService.ts +21 -11
  238. package/src/api/keys/controllers/AdminApiKeyController.ts +23 -0
  239. package/src/api/keys/entities/apiKeyEntity.ts +1 -0
  240. package/src/api/keys/services/ApiKeyService.ts +42 -0
  241. package/src/api/notifications/__tests__/AlephaApiNotifications.spec.ts +63 -0
  242. package/src/api/notifications/controllers/AdminNotificationController.ts +48 -1
  243. package/src/api/notifications/index.ts +13 -3
  244. package/src/api/notifications/jobs/NotificationJobs.ts +0 -6
  245. package/src/api/parameters/controllers/AdminParameterController.ts +26 -0
  246. package/src/api/parameters/services/ParameterProvider.ts +18 -0
  247. package/src/api/payments/controllers/MockCheckoutController.ts +146 -0
  248. package/src/api/payments/index.ts +3 -0
  249. package/src/api/payments/providers/MemoryPaymentProvider.ts +9 -4
  250. package/src/api/payments/providers/PaymentProvider.ts +25 -9
  251. package/src/api/payments/services/PaymentService.ts +3 -0
  252. package/src/api/subscriptions/__tests__/BillingService.spec.ts +218 -0
  253. package/src/api/subscriptions/__tests__/SubscriptionService.spec.ts +278 -0
  254. package/src/api/subscriptions/controllers/AdminSubscriptionController.ts +212 -0
  255. package/src/api/subscriptions/controllers/SubscriptionController.ts +189 -0
  256. package/src/api/subscriptions/entities/subscriptionEvents.ts +54 -0
  257. package/src/api/subscriptions/entities/subscriptions.ts +68 -0
  258. package/src/api/subscriptions/index.ts +133 -0
  259. package/src/api/subscriptions/jobs/SubscriptionJobs.ts +382 -0
  260. package/src/api/subscriptions/middleware/$requireLimit.ts +50 -0
  261. package/src/api/subscriptions/middleware/$requirePlan.ts +49 -0
  262. package/src/api/subscriptions/notifications/SubscriptionNotifications.ts +110 -0
  263. package/src/api/subscriptions/schemas/cancelSubscriptionSchema.ts +8 -0
  264. package/src/api/subscriptions/schemas/changePlanSchema.ts +9 -0
  265. package/src/api/subscriptions/schemas/createSubscriptionSchema.ts +11 -0
  266. package/src/api/subscriptions/schemas/entitlementsSchema.ts +21 -0
  267. package/src/api/subscriptions/schemas/mrrSchema.ts +13 -0
  268. package/src/api/subscriptions/schemas/planDefinitionSchema.ts +71 -0
  269. package/src/api/subscriptions/schemas/planResourceSchema.ts +25 -0
  270. package/src/api/subscriptions/schemas/subscriptionEventResourceSchema.ts +8 -0
  271. package/src/api/subscriptions/schemas/subscriptionQuerySchema.ts +19 -0
  272. package/src/api/subscriptions/schemas/subscriptionResourceSchema.ts +6 -0
  273. package/src/api/subscriptions/schemas/subscriptionSettingsSchema.ts +32 -0
  274. package/src/api/subscriptions/schemas/subscriptionStatsSchema.ts +23 -0
  275. package/src/api/subscriptions/services/BillingService.ts +437 -0
  276. package/src/api/subscriptions/services/SubscriptionConfig.ts +56 -0
  277. package/src/api/subscriptions/services/SubscriptionService.ts +867 -0
  278. package/src/api/subscriptions/services/UsageService.ts +118 -0
  279. package/src/api/users/__tests__/Registration-emailMode.spec.ts +203 -0
  280. package/src/api/users/__tests__/UsernameSlugger.spec.ts +138 -0
  281. package/src/api/users/atoms/realmAuthSettingsAtom.ts +41 -3
  282. package/src/api/users/controllers/AdminSessionController.ts +29 -0
  283. package/src/api/users/controllers/AdminUserController.ts +32 -0
  284. package/src/api/users/index.ts +3 -0
  285. package/src/api/users/services/CredentialService.ts +5 -0
  286. package/src/api/users/services/RegistrationService.ts +49 -1
  287. package/src/api/users/services/SessionCrudService.ts +16 -0
  288. package/src/api/users/services/SessionService.ts +17 -59
  289. package/src/api/users/services/UsernameSlugger.ts +195 -0
  290. package/src/bucket/primitives/$bucket.ts +21 -0
  291. package/src/bucket/providers/CloudflareR2Provider.ts +15 -0
  292. package/src/bucket/providers/FileStorageProvider.ts +9 -0
  293. package/src/bucket/providers/LocalFileStorageProvider.ts +14 -0
  294. package/src/bucket/providers/MemoryFileStorageProvider.ts +9 -0
  295. package/src/bucket/providers/NodeS3BucketProvider.ts +35 -0
  296. package/src/cache/core/__tests__/$cache.memory.spec.ts +450 -0
  297. package/src/cache/core/__tests__/$cache.swr.spec.ts +394 -0
  298. package/src/cache/core/index.ts +16 -0
  299. package/src/cache/core/primitives/$cache.ts +367 -24
  300. package/src/cache/database/__tests__/DatabaseCacheProvider.behavior.spec.ts +203 -0
  301. package/src/cache/database/__tests__/DatabaseCacheProvider.spec.ts +110 -0
  302. package/src/cache/database/entities/cacheEntries.ts +55 -0
  303. package/src/cache/database/index.ts +36 -0
  304. package/src/cache/database/providers/DatabaseCacheProvider.ts +348 -0
  305. package/src/cli/core/services/ProjectScaffolder.ts +0 -2
  306. package/src/cli/core/tasks/BuildCloudflareTask.ts +33 -3
  307. package/src/cli/core/tasks/BuildSitemapTask.ts +7 -0
  308. package/src/cli/core/tasks/BuildVercelTask.ts +82 -3
  309. package/src/cli/core/templates/agentMd.ts +39 -4
  310. package/src/cli/core/templates/biomeJson.ts +25 -1
  311. package/src/cli/core/templates/saasAdminLayoutTsx.ts +2 -2
  312. package/src/cli/platform/__tests__/CloudflareAdapter.spec.ts +117 -0
  313. package/src/cli/platform/__tests__/detectResources.spec.ts +96 -0
  314. package/src/cli/platform/adapters/CloudflareAdapter.ts +104 -7
  315. package/src/cli/platform/atoms/platformOptions.ts +13 -0
  316. package/src/cli/platform/commands/platform.ts +7 -1
  317. package/src/cli/platform/schemas/platform.ts +1 -0
  318. package/src/cli/platform/services/CloudflareApi.ts +61 -0
  319. package/src/cli/platform/services/PlatformOrchestrator.ts +9 -4
  320. package/src/core/__tests__/$module.spec.ts +2 -2
  321. package/src/core/primitives/$module.ts +4 -4
  322. package/src/mcp/providers/McpServerProvider.ts +1 -1
  323. package/src/orm/core/providers/DatabaseTypeProvider.ts +9 -3
  324. package/src/orm/core/providers/drivers/DatabaseProvider.ts +1 -1
  325. package/src/orm/core/schemas/insertSchema.ts +10 -2
  326. package/src/orm/core/services/Repository.ts +27 -7
  327. package/src/react/form/hooks/useFormState.ts +8 -1
  328. package/src/react/form/index.ts +10 -1
  329. package/src/react/form/services/FormModel.ts +9 -3
  330. package/src/scheduler/index.ts +14 -0
  331. package/src/scheduler/providers/CronProvider.ts +13 -0
  332. package/src/security/atoms/currentTenantAtom.ts +34 -0
  333. package/src/security/index.browser.ts +1 -0
  334. package/src/security/index.ts +12 -1
  335. package/src/security/primitives/$issuer.ts +17 -1
  336. package/src/security/providers/SecurityProvider.ts +37 -0
  337. package/src/server/auth/__tests__/validateRedirectUri.spec.ts +78 -0
  338. package/src/server/auth/providers/ServerAuthProvider.ts +21 -5
  339. package/tsconfig.base.json +2 -1
  340. package/dist/react/websocket/index.d.ts +0 -117
  341. package/dist/react/websocket/index.d.ts.map +0 -1
  342. package/dist/react/websocket/index.js +0 -108
  343. package/dist/react/websocket/index.js.map +0 -1
  344. package/dist/websocket/index.browser.js +0 -844
  345. package/dist/websocket/index.browser.js.map +0 -1
  346. package/dist/websocket/index.d.ts +0 -876
  347. package/dist/websocket/index.d.ts.map +0 -1
  348. package/dist/websocket/index.js +0 -1175
  349. package/dist/websocket/index.js.map +0 -1
  350. package/src/react/websocket/hooks/useRoom.tsx +0 -251
  351. package/src/react/websocket/index.ts +0 -7
  352. package/src/websocket/__tests__/$channel.spec.ts +0 -30
  353. package/src/websocket/__tests__/$websocket-new.spec.ts +0 -195
  354. package/src/websocket/__tests__/RoomManager.spec.ts +0 -146
  355. package/src/websocket/__tests__/websocket-integration.spec.ts +0 -951
  356. package/src/websocket/errors/WebSocketError.ts +0 -34
  357. package/src/websocket/index.browser.ts +0 -25
  358. package/src/websocket/index.shared.ts +0 -8
  359. package/src/websocket/index.ts +0 -85
  360. package/src/websocket/interfaces/WebSocketInterfaces.ts +0 -252
  361. package/src/websocket/primitives/$channel.ts +0 -131
  362. package/src/websocket/primitives/$websocket.ts +0 -107
  363. package/src/websocket/providers/NodeWebSocketServerProvider.ts +0 -617
  364. package/src/websocket/providers/WebSocketServerProvider.ts +0 -56
  365. package/src/websocket/services/RoomManager.ts +0 -160
  366. package/src/websocket/services/WebSocketClient.ts +0 -642
  367. package/src/websocket/services/WebSocketTopicService.ts +0 -108
@@ -8,21 +8,20 @@ import {
8
8
  type TSchema,
9
9
  } from "alepha";
10
10
  import { DateTimeProvider, type DurationLike } from "alepha/datetime";
11
+ import { LockProvider } from "alepha/lock";
11
12
  import type { LogEntry } from "alepha/logger";
12
13
  import { $logger } from "alepha/logger";
13
- import { $repository } from "alepha/orm";
14
+ import { $repository, DbEntityNotFoundError } from "alepha/orm";
14
15
  import { CronProvider } from "alepha/scheduler";
15
16
  import {
16
17
  type JobStatus,
17
18
  jobExecutionEntity,
18
19
  } from "../entities/jobExecutionEntity.ts";
19
- import type {
20
- JobPrimitiveOptions,
21
- JobPriority,
22
- JobRetryBackoff,
23
- JobRetryOptions,
24
- } from "../primitives/$job.ts";
20
+ import type { JobPrimitiveOptions, JobPriority } from "../primitives/$job.ts";
25
21
  import { jobConfig } from "../schemas/jobConfigAtom.ts";
22
+ import { DirectJobDispatcher } from "./DirectJobDispatcher.ts";
23
+ import type { JobDispatcher } from "./JobDispatcher.ts";
24
+ import { JobQueueProvider } from "./JobQueueProvider.ts";
26
25
 
27
26
  // -----------------------------------------------------------------------------------------------------------------
28
27
 
@@ -40,8 +39,6 @@ const PRIORITY_REVERSE: Record<number, JobPriority> = {
40
39
  3: "low",
41
40
  };
42
41
 
43
- const SWEEP_CRON = "*/5 * * * *";
44
-
45
42
  // -----------------------------------------------------------------------------------------------------------------
46
43
 
47
44
  export interface PushOptions {
@@ -72,36 +69,78 @@ export interface CancelContext {
72
69
  cancelledByName?: string;
73
70
  }
74
71
 
72
+ /**
73
+ * The declared shape of the job (set at registration time).
74
+ *
75
+ * **Important** — this `kind` is the *declared* form. The *effective*
76
+ * runtime mode (cron / queue / direct) is exposed by
77
+ * `JobProvider.effectiveMode(name)` and the `JobRegistration.type` field on
78
+ * the admin schema. Don't conflate the two: a `queue` kind can run as
79
+ * `direct` at runtime when no queue dispatcher is loaded.
80
+ */
75
81
  interface JobRuntimeRegistration {
76
82
  name: string;
77
83
  options: JobPrimitiveOptions;
78
- type: "cron" | "queue";
84
+ kind: "cron" | "queue";
79
85
  }
80
86
 
87
+ export type JobEffectiveMode = "cron" | "queue" | "direct";
88
+
81
89
  // -----------------------------------------------------------------------------------------------------------------
82
90
 
83
91
  /**
84
- * Coordinates cron (scheduler) and queue (push) jobs with a durable outbox
85
- * table and a single reconciliation sweep.
92
+ * Coordinates cron and push jobs with a durable outbox table and a single
93
+ * reconciliation sweep. The actual delivery channel (queue / direct) is
94
+ * abstracted behind {@link JobDispatcher}, substituted by DI:
86
95
  *
87
- * Queue-mode flow:
88
- * push() → INSERT row (pending) + queue.send({ executionId })
89
- * worker → SELECT row UPDATE running → handler → DELETE (ok) / UPDATE (error)
96
+ * - **DirectJobDispatcher** (default, registered by `AlephaApiJobs`) —
97
+ * runs the handler in-process right after `push()` returns.
98
+ * - **QueueJobDispatcher** (registered by `AlephaApiJobsQueue`) sends
99
+ * the executionId through `AlephaQueue` so a pool of workers can pick
100
+ * it up.
90
101
  *
91
- * Cron-mode flow:
92
- * scheduler tick handler runs inlineINSERT row only on error
102
+ * Push flow:
103
+ * push() INSERT row (pending)dispatcher.dispatch(jobName, id)
104
+ * worker → claim → UPDATE running → handler → DELETE/UPDATE on success
105
+ * → UPDATE error / scheduled (retry) on failure
93
106
  *
94
- * Sweep responsibilities (every `sweepInterval`):
107
+ * Cron flow:
108
+ * scheduler tick → acquire lock → executeInline (no retry)
109
+ * → enqueue + dispatch (retry declared)
110
+ *
111
+ * Sweep responsibilities (every `sweepCron`):
95
112
  * - re-enqueue pending rows older than `staleThreshold`
96
- * - fail running rows older than `max(timeout*2, runTimeout)`
97
- * - move `scheduled` rows with `scheduledAt <= now` to pending + enqueue
98
- * - trim per-job history beyond `keepLastSuccess` / `keepLastError`
113
+ * - mark crashed running rows as failed and apply retry policy
114
+ * - move `scheduled` rows with `scheduledAt <= now` to pending + dispatch
115
+ *
116
+ * Trim runs on its own cron (`trimCron`, default hourly):
117
+ * - per-job history trimmed beyond `keepLastSuccess` / `keepLastError`
118
+ * - decoupled from sweep because trim cost scales with job count, not
119
+ * retry latency — running it every sweep is wasted work for most apps.
99
120
  */
100
121
  export class JobProvider {
101
122
  protected readonly alepha = $inject(Alepha);
102
123
  protected readonly dt = $inject(DateTimeProvider);
103
124
  protected readonly cronProvider = $inject(CronProvider);
125
+ protected readonly lockProvider = $inject(LockProvider);
104
126
  protected readonly config = $state(jobConfig);
127
+
128
+ /**
129
+ * Resolved at first use (after the container is fully wired) — picks
130
+ * the queue dispatcher when `AlephaApiJobsQueue` was loaded, otherwise
131
+ * the direct dispatcher. Lazy because both dispatchers inject
132
+ * `JobProvider` themselves; resolving them at field-init time would
133
+ * create a circular construction.
134
+ */
135
+ protected dispatcherRef?: JobDispatcher;
136
+ public get dispatcher(): JobDispatcher {
137
+ if (!this.dispatcherRef) {
138
+ this.dispatcherRef = this.alepha.has(JobQueueProvider)
139
+ ? this.alepha.inject(JobQueueProvider)
140
+ : this.alepha.inject(DirectJobDispatcher);
141
+ }
142
+ return this.dispatcherRef;
143
+ }
105
144
  protected readonly log = $logger();
106
145
  protected readonly executions = $repository(jobExecutionEntity);
107
146
 
@@ -111,14 +150,6 @@ export class JobProvider {
111
150
  protected readonly perExecutionLogs = new Map<string, LogEntry[]>();
112
151
  protected stopping = false;
113
152
 
114
- /**
115
- * Set by `JobQueueProvider` when `AlephaApiJobsQueue` is loaded.
116
- * When null, queue-mode jobs cannot be pushed.
117
- */
118
- public queueDispatch:
119
- | ((jobName: string, executionId: string) => Promise<void>)
120
- | null = null;
121
-
122
153
  // --- Registration -----------------------------------------------------------------------------------------------
123
154
 
124
155
  public registerJob(name: string, options: JobPrimitiveOptions): void {
@@ -136,9 +167,9 @@ export class JobProvider {
136
167
  );
137
168
  }
138
169
 
139
- const type: "cron" | "queue" = options.cron ? "cron" : "queue";
140
- this.jobs.set(name, { name, options, type });
141
- this.log.debug(`Registered ${type} job '${name}'`, {
170
+ const kind: "cron" | "queue" = options.cron ? "cron" : "queue";
171
+ this.jobs.set(name, { name, options, kind });
172
+ this.log.debug(`Registered ${kind} job '${name}'`, {
142
173
  cron: options.cron,
143
174
  priority: options.priority ?? "normal",
144
175
  retries: options.retry?.retries ?? 0,
@@ -159,28 +190,200 @@ export class JobProvider {
159
190
  return this.jobs;
160
191
  }
161
192
 
193
+ /**
194
+ * Resolves what *actually* runs at dispatch time. Cron jobs are always
195
+ * "cron"; non-cron jobs delegate to the active `JobDispatcher` (queue
196
+ * vs. direct), which is determined by which modules the app loaded.
197
+ */
198
+ public effectiveMode(name: string): JobEffectiveMode {
199
+ const reg = this.getRegistration(name);
200
+ if (reg.kind === "cron") return "cron";
201
+ return this.dispatcher.kind;
202
+ }
203
+
162
204
  // --- Cron execution (inline, no queue) --------------------------------------------------------------------------
163
205
 
164
206
  protected async runCron(name: string): Promise<void> {
165
207
  const registration = this.getRegistration(name);
166
- if (registration.type !== "cron") {
208
+ if (registration.kind !== "cron") {
167
209
  throw new AlephaError(`Job '${name}' is not cron-mode`);
168
210
  }
169
- if (this.stopping) return;
170
-
171
- const executionId = crypto.randomUUID();
172
- const promise = this.executeInline(registration, executionId, {
173
- payload: undefined,
174
- attempt: 1,
211
+ await this.runCronLocked(registration, {
175
212
  triggeredBy: "system",
176
213
  triggeredByName: "system (cron)",
177
214
  });
178
- this.inFlight.add(promise);
215
+ }
216
+
217
+ /**
218
+ * Cron-mode runner that respects the per-job distributed lock.
219
+ * Used by both the scheduled tick and manual `trigger()` calls so that an
220
+ * admin-triggered run on one instance can't race a scheduled run on another.
221
+ *
222
+ * **Two paths depending on `retry`:**
223
+ *
224
+ * - **No `retry`** — runs the handler inline. No DB row on success;
225
+ * error row only on failure. The "next tick" is the implicit retry.
226
+ * - **`retry` declared** — enqueues a synthetic execution row and hands
227
+ * it to the dispatcher. The handler then runs through the same path
228
+ * as a queue/direct push (claim, retry-on-fail, sweep recovery). Use
229
+ * this when a single failed tick must not block work for the whole
230
+ * `cron` interval (e.g. once-daily jobs).
231
+ */
232
+ protected async runCronLocked(
233
+ registration: JobRuntimeRegistration,
234
+ ctx: { triggeredBy?: string; triggeredByName?: string },
235
+ ): Promise<void> {
236
+ if (this.stopping) return;
237
+
238
+ const useLock = registration.options.lock !== false;
239
+ if (useLock) {
240
+ const acquired = await this.acquireCronLock(registration);
241
+ if (!acquired) {
242
+ this.log.debug(
243
+ `Cron '${registration.name}' skipped — another instance holds the lock`,
244
+ );
245
+ return;
246
+ }
247
+ }
248
+
179
249
  try {
180
- await promise;
250
+ if (registration.options.retry) {
251
+ await this.enqueueCronExecution(registration, ctx);
252
+ return;
253
+ }
254
+
255
+ const executionId = crypto.randomUUID();
256
+ const promise = this.executeInline(registration, executionId, {
257
+ payload: undefined,
258
+ attempt: 1,
259
+ triggeredBy: ctx.triggeredBy,
260
+ triggeredByName: ctx.triggeredByName,
261
+ });
262
+ this.inFlight.add(promise);
263
+ try {
264
+ await promise;
265
+ } finally {
266
+ this.inFlight.delete(promise);
267
+ }
181
268
  } finally {
182
- this.inFlight.delete(promise);
269
+ if (useLock) {
270
+ await this.releaseCronLock(registration);
271
+ }
272
+ }
273
+ }
274
+
275
+ /**
276
+ * Materialize a cron tick into the outbox so it goes through the normal
277
+ * retry/sweep path. Used when the user opts into `retry` on a cron job —
278
+ * a transient failure no longer means "wait for the next cron tick", it
279
+ * means "the sweep will retry within `sweepCron`".
280
+ */
281
+ protected async enqueueCronExecution(
282
+ registration: JobRuntimeRegistration,
283
+ ctx: { triggeredBy?: string; triggeredByName?: string },
284
+ ): Promise<void> {
285
+ const opts = registration.options;
286
+ const maxAttempts = (opts.retry?.retries ?? 0) + 1;
287
+ const execution = await this.executions.create({
288
+ jobName: registration.name,
289
+ payload: undefined,
290
+ status: "pending",
291
+ priority: PRIORITY_MAP[opts.priority ?? "normal"],
292
+ maxAttempts,
293
+ triggeredBy: ctx.triggeredBy,
294
+ triggeredByName: ctx.triggeredByName,
295
+ });
296
+ await this.dispatch(registration.name, execution.id);
297
+ }
298
+
299
+ /**
300
+ * Acquire a per-job NX lock keyed by `cron-job:<name>` so that a single
301
+ * tick across all replicas runs exactly one execution. Auto-expires after
302
+ * `2 * timeout` (or 5 minutes if no per-job timeout) so a crashed worker
303
+ * cannot permanently block the cron from firing.
304
+ *
305
+ * **Caveat — same-process double-fire is not prevented.** The lock value
306
+ * is a per-process holder id, so two concurrent ticks on the same process
307
+ * (e.g. a scheduled tick overlapping an admin `trigger()` call) will both
308
+ * see "we own it". This is acceptable for the multi-replica use case the
309
+ * lock targets; a process that overlaps its own cron handler should set a
310
+ * smaller `timeout` or use idempotent handler logic. A future fix can add
311
+ * a per-process Set guard before reaching the LockProvider.
312
+ */
313
+ protected async acquireCronLock(
314
+ registration: JobRuntimeRegistration,
315
+ ): Promise<boolean> {
316
+ const lockKey = this.cronLockKey(registration.name);
317
+ const ttlMs = registration.options.timeout
318
+ ? this.dt.duration(registration.options.timeout).as("milliseconds") * 2
319
+ : 5 * 60 * 1000;
320
+ const value = `${this.lockHolderId},${this.dt.nowISOString()}`;
321
+ try {
322
+ const stored = await this.lockProvider.set(lockKey, value, true, ttlMs);
323
+ const [holderId] = stored.split(",");
324
+ return holderId === this.lockHolderId;
325
+ } catch (e) {
326
+ this.log.warn(`Cron lock acquire failed for '${registration.name}'`, e);
327
+ return true; // Fail-open: better to risk a duplicate than to silently miss a tick.
328
+ }
329
+ }
330
+
331
+ /**
332
+ * Update only when the row is still in one of the expected statuses.
333
+ * Logs and returns silently when the guard rejects — this happens when a
334
+ * concurrent operation (most often `cancel()`) has already moved the row
335
+ * into a terminal state. We must not overwrite that.
336
+ */
337
+ protected async guardedUpdate(
338
+ executionId: string,
339
+ expectedStatuses: JobStatus[],
340
+ patch: Parameters<typeof this.executions.updateById>[1],
341
+ label: string,
342
+ ): Promise<void> {
343
+ try {
344
+ await this.executions.updateOne(
345
+ { id: { eq: executionId }, status: { inArray: expectedStatuses } },
346
+ patch,
347
+ );
348
+ } catch (e) {
349
+ if (e instanceof DbEntityNotFoundError) {
350
+ this.log.debug(
351
+ `${label}: row ${executionId} not in expected status — skipping write`,
352
+ );
353
+ return;
354
+ }
355
+ throw e;
356
+ }
357
+ }
358
+
359
+ protected async releaseCronLock(
360
+ registration: JobRuntimeRegistration,
361
+ ): Promise<void> {
362
+ try {
363
+ await this.lockProvider.del(this.cronLockKey(registration.name));
364
+ } catch (e) {
365
+ this.log.debug(
366
+ `Cron lock release failed for '${registration.name}' (will expire by TTL)`,
367
+ e,
368
+ );
369
+ }
370
+ }
371
+
372
+ protected cronLockKey(jobName: string): string {
373
+ return `alepha.api.jobs.cron:${jobName}`;
374
+ }
375
+
376
+ /**
377
+ * Stable per-process id used as the lock value — survives multiple ticks.
378
+ * Lazy so that Cloudflare Workers (which forbid random in global scope)
379
+ * stay happy.
380
+ */
381
+ protected lockHolderIdValue?: string;
382
+ protected get lockHolderId(): string {
383
+ if (!this.lockHolderIdValue) {
384
+ this.lockHolderIdValue = crypto.randomUUID();
183
385
  }
386
+ return this.lockHolderIdValue;
184
387
  }
185
388
 
186
389
  /**
@@ -328,7 +531,7 @@ export class JobProvider {
328
531
  options?: PushOptions,
329
532
  ): Promise<string> {
330
533
  const registration = this.getRegistration(name);
331
- if (registration.type !== "queue") {
534
+ if (registration.kind !== "queue") {
332
535
  throw new AlephaError(
333
536
  `Job '${name}' is not queue-mode (no schema declared). Use trigger() instead.`,
334
537
  );
@@ -375,7 +578,7 @@ export class JobProvider {
375
578
  triggeredByName: options.triggeredByName,
376
579
  });
377
580
  if (status === "pending") {
378
- await this.dispatchToQueue(name, execution.id);
581
+ await this.dispatch(name, execution.id);
379
582
  } else if (status === "scheduled" && scheduledAt) {
380
583
  this.scheduleOptimisticDispatch(name, execution.id, scheduledAt);
381
584
  }
@@ -394,7 +597,7 @@ export class JobProvider {
394
597
  });
395
598
 
396
599
  if (status === "pending") {
397
- await this.dispatchToQueue(name, execution.id);
600
+ await this.dispatch(name, execution.id);
398
601
  } else if (status === "scheduled" && scheduledAt) {
399
602
  this.scheduleOptimisticDispatch(name, execution.id, scheduledAt);
400
603
  }
@@ -428,7 +631,7 @@ export class JobProvider {
428
631
  if (items.length === 0) return [];
429
632
 
430
633
  const registration = this.getRegistration(name);
431
- if (registration.type !== "queue") {
634
+ if (registration.kind !== "queue") {
432
635
  throw new AlephaError(
433
636
  `Job '${name}' is not queue-mode (no schema declared).`,
434
637
  );
@@ -487,10 +690,13 @@ export class JobProvider {
487
690
 
488
691
  if (bulk.length > 0) {
489
692
  const created = await this.executions.createMany(bulk);
693
+ // Collect pending rows so we hand them to the dispatcher in a single
694
+ // batch — the queue dispatcher can fan them out in one network call.
695
+ const toDispatch: Array<{ jobName: string; executionId: string }> = [];
490
696
  for (const exec of created) {
491
697
  ids.push(exec.id);
492
698
  if (exec.status === "pending" && !this.stopping) {
493
- await this.dispatchToQueue(name, exec.id);
699
+ toDispatch.push({ jobName: name, executionId: exec.id });
494
700
  } else if (
495
701
  exec.status === "scheduled" &&
496
702
  exec.scheduledAt &&
@@ -499,6 +705,9 @@ export class JobProvider {
499
705
  this.scheduleOptimisticDispatch(name, exec.id, exec.scheduledAt);
500
706
  }
501
707
  }
708
+ if (toDispatch.length > 0) {
709
+ await this.dispatchMany(toDispatch);
710
+ }
502
711
  }
503
712
 
504
713
  this.log.debug(`pushMany '${name}': ${ids.length} jobs created`, {
@@ -509,17 +718,28 @@ export class JobProvider {
509
718
  return ids;
510
719
  }
511
720
 
512
- protected async dispatchToQueue(
721
+ /**
722
+ * Hand a single execution to the active `JobDispatcher`. Whether that
723
+ * results in a queue send or in-process execution depends on which
724
+ * dispatcher is wired (see {@link JobDispatcher}).
725
+ */
726
+ protected async dispatch(
513
727
  jobName: string,
514
728
  executionId: string,
515
729
  ): Promise<void> {
516
730
  if (this.stopping) return;
517
- if (!this.queueDispatch) {
518
- throw new AlephaError(
519
- `Queue-mode job '${jobName}' cannot be pushed: AlephaApiJobsQueue is not loaded. Add '.with(AlephaApiJobsQueue)' to your app.`,
520
- );
521
- }
522
- await this.queueDispatch(jobName, executionId);
731
+ await this.dispatcher.dispatch(jobName, executionId);
732
+ }
733
+
734
+ /**
735
+ * Batched variant. Used by `pushMany` so a backing queue can do a single
736
+ * batch network call (e.g. Cloudflare Queues `sendBatch`).
737
+ */
738
+ protected async dispatchMany(
739
+ items: Array<{ jobName: string; executionId: string }>,
740
+ ): Promise<void> {
741
+ if (this.stopping || items.length === 0) return;
742
+ await this.dispatcher.dispatchMany(items);
523
743
  }
524
744
 
525
745
  // --- Manual trigger (admin / CLI) ------------------------------------------------------------------------------
@@ -530,11 +750,8 @@ export class JobProvider {
530
750
  ): Promise<void> {
531
751
  const registration = this.getRegistration(name);
532
752
 
533
- if (registration.type === "cron") {
534
- const executionId = crypto.randomUUID();
535
- await this.executeInline(registration, executionId, {
536
- payload: undefined,
537
- attempt: 1,
753
+ if (registration.kind === "cron") {
754
+ await this.runCronLocked(registration, {
538
755
  triggeredBy: context?.triggeredBy,
539
756
  triggeredByName: context?.triggeredByName,
540
757
  });
@@ -603,10 +820,13 @@ export class JobProvider {
603
820
  });
604
821
  return;
605
822
  }
606
- if (registration.type !== "queue") {
607
- this.log.warn(`Job '${jobName}' is not queue-mode skipping`, {
608
- executionId,
609
- });
823
+ // Both `queue` and cron-with-retry execute through the outbox path —
824
+ // the DB-level `claim()` is the actual concurrency guard.
825
+ if (registration.kind !== "queue" && !registration.options.retry) {
826
+ this.log.warn(
827
+ `Job '${jobName}' has no outbox path (no schema and no retry) — skipping`,
828
+ { executionId },
829
+ );
610
830
  return;
611
831
  }
612
832
 
@@ -627,15 +847,12 @@ export class JobProvider {
627
847
  const opts = registration.options;
628
848
  const record = opts.record ?? "error";
629
849
 
630
- const claimed = await this.claim(executionId);
631
- if (!claimed) {
850
+ const execution = await this.claim(executionId);
851
+ if (!execution) {
632
852
  this.log.debug(`Execution ${executionId} already claimed, skipping`);
633
853
  return;
634
854
  }
635
855
 
636
- const execution = await this.executions.findById(executionId);
637
- if (!execution) return;
638
-
639
856
  const contextId = this.alepha.context.createContextId();
640
857
  this.perExecutionLogs.set(contextId, []);
641
858
 
@@ -726,21 +943,28 @@ export class JobProvider {
726
943
  }
727
944
  }
728
945
 
729
- protected async claim(executionId: string): Promise<boolean> {
730
- const execution = await this.executions.findById(executionId);
731
- if (!execution) return false;
946
+ /**
947
+ * Transition pending running and return the post-update row.
948
+ * Two round-trips: read current attempt, then guarded UPDATE … RETURNING.
949
+ * Returns null when the row is gone or already claimed by another worker.
950
+ * The returned row replaces a separate post-claim findById, so the dispatch
951
+ * path is 2 queries instead of 3.
952
+ */
953
+ protected async claim(executionId: string) {
954
+ const current = await this.executions.findById(executionId);
955
+ if (!current) return null;
732
956
  try {
733
- await this.executions.updateOne(
957
+ return await this.executions.updateOne(
734
958
  { id: { eq: executionId }, status: { eq: "pending" } },
735
959
  {
736
960
  status: "running",
737
- attempt: execution.attempt + 1,
961
+ attempt: current.attempt + 1,
738
962
  startedAt: this.dt.nowISOString(),
739
963
  },
740
964
  );
741
- return true;
742
- } catch {
743
- return false;
965
+ } catch (e) {
966
+ if (e instanceof DbEntityNotFoundError) return null;
967
+ throw e;
744
968
  }
745
969
  }
746
970
 
@@ -756,46 +980,67 @@ export class JobProvider {
756
980
  const retry = opts.retry;
757
981
  const maxAttempts = (retry?.retries ?? 0) + 1;
758
982
 
983
+ // `retries: 2` means "1 initial + 2 retries = 3 total attempts". Retry
984
+ // while we have not yet *executed* the maxAttempts'th attempt — i.e.
985
+ // currentAttempt (the one that just failed) is strictly less than
986
+ // maxAttempts. The off-by-one fix: previously this was
987
+ // `currentAttempt + 1 < maxAttempts`, which only ran 2 attempts for
988
+ // `retries: 2`.
759
989
  const canRetry =
760
990
  retry &&
761
- currentAttempt + 1 < maxAttempts &&
991
+ currentAttempt < maxAttempts &&
762
992
  (retry.when ? retry.when(error) : true);
763
993
 
764
994
  if (canRetry) {
765
- const nextScheduledAt = this.computeBackoff(retry, currentAttempt + 1);
995
+ // Retries are sweep-driven: write the row as `scheduled` with
996
+ // `scheduledAt = now`. The next sweep tick (every `sweepCron`,
997
+ // default 5 minutes) re-dispatches it. This means the first retry
998
+ // can land anywhere from a few seconds to ~5 minutes later — the
999
+ // exact moment depends on when the next sweep tick fires.
1000
+ //
1001
+ // We deliberately do NOT use exponential backoff or a local timer.
1002
+ // - Exponential backoff was platform-dependent (precise on Node
1003
+ // via setTimeout, but degraded to "next sweep" on Cloudflare
1004
+ // Workers where timers don't survive invocations). The behavior
1005
+ // is now identical everywhere.
1006
+ // - Sweep-only retry keeps the retry path observable from a single
1007
+ // place (the DB) and removes a class of races between the timer
1008
+ // and the sweep.
1009
+ const nextScheduledAt = this.dt.nowISOString();
766
1010
  this.log.info(
767
- `Job '${jobName}' failed, scheduling retry ${currentAttempt + 1}/${maxAttempts}`,
768
- { executionId, error: error.message, nextScheduledAt },
1011
+ `Job '${jobName}' failed, scheduling retry ${currentAttempt + 1}/${maxAttempts} (sweep will pick up)`,
1012
+ { executionId, error: error.message },
769
1013
  );
770
- await this.executions.updateById(executionId, {
771
- status: "scheduled",
772
- error: error.message,
773
- scheduledAt: nextScheduledAt,
774
- logs: this.snapshotLogs(contextId),
775
- });
776
- // Optimistic dispatch: fire a local timer so the retry runs as close to
777
- // `scheduledAt` as possible. The sweep is the safety net for worker
778
- // crashes and stateless runtimes (CF Workers, where setTimeout won't
779
- // survive across invocations anyway).
780
- const delayMs = Math.max(
781
- 0,
782
- new Date(nextScheduledAt).getTime() - this.dt.nowMillis(),
1014
+ // Guard with `status: running` so a concurrent cancel that has already
1015
+ // flipped the row to 'cancelled' is not overwritten by the retry write.
1016
+ await this.guardedUpdate(
1017
+ executionId,
1018
+ ["running"],
1019
+ {
1020
+ status: "scheduled",
1021
+ error: error.message,
1022
+ scheduledAt: nextScheduledAt,
1023
+ logs: this.snapshotLogs(contextId),
1024
+ },
1025
+ "retry-after-failure",
783
1026
  );
784
- this.dt.createTimeout(() => {
785
- void this.dispatchScheduled(jobName, executionId);
786
- }, delayMs);
787
1027
  } else {
788
1028
  this.log.info(
789
1029
  `Job '${jobName}' dead after ${currentAttempt} attempt(s)`,
790
1030
  { executionId, error: error.message },
791
1031
  );
792
- await this.executions.updateById(executionId, {
793
- status: "error",
794
- error: error.message,
795
- completedAt: this.dt.nowISOString(),
796
- key: null,
797
- logs: this.snapshotLogs(contextId),
798
- });
1032
+ await this.guardedUpdate(
1033
+ executionId,
1034
+ ["running"],
1035
+ {
1036
+ status: "error",
1037
+ error: error.message,
1038
+ completedAt: this.dt.nowISOString(),
1039
+ key: null,
1040
+ logs: this.snapshotLogs(contextId),
1041
+ },
1042
+ "terminal-failure",
1043
+ );
799
1044
  }
800
1045
 
801
1046
  await this.alepha.events.emit(
@@ -805,30 +1050,6 @@ export class JobProvider {
805
1050
  );
806
1051
  }
807
1052
 
808
- protected computeBackoff(retry: JobRetryOptions, attempt: number): string {
809
- const now = this.dt.now();
810
- if (!retry.backoff) {
811
- return now.add(1, "second").toISOString();
812
- }
813
- if (Array.isArray(retry.backoff)) {
814
- return now.add(this.dt.duration(retry.backoff)).toISOString();
815
- }
816
- const backoff = retry.backoff as JobRetryBackoff;
817
- const initial = this.dt.duration(backoff.initial).as("milliseconds");
818
- const factor = backoff.factor ?? 2;
819
- let delayMs = initial * factor ** (attempt - 1);
820
- if (backoff.max) {
821
- delayMs = Math.min(
822
- delayMs,
823
- this.dt.duration(backoff.max).as("milliseconds"),
824
- );
825
- }
826
- if (backoff.jitter) {
827
- delayMs = delayMs * (0.75 + Math.random() * 0.5);
828
- }
829
- return now.add(delayMs, "millisecond").toISOString();
830
- }
831
-
832
1053
  protected snapshotLogs(contextId: string): LogEntry[] | undefined {
833
1054
  const entries = this.perExecutionLogs.get(contextId);
834
1055
  if (!entries || entries.length === 0) return undefined;
@@ -866,7 +1087,7 @@ export class JobProvider {
866
1087
  for (const exec of due) {
867
1088
  if (!this.jobs.has(exec.jobName)) continue;
868
1089
  await this.executions.updateById(exec.id, { status: "pending" });
869
- await this.dispatchToQueueSafe(exec.jobName, exec.id);
1090
+ await this.dispatchSafe(exec.jobName, exec.id);
870
1091
  }
871
1092
 
872
1093
  // 2. Stale pending rows → re-dispatch
@@ -882,7 +1103,7 @@ export class JobProvider {
882
1103
  });
883
1104
  for (const exec of stale) {
884
1105
  if (!this.jobs.has(exec.jobName)) continue;
885
- await this.dispatchToQueueSafe(exec.jobName, exec.id);
1106
+ await this.dispatchSafe(exec.jobName, exec.id);
886
1107
  }
887
1108
 
888
1109
  // 3. Crashed running rows → mark as failed + apply retry
@@ -910,20 +1131,17 @@ export class JobProvider {
910
1131
  await this.handleFailure(exec.id, reg, exec.attempt, err, "");
911
1132
  }
912
1133
  }
913
-
914
- // 4. Trim ring buffer per job
915
- await this.trimRingBuffers();
916
1134
  } catch (e) {
917
1135
  this.log.error("Sweep failed", { error: e });
918
1136
  }
919
1137
  }
920
1138
 
921
- protected async dispatchToQueueSafe(
1139
+ protected async dispatchSafe(
922
1140
  jobName: string,
923
1141
  executionId: string,
924
1142
  ): Promise<void> {
925
1143
  try {
926
- await this.dispatchToQueue(jobName, executionId);
1144
+ await this.dispatch(jobName, executionId);
927
1145
  } catch (e) {
928
1146
  this.log.warn(`Sweep failed to dispatch ${jobName} (${executionId})`, e);
929
1147
  }
@@ -944,7 +1162,7 @@ export class JobProvider {
944
1162
  { id: { eq: executionId }, status: { eq: "scheduled" } },
945
1163
  { status: "pending" },
946
1164
  );
947
- await this.dispatchToQueueSafe(jobName, executionId);
1165
+ await this.dispatchSafe(jobName, executionId);
948
1166
  } catch {
949
1167
  // Row already transitioned (sweep ran, another worker claimed, etc.)
950
1168
  }
@@ -971,7 +1189,7 @@ export class JobProvider {
971
1189
  try {
972
1190
  const rows = await this.executions.findMany({
973
1191
  where: { jobName: { eq: jobName }, status: { eq: status } },
974
- orderBy: { column: "startedAt", direction: "desc" },
1192
+ orderBy: { column: "createdAt", direction: "desc" },
975
1193
  limit: keep + 50,
976
1194
  });
977
1195
  if (rows.length <= keep) return;
@@ -992,19 +1210,25 @@ export class JobProvider {
992
1210
  protected readonly onStart = $hook({
993
1211
  on: "start",
994
1212
  handler: async () => {
995
- // Validate that queue-mode jobs have a dispatcher registered.
996
- const needsQueue = [...this.jobs.values()].some(
997
- (j) => j.type === "queue",
998
- );
999
- if (needsQueue && !this.queueDispatch) {
1000
- throw new AlephaError(
1001
- `Queue-mode jobs are registered but no queue dispatcher is available. Add '.with(AlephaApiJobsQueue)' to your app.`,
1002
- );
1213
+ // Summarize effective modes once at start so operators can see at a
1214
+ // glance what each job will do. No validation is needed here: queue
1215
+ // jobs gracefully fall back to direct mode when AlephaApiJobsQueue
1216
+ // isn't loaded.
1217
+ const modes: Record<JobEffectiveMode, number> = {
1218
+ cron: 0,
1219
+ queue: 0,
1220
+ direct: 0,
1221
+ };
1222
+ const perJob: Record<string, JobEffectiveMode> = {};
1223
+ for (const [name] of this.jobs) {
1224
+ const m = this.effectiveMode(name);
1225
+ modes[m]++;
1226
+ perJob[name] = m;
1003
1227
  }
1004
-
1005
1228
  this.log.info(`Job system OK`, {
1006
- dispatch: this.queueDispatch ? "queue" : "inline-only",
1229
+ modes,
1007
1230
  jobs: this.jobs.size,
1231
+ perJob,
1008
1232
  });
1009
1233
 
1010
1234
  // Capture logs per execution context.
@@ -1022,12 +1246,26 @@ export class JobProvider {
1022
1246
 
1023
1247
  this.cronProvider.createCronJob(
1024
1248
  "api:jobs:sweep",
1025
- SWEEP_CRON,
1249
+ this.config.sweepCron,
1026
1250
  async () => {
1027
1251
  await this.sweep();
1028
1252
  },
1029
1253
  true,
1030
1254
  );
1255
+
1256
+ this.cronProvider.createCronJob(
1257
+ "api:jobs:trim",
1258
+ this.config.trimCron,
1259
+ async () => {
1260
+ if (this.stopping) return;
1261
+ try {
1262
+ await this.trimRingBuffers();
1263
+ } catch (e) {
1264
+ this.log.error("Trim failed", { error: e });
1265
+ }
1266
+ },
1267
+ true,
1268
+ );
1031
1269
  },
1032
1270
  });
1033
1271