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
@@ -1,11 +1,11 @@
1
1
  import { $atom, $hook, $inject, $module, $state, Alepha, AlephaError, KIND, PipelinePrimitive, createPrimitive, t } from "alepha";
2
- import { AlephaLock } from "alepha/lock";
2
+ import { AlephaLock, LockProvider } from "alepha/lock";
3
3
  import { $queue, AlephaQueue } from "alepha/queue";
4
4
  import { AlephaScheduler, CronProvider } from "alepha/scheduler";
5
5
  import { $secure } from "alepha/security";
6
6
  import { $action, NotFoundError, okSchema } from "alepha/server";
7
7
  import { $logger, logEntrySchema } from "alepha/logger";
8
- import { $entity, $repository, db, sql } from "alepha/orm";
8
+ import { $entity, $repository, DbEntityNotFoundError, db, sql } from "alepha/orm";
9
9
  import { DateTimeProvider } from "alepha/datetime";
10
10
  //#region ../../src/api/jobs/schemas/jobExecutionQuerySchema.ts
11
11
  const jobExecutionQuerySchema = t.object({
@@ -33,11 +33,11 @@ const jobExecutionQuerySchema = t.object({
33
33
  * the last N rows per job (configurable via `jobConfig.keepLastSuccess`).
34
34
  *
35
35
  * Status transitions:
36
- * - queue push → pending
36
+ * - queue push → pending (or `scheduled` if `delay`/`scheduledAt` was given)
37
37
  * - worker claim → running
38
- * - success → ok
38
+ * - success → ok (or row deleted, depending on `record` and `keepLastSuccess`)
39
39
  * - terminal failure → error
40
- * - retry → scheduled (with scheduledAt = now + backoff)
40
+ * - retryable failure → scheduled (with scheduledAt = now; sweep picks it up)
41
41
  * - delay → scheduled (with scheduledAt = now + delay)
42
42
  * - sweep picks due ones → pending
43
43
  * - cancel → cancelled
@@ -81,6 +81,11 @@ const jobExecutionEntity = $entity({
81
81
  "status",
82
82
  "scheduledAt"
83
83
  ] },
84
+ { columns: [
85
+ "jobName",
86
+ "status",
87
+ "createdAt"
88
+ ] },
84
89
  { columns: ["jobName", "startedAt"] },
85
90
  {
86
91
  columns: ["jobName", "key"],
@@ -90,10 +95,28 @@ const jobExecutionEntity = $entity({
90
95
  });
91
96
  //#endregion
92
97
  //#region ../../src/api/jobs/schemas/jobExecutionResourceSchema.ts
93
- const jobExecutionResourceSchema = t.extend(jobExecutionEntity.schema, { can: t.object({
94
- retry: t.boolean(),
95
- cancel: t.boolean()
96
- }) }, {
98
+ /**
99
+ * Public-facing schema for a job execution row.
100
+ *
101
+ * Diverges from the raw entity in two places, both for API ergonomics:
102
+ *
103
+ * - `priority` is exposed as the **string enum** (`critical`/`high`/...)
104
+ * instead of the numeric value used internally for SQL ordering. The
105
+ * `JobService` is responsible for the int → string transform.
106
+ * - `can` derives the available admin actions from the row's status.
107
+ */
108
+ const jobExecutionResourceSchema = t.extend(jobExecutionEntity.schema, {
109
+ priority: t.enum([
110
+ "critical",
111
+ "high",
112
+ "normal",
113
+ "low"
114
+ ]),
115
+ can: t.object({
116
+ retry: t.boolean(),
117
+ cancel: t.boolean()
118
+ })
119
+ }, {
97
120
  title: "JobExecutionResource",
98
121
  description: "A job execution row with derived actions."
99
122
  });
@@ -102,7 +125,11 @@ const jobExecutionResourceSchema = t.extend(jobExecutionEntity.schema, { can: t.
102
125
  const jobRegistrationSchema = t.object({
103
126
  name: t.text(),
104
127
  description: t.optional(t.text()),
105
- type: t.enum(["cron", "queue"]),
128
+ type: t.enum([
129
+ "cron",
130
+ "queue",
131
+ "direct"
132
+ ], { description: "Effective runtime mode. 'cron' = scheduled. 'queue' = push-driven, dispatched via AlephaApiJobsQueue. 'direct' = push-driven, processed in-process (no queue infrastructure loaded), with the sweep as the safety net." }),
106
133
  priority: t.enum([
107
134
  "critical",
108
135
  "high",
@@ -111,10 +138,7 @@ const jobRegistrationSchema = t.object({
111
138
  ]),
112
139
  cron: t.optional(t.text()),
113
140
  timeout: t.optional(t.text()),
114
- retry: t.optional(t.object({
115
- retries: t.integer(),
116
- hasBackoff: t.boolean()
117
- })),
141
+ retry: t.optional(t.object({ retries: t.integer() })),
118
142
  recent: t.object({
119
143
  ok: t.integer(),
120
144
  error: t.integer(),
@@ -130,7 +154,8 @@ const jobConfig = $atom({
130
154
  name: "alepha.jobs",
131
155
  description: "Configuration for the $job primitive.",
132
156
  schema: t.object({
133
- sweepInterval: t.integer({ description: "Sweep cron interval in milliseconds." }),
157
+ sweepCron: t.text({ description: "Cron expression for the sweep tick. Must be minute-granular at minimum (cron resolution). On Cloudflare Workers this expression is emitted into wrangler.jsonc by the build." }),
158
+ trimCron: t.text({ description: "Cron expression for the ring-buffer trim tick (per-job keepLastSuccess/keepLastError enforcement). Decoupled from `sweepCron` because trim is bounded by job execution rate, not retry latency — running it every sweep is wasted work for most apps." }),
134
159
  staleThreshold: t.integer({ description: "Pending age (ms) before the sweep re-dispatches it." }),
135
160
  runTimeout: t.integer({ description: "Running age (ms) before assumed crash (fallback when no per-job timeout)." }),
136
161
  keepLastSuccess: t.integer({ description: "Max successful rows to keep per job. Set 0 to disable and delete on success." }),
@@ -139,7 +164,8 @@ const jobConfig = $atom({
139
164
  drainTimeout: t.integer({ description: "Max time (ms) to wait for in-flight jobs during shutdown." })
140
165
  }),
141
166
  default: {
142
- sweepInterval: 3e5,
167
+ sweepCron: "*/5 * * * *",
168
+ trimCron: "0 * * * *",
143
169
  staleThreshold: 3e5,
144
170
  runTimeout: 18e5,
145
171
  keepLastSuccess: 10,
@@ -149,6 +175,135 @@ const jobConfig = $atom({
149
175
  }
150
176
  });
151
177
  //#endregion
178
+ //#region ../../src/api/jobs/providers/JobDispatcher.ts
179
+ /**
180
+ * Abstract dispatcher for queued/direct job executions.
181
+ *
182
+ * The default implementation, {@link DirectJobDispatcher}, runs the handler
183
+ * in-process after the caller's `push()` returns — fast and dependency-free.
184
+ *
185
+ * `AlephaApiJobsQueue` substitutes this with `JobQueueProvider`, which
186
+ * publishes the executionId to `AlephaQueue` so a worker pool can consume
187
+ * the work asynchronously.
188
+ *
189
+ * Substitute via DI:
190
+ * ```ts
191
+ * Alepha.create()
192
+ * .with({ provide: JobDispatcher, use: MyCustomDispatcher })
193
+ * .with(AlephaApiJobs);
194
+ * ```
195
+ *
196
+ * The `kind` getter is read by the `JobProvider.effectiveMode` accessor
197
+ * and by the admin UI so users can see which dispatcher is currently active.
198
+ */
199
+ var JobDispatcher = class {
200
+ /**
201
+ * Optional batch dispatch. The default implementation loops, but
202
+ * dispatchers backed by a real queue should override this to use the
203
+ * provider's batch send (e.g. Cloudflare Queues `sendBatch`).
204
+ */
205
+ async dispatchMany(items) {
206
+ for (const item of items) await this.dispatch(item.jobName, item.executionId);
207
+ }
208
+ };
209
+ //#endregion
210
+ //#region ../../src/api/jobs/providers/DirectJobDispatcher.ts
211
+ /**
212
+ * Default `JobDispatcher` for environments without `AlephaApiJobsQueue`.
213
+ *
214
+ * Runs `JobProvider.processExecution` in the background — the caller's
215
+ * `push()` returns immediately while the handler continues to completion
216
+ * in the same process. The DB outbox row is the durability guarantee:
217
+ * if the process dies before the handler finishes, the next sweep tick
218
+ * picks the row up and re-dispatches.
219
+ *
220
+ * **Cloudflare Workers** — when an `executionCtx.waitUntil` is available
221
+ * in the alepha store at `cloudflare.waitUntil`, the dispatch wraps the
222
+ * background promise with `waitUntil` so the runtime keeps the isolate
223
+ * alive past the HTTP response. Without this, the handler would be
224
+ * terminated when the response is returned and only the next sweep
225
+ * (every 5 min by default) would re-dispatch.
226
+ *
227
+ * **Vercel / single-Node** — on long-running runtimes the event loop
228
+ * keeps the promise alive naturally; no special wiring is required.
229
+ */
230
+ var DirectJobDispatcher = class extends JobDispatcher {
231
+ kind = "direct";
232
+ alepha = $inject(Alepha);
233
+ log = $logger();
234
+ jobProviderRef;
235
+ getJobProvider() {
236
+ if (!this.jobProviderRef) this.jobProviderRef = this.alepha.inject(JobProvider);
237
+ return this.jobProviderRef;
238
+ }
239
+ async dispatch(jobName, executionId) {
240
+ const promise = this.getJobProvider().processExecution(jobName, executionId).catch((err) => {
241
+ this.log.warn(`Direct execution failed for '${jobName}' (sweep will retry)`, err);
242
+ });
243
+ const waitUntil = this.alepha.store.get("cloudflare.waitUntil");
244
+ if (typeof waitUntil === "function") try {
245
+ waitUntil(promise);
246
+ } catch (e) {
247
+ this.log.debug("waitUntil rejected — falling back to fire-and-track", e);
248
+ }
249
+ }
250
+ };
251
+ //#endregion
252
+ //#region ../../src/api/jobs/providers/JobQueueProvider.ts
253
+ /**
254
+ * Queue-backed `JobDispatcher` registered by `AlephaApiJobsQueue`.
255
+ *
256
+ * Extends {@link JobDispatcher} and substitutes the default
257
+ * `DirectJobDispatcher` so that `$job.push()` is delivered through
258
+ * `AlephaQueue` (e.g. Cloudflare Queues, Redis, in-memory) instead of
259
+ * being processed in-process.
260
+ *
261
+ * The class is also kept as a `JobQueueProvider` export name for backwards
262
+ * compatibility — it has always been the queue path's entry point.
263
+ */
264
+ var JobQueueProvider = class extends JobDispatcher {
265
+ kind = "queue";
266
+ alepha = $inject(Alepha);
267
+ jobProviderRef;
268
+ getJobProvider() {
269
+ if (!this.jobProviderRef) this.jobProviderRef = this.alepha.inject(JobProvider);
270
+ return this.jobProviderRef;
271
+ }
272
+ queue = $queue({
273
+ name: "api:jobs:dispatch",
274
+ schema: t.object({
275
+ jobName: t.text(),
276
+ executionId: t.text()
277
+ }),
278
+ handler: async (msg) => {
279
+ await this.getJobProvider().processExecution(msg.payload.jobName, msg.payload.executionId);
280
+ }
281
+ });
282
+ async dispatch(jobName, executionId) {
283
+ await this.queue.push({
284
+ jobName,
285
+ executionId
286
+ });
287
+ }
288
+ /**
289
+ * Fan-out to a single variadic `queue.push(...payloads)` call so the
290
+ * underlying queue provider can batch the network round-trips when it
291
+ * supports it (Cloudflare Queues, Redis pipelines).
292
+ */
293
+ async dispatchMany(items) {
294
+ if (items.length === 0) return;
295
+ await this.queue.push(...items);
296
+ }
297
+ /**
298
+ * Backwards-compatible alias for {@link dispatch}. Older code paths called
299
+ * `JobQueueProvider.push(jobName, executionId)` directly; new code should
300
+ * go through the `JobDispatcher.dispatch` API.
301
+ */
302
+ async push(jobName, executionId) {
303
+ return this.dispatch(jobName, executionId);
304
+ }
305
+ };
306
+ //#endregion
152
307
  //#region ../../src/api/jobs/providers/JobProvider.ts
153
308
  const PRIORITY_MAP = {
154
309
  critical: 0,
@@ -162,29 +317,54 @@ const PRIORITY_REVERSE = {
162
317
  2: "normal",
163
318
  3: "low"
164
319
  };
165
- const SWEEP_CRON = "*/5 * * * *";
166
320
  /**
167
- * Coordinates cron (scheduler) and queue (push) jobs with a durable outbox
168
- * table and a single reconciliation sweep.
321
+ * Coordinates cron and push jobs with a durable outbox table and a single
322
+ * reconciliation sweep. The actual delivery channel (queue / direct) is
323
+ * abstracted behind {@link JobDispatcher}, substituted by DI:
324
+ *
325
+ * - **DirectJobDispatcher** (default, registered by `AlephaApiJobs`) —
326
+ * runs the handler in-process right after `push()` returns.
327
+ * - **QueueJobDispatcher** (registered by `AlephaApiJobsQueue`) — sends
328
+ * the executionId through `AlephaQueue` so a pool of workers can pick
329
+ * it up.
169
330
  *
170
- * Queue-mode flow:
171
- * push() → INSERT row (pending) + queue.send({ executionId })
172
- * worker → SELECT row → UPDATE running → handler → DELETE (ok) / UPDATE (error)
331
+ * Push flow:
332
+ * push() → INSERT row (pending) dispatcher.dispatch(jobName, id)
333
+ * worker → claim → UPDATE running → handler → DELETE/UPDATE on success
334
+ * → UPDATE error / scheduled (retry) on failure
173
335
  *
174
- * Cron-mode flow:
175
- * scheduler tick → handler runs inline INSERT row only on error
336
+ * Cron flow:
337
+ * scheduler tick → acquire lockexecuteInline (no retry)
338
+ * → enqueue + dispatch (retry declared)
176
339
  *
177
- * Sweep responsibilities (every `sweepInterval`):
340
+ * Sweep responsibilities (every `sweepCron`):
178
341
  * - re-enqueue pending rows older than `staleThreshold`
179
- * - fail running rows older than `max(timeout*2, runTimeout)`
180
- * - move `scheduled` rows with `scheduledAt <= now` to pending + enqueue
181
- * - trim per-job history beyond `keepLastSuccess` / `keepLastError`
342
+ * - mark crashed running rows as failed and apply retry policy
343
+ * - move `scheduled` rows with `scheduledAt <= now` to pending + dispatch
344
+ *
345
+ * Trim runs on its own cron (`trimCron`, default hourly):
346
+ * - per-job history trimmed beyond `keepLastSuccess` / `keepLastError`
347
+ * - decoupled from sweep because trim cost scales with job count, not
348
+ * retry latency — running it every sweep is wasted work for most apps.
182
349
  */
183
350
  var JobProvider = class {
184
351
  alepha = $inject(Alepha);
185
352
  dt = $inject(DateTimeProvider);
186
353
  cronProvider = $inject(CronProvider);
354
+ lockProvider = $inject(LockProvider);
187
355
  config = $state(jobConfig);
356
+ /**
357
+ * Resolved at first use (after the container is fully wired) — picks
358
+ * the queue dispatcher when `AlephaApiJobsQueue` was loaded, otherwise
359
+ * the direct dispatcher. Lazy because both dispatchers inject
360
+ * `JobProvider` themselves; resolving them at field-init time would
361
+ * create a circular construction.
362
+ */
363
+ dispatcherRef;
364
+ get dispatcher() {
365
+ if (!this.dispatcherRef) this.dispatcherRef = this.alepha.has(JobQueueProvider) ? this.alepha.inject(JobQueueProvider) : this.alepha.inject(DirectJobDispatcher);
366
+ return this.dispatcherRef;
367
+ }
188
368
  log = $logger();
189
369
  executions = $repository(jobExecutionEntity);
190
370
  jobs = /* @__PURE__ */ new Map();
@@ -192,22 +372,17 @@ var JobProvider = class {
192
372
  abortControllers = /* @__PURE__ */ new Map();
193
373
  perExecutionLogs = /* @__PURE__ */ new Map();
194
374
  stopping = false;
195
- /**
196
- * Set by `JobQueueProvider` when `AlephaApiJobsQueue` is loaded.
197
- * When null, queue-mode jobs cannot be pushed.
198
- */
199
- queueDispatch = null;
200
375
  registerJob(name, options) {
201
376
  if (this.jobs.has(name)) throw new AlephaError(`Job already registered: ${name}`);
202
377
  if (options.cron && options.schema) throw new AlephaError(`Job '${name}' declares both 'cron' and 'schema'. A job must be either cron-only (recurring) or queue-only (push-based). Split into two jobs.`);
203
378
  if (!options.cron && !options.schema) throw new AlephaError(`Job '${name}' must declare either 'cron' (for recurring tasks) or 'schema' (for queue-mode tasks).`);
204
- const type = options.cron ? "cron" : "queue";
379
+ const kind = options.cron ? "cron" : "queue";
205
380
  this.jobs.set(name, {
206
381
  name,
207
382
  options,
208
- type
383
+ kind
209
384
  });
210
- this.log.debug(`Registered ${type} job '${name}'`, {
385
+ this.log.debug(`Registered ${kind} job '${name}'`, {
211
386
  cron: options.cron,
212
387
  priority: options.priority ?? "normal",
213
388
  retries: options.retry?.retries ?? 0
@@ -223,24 +398,155 @@ var JobProvider = class {
223
398
  getRegisteredJobs() {
224
399
  return this.jobs;
225
400
  }
401
+ /**
402
+ * Resolves what *actually* runs at dispatch time. Cron jobs are always
403
+ * "cron"; non-cron jobs delegate to the active `JobDispatcher` (queue
404
+ * vs. direct), which is determined by which modules the app loaded.
405
+ */
406
+ effectiveMode(name) {
407
+ if (this.getRegistration(name).kind === "cron") return "cron";
408
+ return this.dispatcher.kind;
409
+ }
226
410
  async runCron(name) {
227
411
  const registration = this.getRegistration(name);
228
- if (registration.type !== "cron") throw new AlephaError(`Job '${name}' is not cron-mode`);
229
- if (this.stopping) return;
230
- const executionId = crypto.randomUUID();
231
- const promise = this.executeInline(registration, executionId, {
232
- payload: void 0,
233
- attempt: 1,
412
+ if (registration.kind !== "cron") throw new AlephaError(`Job '${name}' is not cron-mode`);
413
+ await this.runCronLocked(registration, {
234
414
  triggeredBy: "system",
235
415
  triggeredByName: "system (cron)"
236
416
  });
237
- this.inFlight.add(promise);
417
+ }
418
+ /**
419
+ * Cron-mode runner that respects the per-job distributed lock.
420
+ * Used by both the scheduled tick and manual `trigger()` calls so that an
421
+ * admin-triggered run on one instance can't race a scheduled run on another.
422
+ *
423
+ * **Two paths depending on `retry`:**
424
+ *
425
+ * - **No `retry`** — runs the handler inline. No DB row on success;
426
+ * error row only on failure. The "next tick" is the implicit retry.
427
+ * - **`retry` declared** — enqueues a synthetic execution row and hands
428
+ * it to the dispatcher. The handler then runs through the same path
429
+ * as a queue/direct push (claim, retry-on-fail, sweep recovery). Use
430
+ * this when a single failed tick must not block work for the whole
431
+ * `cron` interval (e.g. once-daily jobs).
432
+ */
433
+ async runCronLocked(registration, ctx) {
434
+ if (this.stopping) return;
435
+ const useLock = registration.options.lock !== false;
436
+ if (useLock) {
437
+ if (!await this.acquireCronLock(registration)) {
438
+ this.log.debug(`Cron '${registration.name}' skipped — another instance holds the lock`);
439
+ return;
440
+ }
441
+ }
238
442
  try {
239
- await promise;
443
+ if (registration.options.retry) {
444
+ await this.enqueueCronExecution(registration, ctx);
445
+ return;
446
+ }
447
+ const executionId = crypto.randomUUID();
448
+ const promise = this.executeInline(registration, executionId, {
449
+ payload: void 0,
450
+ attempt: 1,
451
+ triggeredBy: ctx.triggeredBy,
452
+ triggeredByName: ctx.triggeredByName
453
+ });
454
+ this.inFlight.add(promise);
455
+ try {
456
+ await promise;
457
+ } finally {
458
+ this.inFlight.delete(promise);
459
+ }
240
460
  } finally {
241
- this.inFlight.delete(promise);
461
+ if (useLock) await this.releaseCronLock(registration);
462
+ }
463
+ }
464
+ /**
465
+ * Materialize a cron tick into the outbox so it goes through the normal
466
+ * retry/sweep path. Used when the user opts into `retry` on a cron job —
467
+ * a transient failure no longer means "wait for the next cron tick", it
468
+ * means "the sweep will retry within `sweepCron`".
469
+ */
470
+ async enqueueCronExecution(registration, ctx) {
471
+ const opts = registration.options;
472
+ const maxAttempts = (opts.retry?.retries ?? 0) + 1;
473
+ const execution = await this.executions.create({
474
+ jobName: registration.name,
475
+ payload: void 0,
476
+ status: "pending",
477
+ priority: PRIORITY_MAP[opts.priority ?? "normal"],
478
+ maxAttempts,
479
+ triggeredBy: ctx.triggeredBy,
480
+ triggeredByName: ctx.triggeredByName
481
+ });
482
+ await this.dispatch(registration.name, execution.id);
483
+ }
484
+ /**
485
+ * Acquire a per-job NX lock keyed by `cron-job:<name>` so that a single
486
+ * tick across all replicas runs exactly one execution. Auto-expires after
487
+ * `2 * timeout` (or 5 minutes if no per-job timeout) so a crashed worker
488
+ * cannot permanently block the cron from firing.
489
+ *
490
+ * **Caveat — same-process double-fire is not prevented.** The lock value
491
+ * is a per-process holder id, so two concurrent ticks on the same process
492
+ * (e.g. a scheduled tick overlapping an admin `trigger()` call) will both
493
+ * see "we own it". This is acceptable for the multi-replica use case the
494
+ * lock targets; a process that overlaps its own cron handler should set a
495
+ * smaller `timeout` or use idempotent handler logic. A future fix can add
496
+ * a per-process Set guard before reaching the LockProvider.
497
+ */
498
+ async acquireCronLock(registration) {
499
+ const lockKey = this.cronLockKey(registration.name);
500
+ const ttlMs = registration.options.timeout ? this.dt.duration(registration.options.timeout).as("milliseconds") * 2 : 300 * 1e3;
501
+ const value = `${this.lockHolderId},${this.dt.nowISOString()}`;
502
+ try {
503
+ const [holderId] = (await this.lockProvider.set(lockKey, value, true, ttlMs)).split(",");
504
+ return holderId === this.lockHolderId;
505
+ } catch (e) {
506
+ this.log.warn(`Cron lock acquire failed for '${registration.name}'`, e);
507
+ return true;
508
+ }
509
+ }
510
+ /**
511
+ * Update only when the row is still in one of the expected statuses.
512
+ * Logs and returns silently when the guard rejects — this happens when a
513
+ * concurrent operation (most often `cancel()`) has already moved the row
514
+ * into a terminal state. We must not overwrite that.
515
+ */
516
+ async guardedUpdate(executionId, expectedStatuses, patch, label) {
517
+ try {
518
+ await this.executions.updateOne({
519
+ id: { eq: executionId },
520
+ status: { inArray: expectedStatuses }
521
+ }, patch);
522
+ } catch (e) {
523
+ if (e instanceof DbEntityNotFoundError) {
524
+ this.log.debug(`${label}: row ${executionId} not in expected status — skipping write`);
525
+ return;
526
+ }
527
+ throw e;
528
+ }
529
+ }
530
+ async releaseCronLock(registration) {
531
+ try {
532
+ await this.lockProvider.del(this.cronLockKey(registration.name));
533
+ } catch (e) {
534
+ this.log.debug(`Cron lock release failed for '${registration.name}' (will expire by TTL)`, e);
242
535
  }
243
536
  }
537
+ cronLockKey(jobName) {
538
+ return `alepha.api.jobs.cron:${jobName}`;
539
+ }
540
+ /**
541
+ * Stable per-process id used as the lock value — survives multiple ticks.
542
+ * Lazy so that Cloudflare Workers (which forbid random in global scope)
543
+ * stay happy.
544
+ */
545
+ lockHolderIdValue;
546
+ get lockHolderId() {
547
+ if (!this.lockHolderIdValue) this.lockHolderIdValue = crypto.randomUUID();
548
+ return this.lockHolderIdValue;
549
+ }
244
550
  /**
245
551
  * Execute a cron handler inline. Records a row only on error (or always,
246
552
  * when `record: 'all'`). No DB writes on the happy path by default.
@@ -339,7 +645,7 @@ var JobProvider = class {
339
645
  }
340
646
  async push(name, payload, options) {
341
647
  const registration = this.getRegistration(name);
342
- if (registration.type !== "queue") throw new AlephaError(`Job '${name}' is not queue-mode (no schema declared). Use trigger() instead.`);
648
+ if (registration.kind !== "queue") throw new AlephaError(`Job '${name}' is not queue-mode (no schema declared). Use trigger() instead.`);
343
649
  const opts = registration.options;
344
650
  const validated = this.alepha.codec.validate(opts.schema, payload);
345
651
  const priority = PRIORITY_MAP[options?.priority ?? opts.priority ?? "normal"];
@@ -368,7 +674,7 @@ var JobProvider = class {
368
674
  triggeredBy: options.triggeredBy,
369
675
  triggeredByName: options.triggeredByName
370
676
  });
371
- if (status === "pending") await this.dispatchToQueue(name, execution.id);
677
+ if (status === "pending") await this.dispatch(name, execution.id);
372
678
  else if (status === "scheduled" && scheduledAt) this.scheduleOptimisticDispatch(name, execution.id, scheduledAt);
373
679
  return execution.id;
374
680
  }
@@ -382,7 +688,7 @@ var JobProvider = class {
382
688
  triggeredBy: options?.triggeredBy,
383
689
  triggeredByName: options?.triggeredByName
384
690
  });
385
- if (status === "pending") await this.dispatchToQueue(name, execution.id);
691
+ if (status === "pending") await this.dispatch(name, execution.id);
386
692
  else if (status === "scheduled" && scheduledAt) this.scheduleOptimisticDispatch(name, execution.id, scheduledAt);
387
693
  return execution.id;
388
694
  }
@@ -401,7 +707,7 @@ var JobProvider = class {
401
707
  async pushMany(name, items) {
402
708
  if (items.length === 0) return [];
403
709
  const registration = this.getRegistration(name);
404
- if (registration.type !== "queue") throw new AlephaError(`Job '${name}' is not queue-mode (no schema declared).`);
710
+ if (registration.kind !== "queue") throw new AlephaError(`Job '${name}' is not queue-mode (no schema declared).`);
405
711
  const opts = registration.options;
406
712
  const maxAttempts = (opts.retry?.retries ?? 0) + 1;
407
713
  const keyed = [];
@@ -440,11 +746,16 @@ var JobProvider = class {
440
746
  }
441
747
  if (bulk.length > 0) {
442
748
  const created = await this.executions.createMany(bulk);
749
+ const toDispatch = [];
443
750
  for (const exec of created) {
444
751
  ids.push(exec.id);
445
- if (exec.status === "pending" && !this.stopping) await this.dispatchToQueue(name, exec.id);
752
+ if (exec.status === "pending" && !this.stopping) toDispatch.push({
753
+ jobName: name,
754
+ executionId: exec.id
755
+ });
446
756
  else if (exec.status === "scheduled" && exec.scheduledAt && !this.stopping) this.scheduleOptimisticDispatch(name, exec.id, exec.scheduledAt);
447
757
  }
758
+ if (toDispatch.length > 0) await this.dispatchMany(toDispatch);
448
759
  }
449
760
  this.log.debug(`pushMany '${name}': ${ids.length} jobs created`, {
450
761
  bulk: bulk.length,
@@ -452,18 +763,27 @@ var JobProvider = class {
452
763
  });
453
764
  return ids;
454
765
  }
455
- async dispatchToQueue(jobName, executionId) {
766
+ /**
767
+ * Hand a single execution to the active `JobDispatcher`. Whether that
768
+ * results in a queue send or in-process execution depends on which
769
+ * dispatcher is wired (see {@link JobDispatcher}).
770
+ */
771
+ async dispatch(jobName, executionId) {
456
772
  if (this.stopping) return;
457
- if (!this.queueDispatch) throw new AlephaError(`Queue-mode job '${jobName}' cannot be pushed: AlephaApiJobsQueue is not loaded. Add '.with(AlephaApiJobsQueue)' to your app.`);
458
- await this.queueDispatch(jobName, executionId);
773
+ await this.dispatcher.dispatch(jobName, executionId);
774
+ }
775
+ /**
776
+ * Batched variant. Used by `pushMany` so a backing queue can do a single
777
+ * batch network call (e.g. Cloudflare Queues `sendBatch`).
778
+ */
779
+ async dispatchMany(items) {
780
+ if (this.stopping || items.length === 0) return;
781
+ await this.dispatcher.dispatchMany(items);
459
782
  }
460
783
  async trigger(name, context) {
461
784
  const registration = this.getRegistration(name);
462
- if (registration.type === "cron") {
463
- const executionId = crypto.randomUUID();
464
- await this.executeInline(registration, executionId, {
465
- payload: void 0,
466
- attempt: 1,
785
+ if (registration.kind === "cron") {
786
+ await this.runCronLocked(registration, {
467
787
  triggeredBy: context?.triggeredBy,
468
788
  triggeredByName: context?.triggeredByName
469
789
  });
@@ -499,8 +819,8 @@ var JobProvider = class {
499
819
  this.log.warn(`Unknown job '${jobName}' — skipping execution`, { executionId });
500
820
  return;
501
821
  }
502
- if (registration.type !== "queue") {
503
- this.log.warn(`Job '${jobName}' is not queue-mode — skipping`, { executionId });
822
+ if (registration.kind !== "queue" && !registration.options.retry) {
823
+ this.log.warn(`Job '${jobName}' has no outbox path (no schema and no retry) — skipping`, { executionId });
504
824
  return;
505
825
  }
506
826
  const promise = this.processQueueExecution(registration, executionId);
@@ -515,12 +835,11 @@ var JobProvider = class {
515
835
  const jobName = registration.name;
516
836
  const opts = registration.options;
517
837
  const record = opts.record ?? "error";
518
- if (!await this.claim(executionId)) {
838
+ const execution = await this.claim(executionId);
839
+ if (!execution) {
519
840
  this.log.debug(`Execution ${executionId} already claimed, skipping`);
520
841
  return;
521
842
  }
522
- const execution = await this.executions.findById(executionId);
523
- if (!execution) return;
524
843
  const contextId = this.alepha.context.createContextId();
525
844
  this.perExecutionLogs.set(contextId, []);
526
845
  const abortController = new AbortController();
@@ -581,56 +900,58 @@ var JobProvider = class {
581
900
  this.perExecutionLogs.delete(contextId);
582
901
  }
583
902
  }
903
+ /**
904
+ * Transition pending → running and return the post-update row.
905
+ * Two round-trips: read current attempt, then guarded UPDATE … RETURNING.
906
+ * Returns null when the row is gone or already claimed by another worker.
907
+ * The returned row replaces a separate post-claim findById, so the dispatch
908
+ * path is 2 queries instead of 3.
909
+ */
584
910
  async claim(executionId) {
585
- const execution = await this.executions.findById(executionId);
586
- if (!execution) return false;
911
+ const current = await this.executions.findById(executionId);
912
+ if (!current) return null;
587
913
  try {
588
- await this.executions.updateOne({
914
+ return await this.executions.updateOne({
589
915
  id: { eq: executionId },
590
916
  status: { eq: "pending" }
591
917
  }, {
592
918
  status: "running",
593
- attempt: execution.attempt + 1,
919
+ attempt: current.attempt + 1,
594
920
  startedAt: this.dt.nowISOString()
595
921
  });
596
- return true;
597
- } catch {
598
- return false;
922
+ } catch (e) {
923
+ if (e instanceof DbEntityNotFoundError) return null;
924
+ throw e;
599
925
  }
600
926
  }
601
927
  async handleFailure(executionId, registration, currentAttempt, error, contextId) {
602
928
  const jobName = registration.name;
603
929
  const retry = registration.options.retry;
604
930
  const maxAttempts = (retry?.retries ?? 0) + 1;
605
- if (retry && currentAttempt + 1 < maxAttempts && (retry.when ? retry.when(error) : true)) {
606
- const nextScheduledAt = this.computeBackoff(retry, currentAttempt + 1);
607
- this.log.info(`Job '${jobName}' failed, scheduling retry ${currentAttempt + 1}/${maxAttempts}`, {
931
+ if (retry && currentAttempt < maxAttempts && (retry.when ? retry.when(error) : true)) {
932
+ const nextScheduledAt = this.dt.nowISOString();
933
+ this.log.info(`Job '${jobName}' failed, scheduling retry ${currentAttempt + 1}/${maxAttempts} (sweep will pick up)`, {
608
934
  executionId,
609
- error: error.message,
610
- nextScheduledAt
935
+ error: error.message
611
936
  });
612
- await this.executions.updateById(executionId, {
937
+ await this.guardedUpdate(executionId, ["running"], {
613
938
  status: "scheduled",
614
939
  error: error.message,
615
940
  scheduledAt: nextScheduledAt,
616
941
  logs: this.snapshotLogs(contextId)
617
- });
618
- const delayMs = Math.max(0, new Date(nextScheduledAt).getTime() - this.dt.nowMillis());
619
- this.dt.createTimeout(() => {
620
- this.dispatchScheduled(jobName, executionId);
621
- }, delayMs);
942
+ }, "retry-after-failure");
622
943
  } else {
623
944
  this.log.info(`Job '${jobName}' dead after ${currentAttempt} attempt(s)`, {
624
945
  executionId,
625
946
  error: error.message
626
947
  });
627
- await this.executions.updateById(executionId, {
948
+ await this.guardedUpdate(executionId, ["running"], {
628
949
  status: "error",
629
950
  error: error.message,
630
951
  completedAt: this.dt.nowISOString(),
631
952
  key: null,
632
953
  logs: this.snapshotLogs(contextId)
633
- });
954
+ }, "terminal-failure");
634
955
  }
635
956
  await this.alepha.events.emit("job:error", {
636
957
  name: jobName,
@@ -638,16 +959,6 @@ var JobProvider = class {
638
959
  executionId
639
960
  }, { catch: true });
640
961
  }
641
- computeBackoff(retry, attempt) {
642
- const now = this.dt.now();
643
- if (!retry.backoff) return now.add(1, "second").toISOString();
644
- if (Array.isArray(retry.backoff)) return now.add(this.dt.duration(retry.backoff)).toISOString();
645
- const backoff = retry.backoff;
646
- let delayMs = this.dt.duration(backoff.initial).as("milliseconds") * (backoff.factor ?? 2) ** (attempt - 1);
647
- if (backoff.max) delayMs = Math.min(delayMs, this.dt.duration(backoff.max).as("milliseconds"));
648
- if (backoff.jitter) delayMs = delayMs * (.75 + Math.random() * .5);
649
- return now.add(delayMs, "millisecond").toISOString();
650
- }
651
962
  snapshotLogs(contextId) {
652
963
  const entries = this.perExecutionLogs.get(contextId);
653
964
  if (!entries || entries.length === 0) return void 0;
@@ -683,7 +994,7 @@ var JobProvider = class {
683
994
  for (const exec of due) {
684
995
  if (!this.jobs.has(exec.jobName)) continue;
685
996
  await this.executions.updateById(exec.id, { status: "pending" });
686
- await this.dispatchToQueueSafe(exec.jobName, exec.id);
997
+ await this.dispatchSafe(exec.jobName, exec.id);
687
998
  }
688
999
  const staleIso = now.subtract(this.config.staleThreshold, "millisecond").toISOString();
689
1000
  const staleWhere = this.executions.createQueryWhere();
@@ -698,7 +1009,7 @@ var JobProvider = class {
698
1009
  });
699
1010
  for (const exec of stale) {
700
1011
  if (!this.jobs.has(exec.jobName)) continue;
701
- await this.dispatchToQueueSafe(exec.jobName, exec.id);
1012
+ await this.dispatchSafe(exec.jobName, exec.id);
702
1013
  }
703
1014
  const runningWhere = this.executions.createQueryWhere();
704
1015
  runningWhere.status = { eq: "running" };
@@ -716,14 +1027,13 @@ var JobProvider = class {
716
1027
  await this.handleFailure(exec.id, reg, exec.attempt, err, "");
717
1028
  }
718
1029
  }
719
- await this.trimRingBuffers();
720
1030
  } catch (e) {
721
1031
  this.log.error("Sweep failed", { error: e });
722
1032
  }
723
1033
  }
724
- async dispatchToQueueSafe(jobName, executionId) {
1034
+ async dispatchSafe(jobName, executionId) {
725
1035
  try {
726
- await this.dispatchToQueue(jobName, executionId);
1036
+ await this.dispatch(jobName, executionId);
727
1037
  } catch (e) {
728
1038
  this.log.warn(`Sweep failed to dispatch ${jobName} (${executionId})`, e);
729
1039
  }
@@ -740,7 +1050,7 @@ var JobProvider = class {
740
1050
  id: { eq: executionId },
741
1051
  status: { eq: "scheduled" }
742
1052
  }, { status: "pending" });
743
- await this.dispatchToQueueSafe(jobName, executionId);
1053
+ await this.dispatchSafe(jobName, executionId);
744
1054
  } catch {}
745
1055
  }
746
1056
  async trimRingBuffers() {
@@ -759,7 +1069,7 @@ var JobProvider = class {
759
1069
  status: { eq: status }
760
1070
  },
761
1071
  orderBy: {
762
- column: "startedAt",
1072
+ column: "createdAt",
763
1073
  direction: "desc"
764
1074
  },
765
1075
  limit: keep + 50
@@ -777,10 +1087,21 @@ var JobProvider = class {
777
1087
  onStart = $hook({
778
1088
  on: "start",
779
1089
  handler: async () => {
780
- if ([...this.jobs.values()].some((j) => j.type === "queue") && !this.queueDispatch) throw new AlephaError(`Queue-mode jobs are registered but no queue dispatcher is available. Add '.with(AlephaApiJobsQueue)' to your app.`);
1090
+ const modes = {
1091
+ cron: 0,
1092
+ queue: 0,
1093
+ direct: 0
1094
+ };
1095
+ const perJob = {};
1096
+ for (const [name] of this.jobs) {
1097
+ const m = this.effectiveMode(name);
1098
+ modes[m]++;
1099
+ perJob[name] = m;
1100
+ }
781
1101
  this.log.info(`Job system OK`, {
782
- dispatch: this.queueDispatch ? "queue" : "inline-only",
783
- jobs: this.jobs.size
1102
+ modes,
1103
+ jobs: this.jobs.size,
1104
+ perJob
784
1105
  });
785
1106
  this.alepha.events.on("log", ({ entry }) => {
786
1107
  const ctx = entry.context;
@@ -790,9 +1111,17 @@ var JobProvider = class {
790
1111
  entries.push(entry);
791
1112
  });
792
1113
  if (!this.alepha.isServerless()) await this.sweep();
793
- this.cronProvider.createCronJob("api:jobs:sweep", SWEEP_CRON, async () => {
1114
+ this.cronProvider.createCronJob("api:jobs:sweep", this.config.sweepCron, async () => {
794
1115
  await this.sweep();
795
1116
  }, true);
1117
+ this.cronProvider.createCronJob("api:jobs:trim", this.config.trimCron, async () => {
1118
+ if (this.stopping) return;
1119
+ try {
1120
+ await this.trimRingBuffers();
1121
+ } catch (e) {
1122
+ this.log.error("Trim failed", { error: e });
1123
+ }
1124
+ }, true);
796
1125
  }
797
1126
  });
798
1127
  onStop = $hook({
@@ -889,6 +1218,19 @@ var JobService = class {
889
1218
  };
890
1219
  }
891
1220
  /**
1221
+ * Convert the int-priority storage column into the public enum string.
1222
+ * The cast through `unknown` skips TypeScript's structural check between
1223
+ * the entity-level row (`priority: number`) and the resource schema
1224
+ * (`priority: enum`); the runtime values are correct.
1225
+ */
1226
+ toResource(row) {
1227
+ return {
1228
+ ...row,
1229
+ priority: PRIORITY_REVERSE[row.priority] ?? "normal",
1230
+ can: this.computeCan(row.status)
1231
+ };
1232
+ }
1233
+ /**
892
1234
  * List every registered job with recent ok/error counts and lastRun.
893
1235
  * One aggregate query covers all jobs.
894
1236
  */
@@ -936,14 +1278,11 @@ var JobService = class {
936
1278
  result.push({
937
1279
  name,
938
1280
  description: opts.description,
939
- type: reg.type,
1281
+ type: this.jobProvider.effectiveMode(name),
940
1282
  cron: opts.cron,
941
1283
  priority: opts.priority ?? "normal",
942
1284
  timeout: opts.timeout ? String(opts.timeout) : void 0,
943
- retry: opts.retry ? {
944
- retries: opts.retry.retries,
945
- hasBackoff: Boolean(opts.retry.backoff)
946
- } : void 0,
1285
+ retry: opts.retry ? { retries: opts.retry.retries } : void 0,
947
1286
  recent: counts
948
1287
  });
949
1288
  }
@@ -964,10 +1303,7 @@ var JobService = class {
964
1303
  direction: "desc"
965
1304
  },
966
1305
  limit: query.limit ?? 20
967
- })).map((row) => ({
968
- ...row,
969
- can: this.computeCan(row.status)
970
- }));
1306
+ })).map((row) => this.toResource(row));
971
1307
  }
972
1308
  /**
973
1309
  * Full execution detail (includes captured logs).
@@ -975,10 +1311,7 @@ var JobService = class {
975
1311
  async getExecution(id) {
976
1312
  const execution = await this.executions.findById(id);
977
1313
  if (!execution) throw new NotFoundError(`Execution not found: ${id}`);
978
- return {
979
- ...execution,
980
- can: this.computeCan(execution.status)
981
- };
1314
+ return this.toResource(execution);
982
1315
  }
983
1316
  /**
984
1317
  * Manual trigger (cron jobs) or push-with-payload (queue jobs).
@@ -1103,69 +1436,69 @@ var AdminJobController = class {
1103
1436
  });
1104
1437
  };
1105
1438
  //#endregion
1106
- //#region ../../src/api/jobs/providers/JobQueueProvider.ts
1107
- /**
1108
- * Plumbs outbox-style dispatch through `AlephaQueue`.
1109
- *
1110
- * Registered only when the app imports `AlephaApiJobsQueue`. Sets
1111
- * `JobProvider.queueDispatch` eagerly at instantiation so queue-mode jobs
1112
- * can dispatch regardless of start-hook ordering.
1113
- */
1114
- var JobQueueProvider = class {
1115
- jobProvider = $inject(JobProvider);
1116
- queue = $queue({
1117
- name: "api:jobs:dispatch",
1118
- schema: t.object({
1119
- jobName: t.text(),
1120
- executionId: t.text()
1121
- }),
1122
- handler: async (msg) => {
1123
- await this.jobProvider.processExecution(msg.payload.jobName, msg.payload.executionId);
1124
- }
1125
- });
1126
- constructor() {
1127
- this.wireDispatcher();
1128
- }
1129
- wireDispatcher() {
1130
- this.jobProvider.queueDispatch = (jobName, executionId) => this.push(jobName, executionId);
1131
- }
1132
- async push(jobName, executionId) {
1133
- await this.queue.push({
1134
- jobName,
1135
- executionId
1136
- });
1137
- }
1138
- };
1139
- //#endregion
1140
1439
  //#region ../../src/api/jobs/index.ts
1141
1440
  /**
1142
1441
  * Job execution framework — cron and durable queue work with a single primitive.
1143
1442
  *
1144
- * A `$job` is either **cron-only** (declares `cron`) or **queue-only** (declares `schema`).
1145
- * Cron jobs run inline on their schedule and only record errors by default.
1146
- * Queue jobs use the outbox pattern: push commits to DB first, then notifies via queue.
1443
+ * A `$job` is either **cron-only** (declares `cron`) or **payload-only** (declares `schema`).
1444
+ *
1445
+ * **Three runtime modes:**
1446
+ *
1447
+ * - **cron** — fires on a schedule. Cron-mode jobs are protected by a
1448
+ * distributed lock by default (`lock: true`), so multi-replica Docker
1449
+ * deployments only run the handler once per tick. Override with
1450
+ * `lock: false` if you genuinely want every replica to fire.
1451
+ * - **queue** — push-driven, dispatched through the queue infrastructure
1452
+ * (`AlephaQueue`, e.g. Cloudflare Queues, Redis). Real-time delivery,
1453
+ * ideal for high-volume systems. Requires `AlephaApiJobsQueue`.
1454
+ * - **direct** — push-driven, processed in-process right after the caller
1455
+ * awaits the push. The DB outbox row is the durability guarantee — if
1456
+ * the process dies, the reconciliation sweep re-dispatches. Default
1457
+ * when `AlephaApiJobsQueue` is *not* loaded. Best for cheap deployments
1458
+ * (Cloudflare Workers, single-instance Node) where standing up a queue
1459
+ * is overkill.
1460
+ *
1461
+ * **Retries** are sweep-driven across all modes (no exponential backoff).
1462
+ * Granularity is bounded by `sweepCron` (default 5 min). The first retry
1463
+ * may land anywhere from a few seconds to ~5 min later depending on when
1464
+ * the next sweep tick fires. Cron jobs that declare `retry` go through
1465
+ * the same sweep path — a transient failure no longer means waiting for
1466
+ * the next cron tick (useful for once-daily jobs).
1467
+ *
1468
+ * **Runtime support for cron triggers**
1147
1469
  *
1148
- * **This module provides cron support only.** To enable queue-mode jobs, also
1149
- * import {@link AlephaApiJobsQueue} it brings in the queue layer and infrastructure
1150
- * binding (e.g. Cloudflare Queues). Cron-only deployments (Vercel, CF-without-Queues)
1151
- * do not need `AlephaApiJobsQueue`.
1470
+ * - **Long-running Node / Docker** `CronProvider` runs an in-process
1471
+ * timer loop. Multi-replica deployments serialize ticks via the cron
1472
+ * lock (see `$job.lock`).
1473
+ * - **Cloudflare Workers** — the build emits cron expressions into
1474
+ * `wrangler.jsonc`; Cloudflare invokes the worker on schedule and the
1475
+ * `cloudflare:scheduled` hook routes the event to the matching jobs.
1476
+ * - **Vercel** — the build emits cron entries into
1477
+ * `.vercel/output/config.json` mapped to `/_alepha/cron/:name`; the
1478
+ * serverless handler emits `serverless:cron` and `CronProvider` runs
1479
+ * the matching job. Set `CRON_SECRET` to require authenticated calls.
1152
1480
  *
1153
1481
  * @module alepha.api.jobs
1154
1482
  */
1155
1483
  const AlephaApiJobs = $module({
1156
1484
  name: "alepha.api.jobs",
1485
+ primitives: [$job],
1157
1486
  imports: [AlephaScheduler, AlephaLock],
1158
1487
  services: [
1159
1488
  JobProvider,
1160
1489
  JobService,
1161
- AdminJobController
1490
+ AdminJobController,
1491
+ DirectJobDispatcher
1162
1492
  ]
1163
1493
  });
1164
1494
  /**
1165
1495
  * Queue support for `$job`. Import alongside {@link AlephaApiJobs} when your
1166
- * app declares queue-mode jobs (any `$job` with a `schema`).
1496
+ * app declares queue-mode jobs (any `$job` with a `schema`) and you want a
1497
+ * real queue (e.g. Cloudflare Queues, Redis) instead of in-process direct
1498
+ * execution.
1167
1499
  *
1168
- * Adds `JobQueueProvider` which plumbs the outbox dispatch through `AlephaQueue`.
1500
+ * Adds `JobQueueProvider` to the container. `JobProvider` detects its
1501
+ * presence at start-up and routes dispatches through it.
1169
1502
  *
1170
1503
  * @module alepha.api.jobs.queue
1171
1504
  */
@@ -1175,6 +1508,6 @@ const AlephaApiJobsQueue = $module({
1175
1508
  services: [JobQueueProvider]
1176
1509
  });
1177
1510
  //#endregion
1178
- export { $job, AdminJobController, AlephaApiJobs, AlephaApiJobsQueue, JobPrimitive, JobProvider, JobQueueProvider, JobService, PRIORITY_MAP, PRIORITY_REVERSE, jobConfig, jobExecutionEntity, jobExecutionQuerySchema, jobExecutionResourceSchema, jobRegistrationSchema, triggerJobSchema };
1511
+ export { $job, AdminJobController, AlephaApiJobs, AlephaApiJobsQueue, DirectJobDispatcher, JobDispatcher, JobPrimitive, JobProvider, JobQueueProvider, JobService, PRIORITY_MAP, PRIORITY_REVERSE, jobConfig, jobExecutionEntity, jobExecutionQuerySchema, jobExecutionResourceSchema, jobRegistrationSchema, triggerJobSchema };
1179
1512
 
1180
1513
  //# sourceMappingURL=index.js.map