alepha 0.20.1 → 0.20.2

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 (232) hide show
  1. package/dist/api/files/index.js +2 -1
  2. package/dist/api/files/index.js.map +1 -1
  3. package/dist/api/jobs/index.browser.js +64 -148
  4. package/dist/api/jobs/index.browser.js.map +1 -1
  5. package/dist/api/jobs/index.d.ts +371 -573
  6. package/dist/api/jobs/index.d.ts.map +1 -1
  7. package/dist/api/jobs/index.js +605 -1012
  8. package/dist/api/jobs/index.js.map +1 -1
  9. package/dist/api/notifications/index.d.ts +78 -17
  10. package/dist/api/notifications/index.d.ts.map +1 -1
  11. package/dist/api/notifications/index.js +90 -23
  12. package/dist/api/notifications/index.js.map +1 -1
  13. package/dist/api/payments/index.d.ts +2 -1
  14. package/dist/api/payments/index.d.ts.map +1 -1
  15. package/dist/api/payments/index.js +4 -2
  16. package/dist/api/payments/index.js.map +1 -1
  17. package/dist/api/users/index.d.ts +34 -31
  18. package/dist/api/users/index.d.ts.map +1 -1
  19. package/dist/api/users/index.js +13 -7
  20. package/dist/api/users/index.js.map +1 -1
  21. package/dist/api/verifications/index.js +2 -1
  22. package/dist/api/verifications/index.js.map +1 -1
  23. package/dist/cli/core/index.d.ts +8 -34
  24. package/dist/cli/core/index.d.ts.map +1 -1
  25. package/dist/cli/core/index.js +43 -232
  26. package/dist/cli/core/index.js.map +1 -1
  27. package/dist/cli/platform/index.d.ts +36 -11
  28. package/dist/cli/platform/index.d.ts.map +1 -1
  29. package/dist/cli/platform/index.js +93 -27
  30. package/dist/cli/platform/index.js.map +1 -1
  31. package/dist/command/index.d.ts +1 -1
  32. package/dist/core/index.browser.js +6 -0
  33. package/dist/core/index.browser.js.map +1 -1
  34. package/dist/core/index.d.ts +6 -0
  35. package/dist/core/index.d.ts.map +1 -1
  36. package/dist/core/index.js +6 -0
  37. package/dist/core/index.js.map +1 -1
  38. package/dist/core/index.native.js +6 -0
  39. package/dist/core/index.native.js.map +1 -1
  40. package/dist/core/index.workerd.js +6 -0
  41. package/dist/core/index.workerd.js.map +1 -1
  42. package/dist/react/form/index.d.ts +60 -1
  43. package/dist/react/form/index.d.ts.map +1 -1
  44. package/dist/react/form/index.js +86 -1
  45. package/dist/react/form/index.js.map +1 -1
  46. package/dist/react/head/index.browser.js +16 -1
  47. package/dist/react/head/index.browser.js.map +1 -1
  48. package/dist/react/head/index.d.ts +6 -0
  49. package/dist/react/head/index.d.ts.map +1 -1
  50. package/dist/react/head/index.js +16 -1
  51. package/dist/react/head/index.js.map +1 -1
  52. package/dist/react/router/index.browser.js +0 -10
  53. package/dist/react/router/index.browser.js.map +1 -1
  54. package/dist/react/router/index.d.ts +35 -12
  55. package/dist/react/router/index.d.ts.map +1 -1
  56. package/dist/react/router/index.js +0 -10
  57. package/dist/react/router/index.js.map +1 -1
  58. package/dist/react/ui/index.d.ts +124 -0
  59. package/dist/react/ui/index.d.ts.map +1 -0
  60. package/dist/react/ui/index.js +206 -0
  61. package/dist/react/ui/index.js.map +1 -0
  62. package/dist/router/index.d.ts +13 -13
  63. package/dist/router/index.d.ts.map +1 -1
  64. package/dist/router/index.js +45 -32
  65. package/dist/router/index.js.map +1 -1
  66. package/dist/system/index.d.ts.map +1 -1
  67. package/dist/system/index.js +1 -0
  68. package/dist/system/index.js.map +1 -1
  69. package/dist/topic/core/index.js +1 -1
  70. package/dist/topic/core/index.js.map +1 -1
  71. package/package.json +6 -23
  72. package/src/api/files/jobs/FileJobs.ts +2 -1
  73. package/src/api/jobs/__tests__/$job.spec.ts +316 -2867
  74. package/src/api/jobs/controllers/AdminJobController.ts +29 -138
  75. package/src/api/jobs/entities/jobExecutionEntity.ts +27 -19
  76. package/src/api/jobs/index.browser.ts +5 -7
  77. package/src/api/jobs/index.ts +23 -51
  78. package/src/api/jobs/primitives/$job.ts +66 -58
  79. package/src/api/jobs/providers/JobProvider.ts +561 -566
  80. package/src/api/jobs/providers/JobQueueProvider.ts +18 -19
  81. package/src/api/jobs/schemas/jobConfigAtom.ts +20 -23
  82. package/src/api/jobs/schemas/jobExecutionQuerySchema.ts +3 -27
  83. package/src/api/jobs/schemas/jobExecutionResourceSchema.ts +5 -7
  84. package/src/api/jobs/schemas/jobRegistrationSchema.ts +7 -4
  85. package/src/api/jobs/schemas/triggerJobSchema.ts +0 -1
  86. package/src/api/jobs/services/JobService.ts +90 -483
  87. package/src/api/notifications/controllers/AdminNotificationController.ts +19 -12
  88. package/src/api/notifications/index.ts +7 -4
  89. package/src/api/notifications/jobs/NotificationJobs.ts +83 -12
  90. package/src/api/payments/services/PaymentService.ts +4 -2
  91. package/src/api/users/__tests__/UserJobs.spec.ts +10 -49
  92. package/src/api/users/audits/UserAudits.ts +3 -1
  93. package/src/api/users/buckets/UserBuckets.ts +2 -1
  94. package/src/api/users/index.ts +1 -4
  95. package/src/api/users/jobs/UserJobs.ts +5 -4
  96. package/src/api/verifications/jobs/VerificationJobs.ts +2 -1
  97. package/src/cli/core/__tests__/init.spec.ts +1 -1
  98. package/src/cli/core/commands/init.ts +0 -12
  99. package/src/cli/core/services/PackageManagerUtils.ts +2 -9
  100. package/src/cli/core/services/ProjectScaffolder.ts +17 -65
  101. package/src/cli/core/templates/agentMd.ts +2 -8
  102. package/src/cli/core/templates/apiIndexTs.ts +4 -18
  103. package/src/cli/core/templates/mainCss.ts +1 -36
  104. package/src/cli/core/templates/vitestConfigTs.ts +17 -0
  105. package/src/cli/core/templates/webAppRouterTs.ts +2 -85
  106. package/src/cli/platform/__tests__/CloudflareAdapter.spec.ts +22 -71
  107. package/src/cli/platform/adapters/CloudflareAdapter.ts +12 -11
  108. package/src/cli/platform/atoms/platformOptions.ts +9 -0
  109. package/src/cli/platform/schemas/cloudflare.ts +3 -2
  110. package/src/cli/platform/services/CloudflareApi.ts +164 -25
  111. package/src/cli/platform/services/WranglerApi.ts +0 -17
  112. package/src/core/Alepha.ts +9 -0
  113. package/src/react/form/index.ts +2 -0
  114. package/src/react/form/services/parseField.ts +163 -0
  115. package/src/react/form/services/prettyName.ts +19 -0
  116. package/src/react/head/providers/BrowserHeadProvider.ts +31 -10
  117. package/src/react/router/primitives/$page.ts +35 -12
  118. package/src/react/ui/atoms/uiAtom.ts +28 -0
  119. package/src/react/ui/components/ColorScheme.tsx +36 -0
  120. package/src/react/ui/hooks/useColorMode.ts +49 -0
  121. package/src/react/ui/hooks/useSidebarState.ts +26 -0
  122. package/src/react/ui/hooks/useTheme.ts +22 -0
  123. package/src/react/ui/index.ts +35 -0
  124. package/src/react/ui/services/UiPersistence.ts +41 -0
  125. package/src/router/TemplatedPathParser.ts +50 -51
  126. package/src/router/__tests__/RouterProvider.spec.ts +62 -0
  127. package/src/router/__tests__/TemplatedPathParser.spec.ts +18 -0
  128. package/src/router/providers/RouterProvider.ts +10 -5
  129. package/src/system/providers/NodeShellProvider.ts +1 -0
  130. package/src/topic/core/providers/TopicProvider.ts +1 -1
  131. package/dist/api/invitations/index.d.ts +0 -790
  132. package/dist/api/invitations/index.d.ts.map +0 -1
  133. package/dist/api/invitations/index.js +0 -662
  134. package/dist/api/invitations/index.js.map +0 -1
  135. package/dist/api/issues/index.d.ts +0 -810
  136. package/dist/api/issues/index.d.ts.map +0 -1
  137. package/dist/api/issues/index.js +0 -444
  138. package/dist/api/issues/index.js.map +0 -1
  139. package/dist/api/subscriptions/index.d.ts +0 -1692
  140. package/dist/api/subscriptions/index.d.ts.map +0 -1
  141. package/dist/api/subscriptions/index.js +0 -1867
  142. package/dist/api/subscriptions/index.js.map +0 -1
  143. package/dist/api/workflows/index.browser.js +0 -246
  144. package/dist/api/workflows/index.browser.js.map +0 -1
  145. package/dist/api/workflows/index.d.ts +0 -1618
  146. package/dist/api/workflows/index.d.ts.map +0 -1
  147. package/dist/api/workflows/index.js +0 -1495
  148. package/dist/api/workflows/index.js.map +0 -1
  149. package/src/api/invitations/__tests__/InvitationService.spec.ts +0 -439
  150. package/src/api/invitations/controllers/AdminInvitationController.ts +0 -86
  151. package/src/api/invitations/controllers/InvitationController.ts +0 -84
  152. package/src/api/invitations/entities/invitations.ts +0 -33
  153. package/src/api/invitations/index.ts +0 -58
  154. package/src/api/invitations/jobs/InvitationJobs.ts +0 -37
  155. package/src/api/invitations/providers/InvitationProvider.ts +0 -45
  156. package/src/api/invitations/schemas/createInvitationSchema.ts +0 -12
  157. package/src/api/invitations/schemas/invitationConfigAtom.ts +0 -20
  158. package/src/api/invitations/schemas/invitationQuerySchema.ts +0 -15
  159. package/src/api/invitations/schemas/invitationResourceSchema.ts +0 -6
  160. package/src/api/invitations/schemas/invitationWithResourceInfoSchema.ts +0 -22
  161. package/src/api/invitations/schemas/myInvitationsQuerySchema.ts +0 -10
  162. package/src/api/invitations/services/InvitationService.ts +0 -556
  163. package/src/api/issues/__tests__/IssueService.spec.ts +0 -263
  164. package/src/api/issues/controllers/AdminIssueController.ts +0 -149
  165. package/src/api/issues/controllers/IssueController.ts +0 -44
  166. package/src/api/issues/entities/issues.ts +0 -49
  167. package/src/api/issues/index.ts +0 -50
  168. package/src/api/issues/schemas/createIssueSchema.ts +0 -13
  169. package/src/api/issues/schemas/issueConfigAtom.ts +0 -13
  170. package/src/api/issues/schemas/issueQuerySchema.ts +0 -18
  171. package/src/api/issues/schemas/issueResourceSchema.ts +0 -6
  172. package/src/api/issues/schemas/myIssueQuerySchema.ts +0 -10
  173. package/src/api/issues/schemas/updateIssueSchema.ts +0 -13
  174. package/src/api/issues/services/IssueService.ts +0 -264
  175. package/src/api/jobs/__tests__/$job-middleware.spec.ts +0 -126
  176. package/src/api/jobs/__tests__/JobService.spec.ts +0 -31
  177. package/src/api/jobs/entities/jobExecutionLogEntity.ts +0 -13
  178. package/src/api/jobs/schemas/jobActivitySchema.ts +0 -15
  179. package/src/api/jobs/schemas/jobCronInfoSchema.ts +0 -22
  180. package/src/api/jobs/schemas/jobExecutionDetailResourceSchema.ts +0 -20
  181. package/src/api/jobs/schemas/jobFailureSchema.ts +0 -9
  182. package/src/api/jobs/schemas/jobQueueDepthSchema.ts +0 -14
  183. package/src/api/jobs/schemas/jobStatsSchema.ts +0 -14
  184. package/src/api/jobs/services/JobService-tests.ts +0 -157
  185. package/src/api/subscriptions/__tests__/BillingService.spec.ts +0 -218
  186. package/src/api/subscriptions/__tests__/SubscriptionService.spec.ts +0 -278
  187. package/src/api/subscriptions/controllers/AdminSubscriptionController.ts +0 -212
  188. package/src/api/subscriptions/controllers/SubscriptionController.ts +0 -189
  189. package/src/api/subscriptions/entities/subscriptionEvents.ts +0 -54
  190. package/src/api/subscriptions/entities/subscriptions.ts +0 -68
  191. package/src/api/subscriptions/index.ts +0 -133
  192. package/src/api/subscriptions/jobs/SubscriptionJobs.ts +0 -382
  193. package/src/api/subscriptions/middleware/$requireLimit.ts +0 -50
  194. package/src/api/subscriptions/middleware/$requirePlan.ts +0 -49
  195. package/src/api/subscriptions/notifications/SubscriptionNotifications.ts +0 -110
  196. package/src/api/subscriptions/schemas/cancelSubscriptionSchema.ts +0 -8
  197. package/src/api/subscriptions/schemas/changePlanSchema.ts +0 -9
  198. package/src/api/subscriptions/schemas/createSubscriptionSchema.ts +0 -11
  199. package/src/api/subscriptions/schemas/entitlementsSchema.ts +0 -21
  200. package/src/api/subscriptions/schemas/mrrSchema.ts +0 -13
  201. package/src/api/subscriptions/schemas/planDefinitionSchema.ts +0 -71
  202. package/src/api/subscriptions/schemas/planResourceSchema.ts +0 -25
  203. package/src/api/subscriptions/schemas/subscriptionEventResourceSchema.ts +0 -8
  204. package/src/api/subscriptions/schemas/subscriptionQuerySchema.ts +0 -19
  205. package/src/api/subscriptions/schemas/subscriptionResourceSchema.ts +0 -6
  206. package/src/api/subscriptions/schemas/subscriptionSettingsSchema.ts +0 -32
  207. package/src/api/subscriptions/schemas/subscriptionStatsSchema.ts +0 -23
  208. package/src/api/subscriptions/services/BillingService.ts +0 -437
  209. package/src/api/subscriptions/services/SubscriptionConfig.ts +0 -56
  210. package/src/api/subscriptions/services/SubscriptionService.ts +0 -867
  211. package/src/api/subscriptions/services/UsageService.ts +0 -118
  212. package/src/api/workflows/__tests__/$workflow.spec.ts +0 -616
  213. package/src/api/workflows/controllers/AdminWorkflowController.ts +0 -191
  214. package/src/api/workflows/entities/workflowExecutions.ts +0 -74
  215. package/src/api/workflows/entities/workflowStepExecutions.ts +0 -74
  216. package/src/api/workflows/entities/workflowStepLogs.ts +0 -13
  217. package/src/api/workflows/index.browser.ts +0 -22
  218. package/src/api/workflows/index.ts +0 -115
  219. package/src/api/workflows/jobs/WorkflowJobs.ts +0 -77
  220. package/src/api/workflows/primitives/$workflow.ts +0 -202
  221. package/src/api/workflows/providers/WorkflowProvider.ts +0 -1284
  222. package/src/api/workflows/schemas/workflowActivitySchema.ts +0 -15
  223. package/src/api/workflows/schemas/workflowConfigAtom.ts +0 -51
  224. package/src/api/workflows/schemas/workflowExecutionDetailSchema.ts +0 -18
  225. package/src/api/workflows/schemas/workflowExecutionQuerySchema.ts +0 -26
  226. package/src/api/workflows/schemas/workflowExecutionResourceSchema.ts +0 -30
  227. package/src/api/workflows/schemas/workflowRegistrationSchema.ts +0 -26
  228. package/src/api/workflows/schemas/workflowStatsSchema.ts +0 -16
  229. package/src/api/workflows/schemas/workflowStepExecutionResourceSchema.ts +0 -15
  230. package/src/api/workflows/services/WorkflowService.ts +0 -382
  231. package/src/cli/core/templates/apiAppSecurityTs.ts +0 -43
  232. package/src/cli/core/templates/webAdminDashboardTsx.ts +0 -17
@@ -1,47 +1,47 @@
1
1
  import { $atom, $hook, $inject, $module, $state, Alepha, AlephaError, KIND, PipelinePrimitive, createPrimitive, t } from "alepha";
2
- import { AlephaLock, LockProvider } from "alepha/lock";
2
+ import { AlephaLock } 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, DatabaseProvider, db, pageQuerySchema, sql } from "alepha/orm";
8
+ import { $entity, $repository, db, sql } from "alepha/orm";
9
9
  import { DateTimeProvider } from "alepha/datetime";
10
- //#region ../../src/api/jobs/schemas/jobActivitySchema.ts
11
- const jobActivityPointSchema = t.object({
12
- date: t.text(),
13
- completed: t.integer(),
14
- failed: t.integer()
15
- });
16
- const jobActivityQuerySchema = t.object({ days: t.optional(t.integer({
17
- minimum: 1,
18
- maximum: 90
19
- })) });
20
- //#endregion
21
- //#region ../../src/api/jobs/schemas/jobCronInfoSchema.ts
22
- const jobCronInfoSchema = t.object({
23
- name: t.text(),
24
- cron: t.text(),
25
- lock: t.boolean(),
26
- priority: t.enum([
27
- "critical",
28
- "high",
29
- "normal",
30
- "low"
31
- ]),
32
- concurrency: t.integer(),
33
- hasSchema: t.boolean(),
34
- paused: t.boolean(),
35
- lastExecution: t.optional(t.object({
36
- id: t.uuid(),
37
- status: t.text(),
38
- startedAt: t.optional(t.datetime()),
39
- completedAt: t.optional(t.datetime()),
40
- error: t.optional(t.text())
10
+ //#region ../../src/api/jobs/schemas/jobExecutionQuerySchema.ts
11
+ const jobExecutionQuerySchema = t.object({
12
+ status: t.optional(t.enum([
13
+ "pending",
14
+ "running",
15
+ "scheduled",
16
+ "ok",
17
+ "error",
18
+ "cancelled"
19
+ ])),
20
+ limit: t.optional(t.integer({
21
+ minimum: 1,
22
+ maximum: 200,
23
+ default: 20
41
24
  }))
42
25
  });
43
26
  //#endregion
44
27
  //#region ../../src/api/jobs/entities/jobExecutionEntity.ts
28
+ /**
29
+ * Job execution record.
30
+ *
31
+ * Stores durable state for queue-mode jobs (outbox pattern) and error records
32
+ * for cron-mode jobs. Successful executions are trimmed by the sweep to keep
33
+ * the last N rows per job (configurable via `jobConfig.keepLastSuccess`).
34
+ *
35
+ * Status transitions:
36
+ * - queue push → pending
37
+ * - worker claim → running
38
+ * - success → ok
39
+ * - terminal failure → error
40
+ * - retry → scheduled (with scheduledAt = now + backoff)
41
+ * - delay → scheduled (with scheduledAt = now + delay)
42
+ * - sweep picks due ones → pending
43
+ * - cancel → cancelled
44
+ */
45
45
  const jobExecutionEntity = $entity({
46
46
  name: "job_executions",
47
47
  schema: t.object({
@@ -50,14 +50,12 @@ const jobExecutionEntity = $entity({
50
50
  updatedAt: db.updatedAt(),
51
51
  jobName: t.text(),
52
52
  key: t.optional(t.nullable(t.text())),
53
- payload: t.optional(t.record(t.text(), t.any())),
54
53
  status: db.default(t.enum([
55
54
  "pending",
56
- "scheduled",
57
- "retrying",
58
55
  "running",
59
- "completed",
60
- "dead",
56
+ "scheduled",
57
+ "ok",
58
+ "error",
61
59
  "cancelled"
62
60
  ]), "pending"),
63
61
  priority: db.default(t.integer({
@@ -66,12 +64,12 @@ const jobExecutionEntity = $entity({
66
64
  }), 2),
67
65
  attempt: db.default(t.integer(), 0),
68
66
  maxAttempts: db.default(t.integer(), 1),
67
+ payload: t.optional(t.record(t.text(), t.any())),
69
68
  scheduledAt: t.optional(t.datetime()),
70
69
  startedAt: t.optional(t.datetime()),
71
70
  completedAt: t.optional(t.datetime()),
72
- result: t.optional(t.record(t.text(), t.any())),
73
71
  error: t.optional(t.text()),
74
- workerId: t.optional(t.text()),
72
+ logs: t.optional(t.array(logEntrySchema)),
75
73
  triggeredBy: t.optional(t.text()),
76
74
  triggeredByName: t.optional(t.text()),
77
75
  cancelledBy: t.optional(t.text()),
@@ -81,15 +79,9 @@ const jobExecutionEntity = $entity({
81
79
  { columns: [
82
80
  "jobName",
83
81
  "status",
84
- "priority",
85
82
  "scheduledAt"
86
83
  ] },
87
- { columns: [
88
- "jobName",
89
- "status",
90
- "startedAt"
91
- ] },
92
- { columns: ["jobName", "completedAt"] },
84
+ { columns: ["jobName", "startedAt"] },
93
85
  {
94
86
  columns: ["jobName", "key"],
95
87
  unique: true
@@ -98,140 +90,60 @@ const jobExecutionEntity = $entity({
98
90
  });
99
91
  //#endregion
100
92
  //#region ../../src/api/jobs/schemas/jobExecutionResourceSchema.ts
101
- const jobExecutionCanSchema = t.object({
93
+ const jobExecutionResourceSchema = t.extend(jobExecutionEntity.schema, { can: t.object({
102
94
  retry: t.boolean(),
103
95
  cancel: t.boolean()
104
- });
105
- const jobExecutionResourceSchema = t.extend(jobExecutionEntity.schema, { can: jobExecutionCanSchema }, {
96
+ }) }, {
106
97
  title: "JobExecutionResource",
107
- description: "A job execution resource."
108
- });
109
- //#endregion
110
- //#region ../../src/api/jobs/schemas/jobExecutionDetailResourceSchema.ts
111
- const jobExecutionDetailResourceSchema = t.extend(jobExecutionEntity.schema, {
112
- can: jobExecutionCanSchema,
113
- logs: t.optional(t.array(logEntrySchema))
114
- }, {
115
- title: "JobExecutionDetailResource",
116
- description: "A job execution resource with logs."
117
- });
118
- //#endregion
119
- //#region ../../src/api/jobs/schemas/jobExecutionQuerySchema.ts
120
- const jobExecutionQuerySchema = t.extend(pageQuerySchema, {
121
- job: t.optional(t.text({ description: "Filter by job name" })),
122
- status: t.optional(t.enum([
123
- "pending",
124
- "scheduled",
125
- "retrying",
126
- "running",
127
- "completed",
128
- "dead",
129
- "cancelled"
130
- ])),
131
- priority: t.optional(t.enum([
132
- "critical",
133
- "high",
134
- "normal",
135
- "low"
136
- ])),
137
- from: t.optional(t.datetime({ description: "From date (ISO)" })),
138
- to: t.optional(t.datetime({ description: "To date (ISO)" }))
139
- });
140
- //#endregion
141
- //#region ../../src/api/jobs/schemas/jobFailureSchema.ts
142
- const jobFailureSchema = t.object({
143
- jobName: t.text(),
144
- failures: t.integer(),
145
- lastError: t.optional(t.text())
146
- });
147
- //#endregion
148
- //#region ../../src/api/jobs/schemas/jobQueueDepthSchema.ts
149
- const jobQueueDepthSchema = t.object({
150
- jobName: t.text(),
151
- pending: t.integer(),
152
- running: t.integer(),
153
- scheduled: t.integer(),
154
- retrying: t.integer(),
155
- dead: t.integer(),
156
- concurrency: t.integer(),
157
- paused: t.boolean()
98
+ description: "A job execution row with derived actions."
158
99
  });
159
100
  //#endregion
160
101
  //#region ../../src/api/jobs/schemas/jobRegistrationSchema.ts
161
102
  const jobRegistrationSchema = t.object({
162
103
  name: t.text(),
163
- type: t.enum([
164
- "cron",
165
- "push",
166
- "both"
167
- ]),
104
+ description: t.optional(t.text()),
105
+ type: t.enum(["cron", "queue"]),
168
106
  priority: t.enum([
169
107
  "critical",
170
108
  "high",
171
109
  "normal",
172
110
  "low"
173
111
  ]),
174
- concurrency: t.integer(),
175
- hasSchema: t.boolean(),
176
112
  cron: t.optional(t.text()),
177
113
  timeout: t.optional(t.text()),
178
114
  retry: t.optional(t.object({
179
115
  retries: t.integer(),
180
116
  hasBackoff: t.boolean()
181
117
  })),
182
- paused: t.boolean()
183
- });
184
- //#endregion
185
- //#region ../../src/api/jobs/schemas/jobStatsSchema.ts
186
- const jobStatsSchema = t.object({
187
- registered: t.integer(),
188
- running: t.integer(),
189
- pending: t.integer(),
190
- scheduled: t.integer(),
191
- retrying: t.integer(),
192
- dead: t.integer(),
193
- completed: t.integer(),
194
- failed: t.integer()
118
+ recent: t.object({
119
+ ok: t.integer(),
120
+ error: t.integer(),
121
+ lastRun: t.optional(t.datetime())
122
+ })
195
123
  });
196
124
  //#endregion
197
125
  //#region ../../src/api/jobs/schemas/triggerJobSchema.ts
198
- const triggerJobSchema = t.object({
199
- name: t.text(),
200
- payload: t.optional(t.record(t.text(), t.any()))
201
- });
202
- //#endregion
203
- //#region ../../src/api/jobs/entities/jobExecutionLogEntity.ts
204
- const jobExecutionLogEntity = $entity({
205
- name: "job_execution_logs",
206
- schema: t.object({
207
- id: db.primaryKey(t.uuid()),
208
- logs: t.array(logEntrySchema)
209
- })
210
- });
126
+ const triggerJobSchema = t.object({ payload: t.optional(t.record(t.text(), t.any())) });
211
127
  //#endregion
212
128
  //#region ../../src/api/jobs/schemas/jobConfigAtom.ts
213
129
  const jobConfig = $atom({
214
130
  name: "alepha.jobs",
215
- description: "Configuration for the $job v2 primitive.",
131
+ description: "Configuration for the $job primitive.",
216
132
  schema: t.object({
217
- recovery: t.object({
218
- interval: t.integer({ description: "Sweep interval (ms)." }),
219
- staleThreshold: t.integer({ description: "Pending age (ms) before re-dispatch." }),
220
- runTimeout: t.integer({ description: "Running age (ms) before assumed crash. Used as fallback when no per-job timeout is set." })
221
- }),
222
- delayed: t.object({ interval: t.integer({ description: "Sweep interval (ms)." }) }),
223
- logRetentionDays: t.integer({ description: "Days to keep completed/dead executions." }),
133
+ sweepInterval: t.integer({ description: "Sweep cron interval in milliseconds." }),
134
+ staleThreshold: t.integer({ description: "Pending age (ms) before the sweep re-dispatches it." }),
135
+ runTimeout: t.integer({ description: "Running age (ms) before assumed crash (fallback when no per-job timeout)." }),
136
+ keepLastSuccess: t.integer({ description: "Max successful rows to keep per job. Set 0 to disable and delete on success." }),
137
+ keepLastError: t.integer({ description: "Max error rows to keep per job." }),
224
138
  logMaxEntries: t.integer({ description: "Max log entries captured per execution." }),
225
139
  drainTimeout: t.integer({ description: "Max time (ms) to wait for in-flight jobs during shutdown." })
226
140
  }),
227
141
  default: {
228
- recovery: {
229
- interval: 3e5,
230
- staleThreshold: 3e5,
231
- runTimeout: 18e5
232
- },
233
- delayed: { interval: 3e5 },
234
- logRetentionDays: 30,
142
+ sweepInterval: 3e5,
143
+ staleThreshold: 3e5,
144
+ runTimeout: 18e5,
145
+ keepLastSuccess: 10,
146
+ keepLastError: 10,
235
147
  logMaxEntries: 100,
236
148
  drainTimeout: 3e4
237
149
  }
@@ -250,59 +162,185 @@ const PRIORITY_REVERSE = {
250
162
  2: "normal",
251
163
  3: "low"
252
164
  };
253
- var JobProvider = class JobProvider {
165
+ const SWEEP_CRON = "*/5 * * * *";
166
+ /**
167
+ * Coordinates cron (scheduler) and queue (push) jobs with a durable outbox
168
+ * table and a single reconciliation sweep.
169
+ *
170
+ * Queue-mode flow:
171
+ * push() → INSERT row (pending) + queue.send({ executionId })
172
+ * worker → SELECT row → UPDATE running → handler → DELETE (ok) / UPDATE (error)
173
+ *
174
+ * Cron-mode flow:
175
+ * scheduler tick → handler runs inline → INSERT row only on error
176
+ *
177
+ * Sweep responsibilities (every `sweepInterval`):
178
+ * - 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`
182
+ */
183
+ var JobProvider = class {
254
184
  alepha = $inject(Alepha);
255
185
  dt = $inject(DateTimeProvider);
256
186
  cronProvider = $inject(CronProvider);
257
- lockProvider = $inject(LockProvider);
258
187
  config = $state(jobConfig);
259
188
  log = $logger();
260
189
  executions = $repository(jobExecutionEntity);
261
- executionLogs = $repository(jobExecutionLogEntity);
262
190
  jobs = /* @__PURE__ */ new Map();
263
- pausedJobs = /* @__PURE__ */ new Set();
264
191
  inFlight = /* @__PURE__ */ new Set();
192
+ abortControllers = /* @__PURE__ */ new Map();
193
+ perExecutionLogs = /* @__PURE__ */ new Map();
194
+ stopping = false;
265
195
  /**
266
- * When set, job executions are dispatched through a queue (e.g. `JobQueueProvider`).
267
- * When null, jobs execute inline (fire-and-forget). Useful for serverless environments.
196
+ * Set by `JobQueueProvider` when `AlephaApiJobsQueue` is loaded.
197
+ * When null, queue-mode jobs cannot be pushed.
268
198
  */
269
199
  queueDispatch = null;
270
- logs = /* @__PURE__ */ new Map();
271
- abortControllers = /* @__PURE__ */ new Map();
272
- static SWEEP_CRON = "*/5 * * * *";
273
- stopping = false;
274
- workerId = "";
275
200
  registerJob(name, options) {
276
201
  if (this.jobs.has(name)) throw new AlephaError(`Job already registered: ${name}`);
202
+ 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
+ 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";
277
205
  this.jobs.set(name, {
278
206
  name,
279
- options
207
+ options,
208
+ type
280
209
  });
281
- this.log.debug(`Registered job '${name}'`, {
210
+ this.log.debug(`Registered ${type} job '${name}'`, {
282
211
  cron: options.cron,
283
212
  priority: options.priority ?? "normal",
284
213
  retries: options.retry?.retries ?? 0
285
214
  });
286
215
  if (options.cron) this.cronProvider.createCronJob(name, options.cron, async () => {
287
216
  try {
288
- await this.trigger(name, {
289
- triggeredBy: "system",
290
- triggeredByName: "system (cron)"
291
- });
217
+ await this.runCron(name);
292
218
  } catch (error) {
293
- this.log.error(`Cron trigger failed for job '${name}'`, error);
219
+ this.log.error(`Cron tick failed for job '${name}'`, error);
294
220
  }
295
221
  });
296
222
  }
297
- /**
298
- * Get all registered job definitions.
299
- */
300
223
  getRegisteredJobs() {
301
224
  return this.jobs;
302
225
  }
226
+ async runCron(name) {
227
+ 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,
234
+ triggeredBy: "system",
235
+ triggeredByName: "system (cron)"
236
+ });
237
+ this.inFlight.add(promise);
238
+ try {
239
+ await promise;
240
+ } finally {
241
+ this.inFlight.delete(promise);
242
+ }
243
+ }
244
+ /**
245
+ * Execute a cron handler inline. Records a row only on error (or always,
246
+ * when `record: 'all'`). No DB writes on the happy path by default.
247
+ */
248
+ async executeInline(registration, executionId, ctx) {
249
+ const opts = registration.options;
250
+ const name = registration.name;
251
+ const record = opts.record ?? "error";
252
+ const contextId = this.alepha.context.createContextId();
253
+ this.perExecutionLogs.set(contextId, []);
254
+ const abortController = new AbortController();
255
+ this.abortControllers.set(executionId, abortController);
256
+ let timeoutId;
257
+ if (opts.timeout) {
258
+ const ms = this.dt.duration(opts.timeout).as("milliseconds");
259
+ timeoutId = setTimeout(() => abortController.abort(), ms);
260
+ }
261
+ const startedAt = this.dt.now();
262
+ try {
263
+ await this.alepha.context.run(async () => {
264
+ await this.alepha.events.emit("job:begin", {
265
+ name,
266
+ now: startedAt,
267
+ executionId
268
+ });
269
+ try {
270
+ await opts.handler({
271
+ payload: ctx.payload,
272
+ attempt: ctx.attempt,
273
+ now: startedAt,
274
+ signal: abortController.signal,
275
+ executionId
276
+ });
277
+ if (record === "all") await this.writeTerminalRow(executionId, name, "ok", {
278
+ payload: ctx.payload,
279
+ attempt: ctx.attempt,
280
+ startedAt,
281
+ error: void 0,
282
+ context: contextId,
283
+ triggeredBy: ctx.triggeredBy,
284
+ triggeredByName: ctx.triggeredByName
285
+ });
286
+ await this.alepha.events.emit("job:success", {
287
+ name,
288
+ executionId
289
+ }, { catch: true });
290
+ } catch (error) {
291
+ const err = error instanceof Error ? error : new Error(String(error));
292
+ if (record !== "none") await this.writeTerminalRow(executionId, name, "error", {
293
+ payload: ctx.payload,
294
+ attempt: ctx.attempt,
295
+ startedAt,
296
+ error: err,
297
+ context: contextId,
298
+ triggeredBy: ctx.triggeredBy,
299
+ triggeredByName: ctx.triggeredByName
300
+ });
301
+ await this.alepha.events.emit("job:error", {
302
+ name,
303
+ error: err,
304
+ executionId
305
+ }, { catch: true });
306
+ } finally {
307
+ if (timeoutId) clearTimeout(timeoutId);
308
+ this.abortControllers.delete(executionId);
309
+ await this.alepha.events.emit("job:end", {
310
+ name,
311
+ executionId
312
+ }, { catch: true });
313
+ }
314
+ }, { context: contextId });
315
+ } finally {
316
+ this.perExecutionLogs.delete(contextId);
317
+ }
318
+ }
319
+ async writeTerminalRow(executionId, jobName, status, fields) {
320
+ try {
321
+ const logs = status === "error" ? this.snapshotLogs(fields.context) : void 0;
322
+ await this.executions.create({
323
+ id: executionId,
324
+ jobName,
325
+ status,
326
+ payload: fields.payload,
327
+ attempt: fields.attempt,
328
+ maxAttempts: fields.attempt,
329
+ startedAt: fields.startedAt.toISOString(),
330
+ completedAt: this.dt.nowISOString(),
331
+ error: fields.error?.message,
332
+ logs,
333
+ triggeredBy: fields.triggeredBy,
334
+ triggeredByName: fields.triggeredByName
335
+ });
336
+ } catch (e) {
337
+ this.log.warn(`Failed to write terminal row for ${executionId}`, e);
338
+ }
339
+ }
303
340
  async push(name, payload, options) {
304
- const opts = this.getRegistration(name).options;
305
- if (!opts.schema) throw new AlephaError(`Cannot push to job '${name}': no schema defined. Use trigger() for cron-only jobs.`);
341
+ 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.`);
343
+ const opts = registration.options;
306
344
  const validated = this.alepha.codec.validate(opts.schema, payload);
307
345
  const priority = PRIORITY_MAP[options?.priority ?? opts.priority ?? "normal"];
308
346
  const maxAttempts = (opts.retry?.retries ?? 0) + 1;
@@ -311,8 +349,15 @@ var JobProvider = class JobProvider {
311
349
  if (options?.scheduledAt) scheduledAt = options.scheduledAt.toISOString();
312
350
  else if (options?.delay) scheduledAt = this.dt.now().add(this.dt.duration(options.delay)).toISOString();
313
351
  if (options?.key) {
314
- const now = this.dt.nowISOString();
315
- const execution = await this.executions.upsert({
352
+ const existing = await this.executions.findMany({
353
+ where: {
354
+ jobName: { eq: name },
355
+ key: { eq: options.key }
356
+ },
357
+ limit: 1
358
+ });
359
+ if (existing.length > 0) return existing[0].id;
360
+ const execution = await this.executions.create({
316
361
  jobName: name,
317
362
  key: options.key,
318
363
  payload: validated,
@@ -320,14 +365,11 @@ var JobProvider = class JobProvider {
320
365
  priority,
321
366
  maxAttempts,
322
367
  scheduledAt,
323
- createdAt: now,
324
- updatedAt: now
325
- }, {
326
- target: ["jobName", "key"],
327
- set: {},
328
- now
368
+ triggeredBy: options.triggeredBy,
369
+ triggeredByName: options.triggeredByName
329
370
  });
330
- if (execution.createdAt === execution.updatedAt && status === "pending" && !this.stopping) await this.scheduleProcessing(name, execution.id);
371
+ if (status === "pending") await this.dispatchToQueue(name, execution.id);
372
+ else if (status === "scheduled" && scheduledAt) this.scheduleOptimisticDispatch(name, execution.id, scheduledAt);
331
373
  return execution.id;
332
374
  }
333
375
  const execution = await this.executions.create({
@@ -336,43 +378,55 @@ var JobProvider = class JobProvider {
336
378
  status,
337
379
  priority,
338
380
  maxAttempts,
339
- scheduledAt
340
- });
341
- this.log.debug(`Pushed job '${name}'`, {
342
- executionId: execution.id,
343
- status,
344
- priority: PRIORITY_REVERSE[priority]
381
+ scheduledAt,
382
+ triggeredBy: options?.triggeredBy,
383
+ triggeredByName: options?.triggeredByName
345
384
  });
346
- if (status === "pending" && !this.stopping) await this.scheduleProcessing(name, execution.id);
385
+ if (status === "pending") await this.dispatchToQueue(name, execution.id);
386
+ else if (status === "scheduled" && scheduledAt) this.scheduleOptimisticDispatch(name, execution.id, scheduledAt);
347
387
  return execution.id;
348
388
  }
389
+ /**
390
+ * Fire a local setTimeout so delayed/retrying rows dispatch as close to
391
+ * `scheduledAt` as possible, rather than waiting for the next sweep tick.
392
+ * No-op on stateless runtimes where timers won't survive (the sweep
393
+ * handles those).
394
+ */
395
+ scheduleOptimisticDispatch(jobName, executionId, scheduledAt) {
396
+ const delayMs = Math.max(0, new Date(scheduledAt).getTime() - this.dt.nowMillis());
397
+ this.dt.createTimeout(() => {
398
+ this.dispatchScheduled(jobName, executionId);
399
+ }, delayMs);
400
+ }
349
401
  async pushMany(name, items) {
350
402
  if (items.length === 0) return [];
351
- const opts = this.getRegistration(name).options;
352
- if (!opts.schema) throw new AlephaError(`Cannot push to job '${name}': no schema defined. Use trigger() for cron-only jobs.`);
403
+ const registration = this.getRegistration(name);
404
+ if (registration.type !== "queue") throw new AlephaError(`Job '${name}' is not queue-mode (no schema declared).`);
405
+ const opts = registration.options;
353
406
  const maxAttempts = (opts.retry?.retries ?? 0) + 1;
354
407
  const keyed = [];
355
- const bulkRows = [];
408
+ const bulk = [];
356
409
  for (const item of items) {
357
410
  const validated = this.alepha.codec.validate(opts.schema, item.payload);
358
- if (item.key) keyed.push({
359
- ...item,
360
- payload: validated
361
- });
362
- else {
363
- const status = item.delay || item.scheduledAt ? "scheduled" : "pending";
364
- let scheduledAt;
365
- if (item.scheduledAt) scheduledAt = item.scheduledAt.toISOString();
366
- else if (item.delay) scheduledAt = this.dt.now().add(this.dt.duration(item.delay)).toISOString();
367
- bulkRows.push({
368
- jobName: name,
369
- payload: validated,
370
- status,
371
- priority: PRIORITY_MAP[item.priority ?? opts.priority ?? "normal"],
372
- maxAttempts,
373
- scheduledAt
411
+ if (item.key) {
412
+ keyed.push({
413
+ ...item,
414
+ payload: validated
374
415
  });
416
+ continue;
375
417
  }
418
+ const status = item.delay || item.scheduledAt ? "scheduled" : "pending";
419
+ let scheduledAt;
420
+ if (item.scheduledAt) scheduledAt = item.scheduledAt.toISOString();
421
+ else if (item.delay) scheduledAt = this.dt.now().add(this.dt.duration(item.delay)).toISOString();
422
+ bulk.push({
423
+ jobName: name,
424
+ payload: validated,
425
+ status,
426
+ priority: PRIORITY_MAP[item.priority ?? opts.priority ?? "normal"],
427
+ maxAttempts,
428
+ scheduledAt
429
+ });
376
430
  }
377
431
  const ids = [];
378
432
  for (const item of keyed) {
@@ -384,49 +438,47 @@ var JobProvider = class JobProvider {
384
438
  });
385
439
  ids.push(id);
386
440
  }
387
- if (bulkRows.length > 0) {
388
- const created = await this.executions.createMany(bulkRows);
441
+ if (bulk.length > 0) {
442
+ const created = await this.executions.createMany(bulk);
389
443
  for (const exec of created) {
390
444
  ids.push(exec.id);
391
- if (exec.status === "pending" && !this.stopping) await this.scheduleProcessing(name, exec.id);
445
+ if (exec.status === "pending" && !this.stopping) await this.dispatchToQueue(name, exec.id);
446
+ else if (exec.status === "scheduled" && exec.scheduledAt && !this.stopping) this.scheduleOptimisticDispatch(name, exec.id, exec.scheduledAt);
392
447
  }
393
448
  }
394
449
  this.log.debug(`pushMany '${name}': ${ids.length} jobs created`, {
395
- bulk: bulkRows.length,
450
+ bulk: bulk.length,
396
451
  keyed: keyed.length
397
452
  });
398
453
  return ids;
399
454
  }
455
+ async dispatchToQueue(jobName, executionId) {
456
+ 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);
459
+ }
400
460
  async trigger(name, context) {
401
- const opts = this.getRegistration(name).options;
402
- if (context?.payload && opts.schema) {
403
- const id = await this.push(name, context.payload, {});
404
- await this.executions.updateById(id, {
461
+ 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,
405
467
  triggeredBy: context?.triggeredBy,
406
468
  triggeredByName: context?.triggeredByName
407
469
  });
408
470
  return;
409
471
  }
410
- const maxAttempts = (opts.retry?.retries ?? 0) + 1;
411
- const priority = PRIORITY_MAP[opts.priority ?? "normal"];
412
- const execution = await this.executions.create({
413
- jobName: name,
414
- status: "pending",
415
- priority,
416
- maxAttempts,
417
- triggeredBy: context?.triggeredBy,
418
- triggeredByName: context?.triggeredByName
419
- });
420
- this.log.debug(`Triggered job '${name}'`, {
421
- executionId: execution.id,
422
- triggeredBy: context?.triggeredByName ?? context?.triggeredBy
472
+ if (!context?.payload) throw new AlephaError(`Queue-mode job '${name}' requires a payload for manual trigger.`);
473
+ await this.push(name, context.payload, {
474
+ triggeredBy: context.triggeredBy,
475
+ triggeredByName: context.triggeredByName
423
476
  });
424
- if (!this.stopping) await this.scheduleProcessing(name, execution.id);
425
477
  }
426
478
  async cancel(executionId, context) {
427
479
  const execution = await this.executions.findById(executionId);
428
480
  if (!execution) throw new AlephaError(`Execution not found: ${executionId}`);
429
- if (execution.status === "completed" || execution.status === "dead" || execution.status === "cancelled") throw new AlephaError(`Cannot cancel execution in '${execution.status}' status`);
481
+ if (execution.status === "ok" || execution.status === "error" || execution.status === "cancelled") throw new AlephaError(`Cannot cancel execution in '${execution.status}' status`);
430
482
  const controller = this.abortControllers.get(executionId);
431
483
  if (controller) controller.abort();
432
484
  await this.executions.updateById(executionId, {
@@ -441,30 +493,17 @@ var JobProvider = class JobProvider {
441
493
  cancelledBy: context?.cancelledByName ?? context?.cancelledBy
442
494
  });
443
495
  }
444
- async scheduleProcessing(jobName, executionId) {
445
- if (this.pausedJobs.has(jobName)) {
446
- this.log.debug(`Job '${jobName}' is paused, deferring`, { executionId });
496
+ async processExecution(jobName, executionId) {
497
+ const registration = this.jobs.get(jobName);
498
+ if (!registration) {
499
+ this.log.warn(`Unknown job '${jobName}' — skipping execution`, { executionId });
447
500
  return;
448
501
  }
449
- const maxConcurrency = this.getRegistration(jobName).options.concurrency ?? 1;
450
- const runningCount = await this.executions.count({
451
- jobName: { eq: jobName },
452
- status: { eq: "running" }
453
- });
454
- if (runningCount >= maxConcurrency) {
455
- this.log.debug(`Job '${jobName}' at concurrency limit (${runningCount}/${maxConcurrency}), deferring`, { executionId });
502
+ if (registration.type !== "queue") {
503
+ this.log.warn(`Job '${jobName}' is not queue-mode — skipping`, { executionId });
456
504
  return;
457
505
  }
458
- if (this.queueDispatch) {
459
- this.log.debug(`Dispatching job '${jobName}' via queue`, { executionId });
460
- await this.queueDispatch(jobName, executionId);
461
- } else {
462
- this.log.debug(`Executing job '${jobName}' inline`, { executionId });
463
- await this.processExecution(jobName, executionId);
464
- }
465
- }
466
- async processExecution(jobName, executionId) {
467
- const promise = this.processExecutionInner(jobName, executionId);
506
+ const promise = this.processQueueExecution(registration, executionId);
468
507
  this.inFlight.add(promise);
469
508
  try {
470
509
  await promise;
@@ -472,71 +511,63 @@ var JobProvider = class JobProvider {
472
511
  this.inFlight.delete(promise);
473
512
  }
474
513
  }
475
- async processExecutionInner(jobName, executionId) {
476
- const registration = this.getRegistration(jobName);
514
+ async processQueueExecution(registration, executionId) {
515
+ const jobName = registration.name;
516
+ const opts = registration.options;
517
+ const record = opts.record ?? "error";
477
518
  if (!await this.claim(executionId)) {
478
519
  this.log.debug(`Execution ${executionId} already claimed, skipping`);
479
520
  return;
480
521
  }
481
- const context = this.alepha.context.createContextId();
482
- this.logs.set(context, []);
483
- this.log.debug(`Started processing job '${jobName}'`, { executionId });
522
+ const execution = await this.executions.findById(executionId);
523
+ if (!execution) return;
524
+ const contextId = this.alepha.context.createContextId();
525
+ this.perExecutionLogs.set(contextId, []);
526
+ const abortController = new AbortController();
527
+ this.abortControllers.set(executionId, abortController);
528
+ let timeoutId;
529
+ if (opts.timeout) {
530
+ const ms = this.dt.duration(opts.timeout).as("milliseconds");
531
+ timeoutId = setTimeout(() => abortController.abort(), ms);
532
+ }
533
+ const now = this.dt.now();
484
534
  try {
485
535
  await this.alepha.context.run(async () => {
486
- const abortController = new AbortController();
487
- this.abortControllers.set(executionId, abortController);
488
- let timeoutId;
489
- const opts = registration.options;
490
- if (opts.timeout) {
491
- const ms = this.dt.duration(opts.timeout).as("milliseconds");
492
- timeoutId = setTimeout(() => abortController.abort(), ms);
493
- }
494
- const now = this.dt.now();
495
536
  await this.alepha.events.emit("job:begin", {
496
537
  name: jobName,
497
538
  now,
498
539
  executionId
499
540
  });
500
541
  try {
501
- const execution = await this.executions.findById(executionId);
502
- const items = [];
503
- if (execution?.payload) items.push({
504
- id: executionId,
505
- payload: execution.payload,
506
- attempt: execution.attempt
507
- });
508
- this.log.debug(`Running job '${jobName}'`, {
509
- executionId,
510
- attempt: execution?.attempt,
511
- items: items.length
512
- });
513
542
  await opts.handler({
514
- items,
543
+ payload: execution.payload,
544
+ attempt: execution.attempt,
515
545
  now,
516
- signal: abortController.signal
546
+ signal: abortController.signal,
547
+ executionId
517
548
  });
518
- await this.executions.updateById(executionId, {
519
- status: "completed",
549
+ if (record === "all" && this.config.keepLastSuccess > 0) await this.executions.updateById(executionId, {
550
+ status: "ok",
520
551
  completedAt: this.dt.nowISOString(),
521
552
  key: null
522
553
  });
523
- this.log.info(`Job '${jobName}' completed`, { executionId });
524
- await this.writeLogs(executionId, context);
554
+ else await this.executions.deleteById(executionId);
525
555
  await this.alepha.events.emit("job:success", {
526
556
  name: jobName,
527
557
  executionId
528
558
  }, { catch: true });
529
559
  } catch (error) {
530
560
  const err = error instanceof Error ? error : new Error(String(error));
531
- if (abortController.signal.aborted) if ((await this.executions.findById(executionId))?.status !== "cancelled") await this.handleFailure(executionId, jobName, err, context);
532
- else {
533
- await this.writeLogs(executionId, context);
534
- await this.alepha.events.emit("job:cancel", {
535
- name: jobName,
536
- executionId
537
- }, { catch: true });
561
+ if (abortController.signal.aborted) {
562
+ if ((await this.executions.findById(executionId))?.status === "cancelled") {
563
+ await this.alepha.events.emit("job:cancel", {
564
+ name: jobName,
565
+ executionId
566
+ }, { catch: true });
567
+ return;
568
+ }
538
569
  }
539
- else await this.handleFailure(executionId, jobName, err, context);
570
+ await this.handleFailure(executionId, registration, execution.attempt, err, contextId);
540
571
  } finally {
541
572
  if (timeoutId) clearTimeout(timeoutId);
542
573
  this.abortControllers.delete(executionId);
@@ -544,39 +575,12 @@ var JobProvider = class JobProvider {
544
575
  name: jobName,
545
576
  executionId
546
577
  }, { catch: true });
547
- await this.dispatchNextPending(jobName);
548
578
  }
549
- }, { context });
579
+ }, { context: contextId });
550
580
  } finally {
551
- this.logs.delete(context);
581
+ this.perExecutionLogs.delete(contextId);
552
582
  }
553
583
  }
554
- /**
555
- * After a job finishes (success, failure, or cancel), dispatch any pending
556
- * jobs that were deferred due to the concurrency limit.
557
- */
558
- async dispatchNextPending(jobName) {
559
- if (this.stopping || this.pausedJobs.has(jobName)) return;
560
- const registration = this.jobs.get(jobName);
561
- if (!registration) return;
562
- const available = (registration.options.concurrency ?? 1) - await this.executions.count({
563
- jobName: { eq: jobName },
564
- status: { eq: "running" }
565
- });
566
- if (available <= 0) return;
567
- const pending = await this.executions.findMany({
568
- where: {
569
- jobName: { eq: jobName },
570
- status: { eq: "pending" }
571
- },
572
- orderBy: {
573
- column: "priority",
574
- direction: "asc"
575
- },
576
- limit: available
577
- });
578
- for (const exec of pending) await this.scheduleProcessing(jobName, exec.id);
579
- }
580
584
  async claim(executionId) {
581
585
  const execution = await this.executions.findById(executionId);
582
586
  if (!execution) return false;
@@ -587,45 +591,46 @@ var JobProvider = class JobProvider {
587
591
  }, {
588
592
  status: "running",
589
593
  attempt: execution.attempt + 1,
590
- startedAt: this.dt.nowISOString(),
591
- workerId: this.workerId
594
+ startedAt: this.dt.nowISOString()
592
595
  });
593
596
  return true;
594
597
  } catch {
595
598
  return false;
596
599
  }
597
600
  }
598
- async handleFailure(executionId, jobName, error, context) {
599
- const execution = await this.executions.findById(executionId);
600
- if (!execution) return;
601
- const retryOpts = this.getRegistration(jobName).options.retry;
602
- if (retryOpts && execution.attempt < execution.maxAttempts && (retryOpts.when ? retryOpts.when(error) : true)) {
603
- const nextScheduledAt = this.computeBackoff(retryOpts, execution.attempt);
604
- this.log.info(`Job '${jobName}' failed, scheduling retry ${execution.attempt}/${execution.maxAttempts}`, {
601
+ async handleFailure(executionId, registration, currentAttempt, error, contextId) {
602
+ const jobName = registration.name;
603
+ const retry = registration.options.retry;
604
+ 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}`, {
605
608
  executionId,
606
609
  error: error.message,
607
610
  nextScheduledAt
608
611
  });
609
612
  await this.executions.updateById(executionId, {
610
- status: "retrying",
613
+ status: "scheduled",
611
614
  error: error.message,
612
- scheduledAt: nextScheduledAt
615
+ scheduledAt: nextScheduledAt,
616
+ logs: this.snapshotLogs(contextId)
613
617
  });
614
- await this.writeLogs(executionId, context);
615
618
  const delayMs = Math.max(0, new Date(nextScheduledAt).getTime() - this.dt.nowMillis());
616
- this.dt.createTimeout(() => void this.dispatchRetrying(jobName, executionId), delayMs);
619
+ this.dt.createTimeout(() => {
620
+ this.dispatchScheduled(jobName, executionId);
621
+ }, delayMs);
617
622
  } else {
618
- this.log.info(`Job '${jobName}' is dead after ${execution.attempt} attempt(s)`, {
623
+ this.log.info(`Job '${jobName}' dead after ${currentAttempt} attempt(s)`, {
619
624
  executionId,
620
625
  error: error.message
621
626
  });
622
627
  await this.executions.updateById(executionId, {
623
- status: "dead",
628
+ status: "error",
624
629
  error: error.message,
625
630
  completedAt: this.dt.nowISOString(),
626
- key: null
631
+ key: null,
632
+ logs: this.snapshotLogs(contextId)
627
633
  });
628
- await this.writeLogs(executionId, context);
629
634
  }
630
635
  await this.alepha.events.emit("job:error", {
631
636
  name: jobName,
@@ -633,238 +638,160 @@ var JobProvider = class JobProvider {
633
638
  executionId
634
639
  }, { catch: true });
635
640
  }
636
- computeBackoff(retryOpts, attempt) {
641
+ computeBackoff(retry, attempt) {
637
642
  const now = this.dt.now();
638
- if (!retryOpts.backoff) return now.add(1, "second").toISOString();
639
- if (Array.isArray(retryOpts.backoff)) {
640
- const delay = this.dt.duration(retryOpts.backoff);
641
- return now.add(delay).toISOString();
642
- }
643
- const backoff = retryOpts.backoff;
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;
644
646
  let delayMs = this.dt.duration(backoff.initial).as("milliseconds") * (backoff.factor ?? 2) ** (attempt - 1);
645
- if (backoff.max) {
646
- const maxMs = this.dt.duration(backoff.max).as("milliseconds");
647
- delayMs = Math.min(delayMs, maxMs);
648
- }
647
+ if (backoff.max) delayMs = Math.min(delayMs, this.dt.duration(backoff.max).as("milliseconds"));
649
648
  if (backoff.jitter) delayMs = delayMs * (.75 + Math.random() * .5);
650
649
  return now.add(delayMs, "millisecond").toISOString();
651
650
  }
652
- async writeLogs(executionId, context) {
653
- const entries = this.logs.get(context);
654
- if (!entries || entries.length === 0) return;
655
- const maxEntries = this.config.logMaxEntries;
656
- if (maxEntries === 0) return;
657
- let logs = entries;
658
- if (logs.length > maxEntries) {
659
- logs = logs.slice(0, maxEntries);
660
- logs.push({
661
- level: "WARN",
662
- message: `Log entries truncated at ${maxEntries}`,
663
- timestamp: this.dt.nowMillis(),
664
- service: "alepha.jobs",
665
- module: "JobProvider"
666
- });
667
- }
668
- try {
669
- await this.executionLogs.create({
670
- id: executionId,
671
- logs
672
- });
673
- } catch {
674
- this.log.warn(`Failed to write logs for execution ${executionId}`);
675
- }
676
- }
677
- async dispatchRetrying(jobName, executionId) {
678
- if (this.stopping) return;
679
- try {
680
- await this.executions.updateOne({
681
- id: { eq: executionId },
682
- status: { eq: "retrying" }
683
- }, { status: "pending" });
684
- await this.scheduleProcessing(jobName, executionId);
685
- } catch {}
651
+ snapshotLogs(contextId) {
652
+ const entries = this.perExecutionLogs.get(contextId);
653
+ if (!entries || entries.length === 0) return void 0;
654
+ const max = this.config.logMaxEntries;
655
+ if (max === 0) return void 0;
656
+ if (entries.length <= max) return [...entries];
657
+ const truncated = entries.slice(0, max);
658
+ truncated.push({
659
+ level: "WARN",
660
+ message: `Log entries truncated at ${max}`,
661
+ timestamp: this.dt.nowMillis(),
662
+ service: "alepha.jobs",
663
+ module: "JobProvider"
664
+ });
665
+ return truncated;
686
666
  }
687
- /**
688
- * Recovery Sweep (Section 5.1)
689
- *
690
- * Runs every `recovery.interval` (default: 1 minute).
691
- * - Stale `pending` jobs older than `staleThreshold` → re-dispatch.
692
- * - Crashed `running` jobs older than `max(job.timeout * 2, recovery.runTimeout)` → mark failed, apply retry policy.
693
- */
694
- async recoverySweep() {
695
- this.log.trace("Starting recovery sweep");
667
+ async sweep() {
696
668
  if (this.stopping) return;
697
- if (!await this.tryLock("_alepha:jobs:recovery-lock", 3e5)) return;
669
+ this.log.trace("Starting job sweep");
670
+ const now = this.dt.now();
671
+ const nowIso = now.toISOString();
698
672
  try {
699
- const now = this.dt.now();
700
- const staleThreshold = now.subtract(this.config.recovery.staleThreshold, "millisecond").toISOString();
701
- const pendingWhere = this.executions.createQueryWhere();
702
- pendingWhere.status = { eq: "pending" };
703
- pendingWhere.createdAt = { lte: staleThreshold };
704
- const stalePending = await this.executions.findMany({
705
- where: pendingWhere,
673
+ const dueWhere = this.executions.createQueryWhere();
674
+ dueWhere.status = { eq: "scheduled" };
675
+ dueWhere.scheduledAt = { lte: nowIso };
676
+ const due = await this.executions.findMany({
677
+ where: dueWhere,
678
+ orderBy: {
679
+ column: "priority",
680
+ direction: "asc"
681
+ }
682
+ });
683
+ for (const exec of due) {
684
+ if (!this.jobs.has(exec.jobName)) continue;
685
+ await this.executions.updateById(exec.id, { status: "pending" });
686
+ await this.dispatchToQueueSafe(exec.jobName, exec.id);
687
+ }
688
+ const staleIso = now.subtract(this.config.staleThreshold, "millisecond").toISOString();
689
+ const staleWhere = this.executions.createQueryWhere();
690
+ staleWhere.status = { eq: "pending" };
691
+ staleWhere.createdAt = { lte: staleIso };
692
+ const stale = await this.executions.findMany({
693
+ where: staleWhere,
706
694
  orderBy: {
707
695
  column: "priority",
708
696
  direction: "asc"
709
697
  }
710
698
  });
711
- for (const exec of stalePending) {
699
+ for (const exec of stale) {
712
700
  if (!this.jobs.has(exec.jobName)) continue;
713
- this.log.debug(`Recovery sweep: re-dispatching stale pending job ${exec.jobName} (${exec.id})`);
714
- await this.scheduleProcessing(exec.jobName, exec.id);
701
+ await this.dispatchToQueueSafe(exec.jobName, exec.id);
715
702
  }
716
703
  const runningWhere = this.executions.createQueryWhere();
717
704
  runningWhere.status = { eq: "running" };
718
705
  const running = await this.executions.findMany({ where: runningWhere });
719
706
  const nowMs = now.valueOf();
720
707
  for (const exec of running) {
721
- const registration = this.jobs.get(exec.jobName);
722
- if (!registration) continue;
708
+ const reg = this.jobs.get(exec.jobName);
709
+ if (!reg) continue;
723
710
  if (this.abortControllers.has(exec.id)) continue;
724
- const opts = registration.options;
725
- let crashThresholdMs;
726
- if (opts.timeout) crashThresholdMs = this.dt.duration(opts.timeout).as("milliseconds") * 2;
727
- else crashThresholdMs = this.config.recovery.runTimeout;
728
- const startedAt = exec.startedAt ? new Date(exec.startedAt).getTime() : 0;
729
- if (startedAt > 0 && nowMs - startedAt > crashThresholdMs) {
730
- this.log.warn(`Recovery sweep: marking crashed job ${exec.jobName} (${exec.id}) as failed`);
731
- const error = /* @__PURE__ */ new Error("Execution assumed crashed (recovered by sweep)");
732
- await this.handleFailure(exec.id, exec.jobName, error, "");
711
+ const crashThresholdMs = reg.options.timeout ? this.dt.duration(reg.options.timeout).as("milliseconds") * 2 : this.config.runTimeout;
712
+ const startedAtMs = exec.startedAt ? new Date(exec.startedAt).getTime() : 0;
713
+ if (startedAtMs > 0 && nowMs - startedAtMs > crashThresholdMs) {
714
+ this.log.warn(`Sweep: marking crashed ${exec.jobName} (${exec.id}) as failed`);
715
+ const err = /* @__PURE__ */ new Error("Execution assumed crashed (recovered by sweep)");
716
+ await this.handleFailure(exec.id, reg, exec.attempt, err, "");
733
717
  }
734
718
  }
719
+ await this.trimRingBuffers();
735
720
  } catch (e) {
736
- this.log.error("Recovery sweep failed", { error: e });
737
- } finally {
738
- await this.releaseLock("_alepha:jobs:recovery-lock");
721
+ this.log.error("Sweep failed", { error: e });
739
722
  }
740
723
  }
741
- /**
742
- * Delayed Dispatch Sweep (Section 5.2)
743
- *
744
- * Runs every `delayed.interval` (default: 30 seconds).
745
- * Scans for `scheduled` and `retrying` jobs where `scheduledAt <= now`,
746
- * moves them to `pending`, and dispatches to the queue layer.
747
- */
748
- async delayedDispatchSweep() {
749
- this.log.trace("Starting delayed dispatch sweep");
750
- if (this.stopping) return;
751
- if (!await this.tryLock("_alepha:jobs:dispatch-lock", 6e4)) return;
724
+ async dispatchToQueueSafe(jobName, executionId) {
752
725
  try {
753
- const now = this.dt.nowISOString();
754
- const where = this.executions.createQueryWhere();
755
- where.status = { inArray: ["scheduled", "retrying"] };
756
- where.scheduledAt = { lte: now };
757
- const ready = await this.executions.findMany({
758
- where,
759
- orderBy: {
760
- column: "priority",
761
- direction: "asc"
762
- }
763
- });
764
- for (const exec of ready) {
765
- if (!this.jobs.has(exec.jobName)) continue;
766
- await this.executions.updateById(exec.id, { status: "pending" });
767
- await this.scheduleProcessing(exec.jobName, exec.id);
768
- }
726
+ await this.dispatchToQueue(jobName, executionId);
769
727
  } catch (e) {
770
- this.log.error("Delayed dispatch sweep failed", { error: e });
771
- } finally {
772
- await this.releaseLock("_alepha:jobs:dispatch-lock");
728
+ this.log.warn(`Sweep failed to dispatch ${jobName} (${executionId})`, e);
773
729
  }
774
730
  }
775
731
  /**
776
- * Log Purge (Section 5.3)
777
- *
778
- * Runs daily at 03:00 via cron.
779
- * Deletes completed/dead/cancelled execution records older than `logRetentionDays`.
732
+ * Move a row from `scheduled` → `pending` and dispatch it.
733
+ * Used by the optimistic retry/delay timer. If the sweep has already moved
734
+ * the row, or another worker has claimed it, the UPDATE guard fails silently.
780
735
  */
781
- async logPurge() {
736
+ async dispatchScheduled(jobName, executionId) {
782
737
  if (this.stopping) return;
783
738
  try {
784
- const cutoff = this.dt.now().subtract(this.config.logRetentionDays, "day").toISOString();
785
- const where = this.executions.createQueryWhere();
786
- where.status = { inArray: [
787
- "completed",
788
- "dead",
789
- "cancelled"
790
- ] };
791
- where.completedAt = { lte: cutoff };
792
- const expiredIds = await this.executions.findMany({
793
- where,
794
- columns: ["id"]
739
+ await this.executions.updateOne({
740
+ id: { eq: executionId },
741
+ status: { eq: "scheduled" }
742
+ }, { status: "pending" });
743
+ await this.dispatchToQueueSafe(jobName, executionId);
744
+ } catch {}
745
+ }
746
+ async trimRingBuffers() {
747
+ for (const [jobName, reg] of this.jobs) {
748
+ const okLimit = reg.options.keep?.ok ?? this.config.keepLastSuccess;
749
+ const errLimit = reg.options.keep?.error ?? this.config.keepLastError;
750
+ if (okLimit > 0) await this.trimByStatus(jobName, "ok", okLimit);
751
+ if (errLimit > 0) await this.trimByStatus(jobName, "error", errLimit);
752
+ }
753
+ }
754
+ async trimByStatus(jobName, status, keep) {
755
+ try {
756
+ const rows = await this.executions.findMany({
757
+ where: {
758
+ jobName: { eq: jobName },
759
+ status: { eq: status }
760
+ },
761
+ orderBy: {
762
+ column: "startedAt",
763
+ direction: "desc"
764
+ },
765
+ limit: keep + 50
795
766
  });
796
- if (expiredIds.length > 0) {
797
- const ids = expiredIds.map((e) => e.id);
798
- await this.executionLogs.deleteMany({ id: { inArray: ids } });
799
- await this.executions.deleteMany({ id: { inArray: ids } });
800
- this.log.info(`Log purge: deleted ${ids.length} old execution records`);
767
+ if (rows.length <= keep) return;
768
+ const toDelete = rows.slice(keep).map((r) => r.id);
769
+ if (toDelete.length > 0) {
770
+ await this.executions.deleteMany({ id: { inArray: toDelete } });
771
+ this.log.debug(`Trimmed ${toDelete.length} ${status} rows for '${jobName}'`);
801
772
  }
802
773
  } catch (e) {
803
- this.log.error("Log purge failed", { error: e });
774
+ this.log.warn(`Failed to trim ${status} rows for '${jobName}'`, e);
804
775
  }
805
776
  }
806
- pauseJob(name) {
807
- this.getRegistration(name);
808
- this.pausedJobs.add(name);
809
- this.log.info(`Paused job '${name}'`);
810
- }
811
- async resumeJob(name) {
812
- this.getRegistration(name);
813
- this.pausedJobs.delete(name);
814
- this.log.info(`Resumed job '${name}'`);
815
- const pending = await this.executions.findMany({
816
- where: {
817
- jobName: { eq: name },
818
- status: { eq: "pending" }
819
- },
820
- orderBy: {
821
- column: "priority",
822
- direction: "asc"
823
- }
824
- });
825
- for (const exec of pending) await this.scheduleProcessing(name, exec.id);
826
- }
827
- isJobPaused(name) {
828
- return this.pausedJobs.has(name);
829
- }
830
- getPausedJobs() {
831
- return [...this.pausedJobs];
832
- }
833
- async tryLock(key, ttlMs) {
834
- const lockValue = `${this.workerId},${this.dt.nowISOString()}`;
835
- const [lockId] = (await this.lockProvider.set(key, lockValue, true, ttlMs)).split(",");
836
- return lockId === this.workerId;
837
- }
838
- async releaseLock(key) {
839
- await this.lockProvider.del(key);
840
- }
841
777
  onStart = $hook({
842
778
  on: "start",
843
779
  handler: async () => {
844
- this.workerId = crypto.randomUUID().slice(0, 12);
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.`);
845
781
  this.log.info(`Job system OK`, {
846
- workerId: this.workerId,
847
- dispatch: this.queueDispatch ? "queue" : "inline"
782
+ dispatch: this.queueDispatch ? "queue" : "inline-only",
783
+ jobs: this.jobs.size
848
784
  });
849
785
  this.alepha.events.on("log", ({ entry }) => {
850
786
  const ctx = entry.context;
851
787
  if (!ctx) return;
852
- const entries = this.logs.get(ctx);
788
+ const entries = this.perExecutionLogs.get(ctx);
853
789
  if (!entries) return;
854
790
  entries.push(entry);
855
791
  });
856
- if (!this.alepha.isServerless()) {
857
- await this.delayedDispatchSweep();
858
- await this.recoverySweep();
859
- }
860
- this.cronProvider.createCronJob("_alepha:jobs:recovery", JobProvider.SWEEP_CRON, async () => {
861
- await this.recoverySweep();
862
- }, true);
863
- this.cronProvider.createCronJob("_alepha:jobs:dispatch", JobProvider.SWEEP_CRON, async () => {
864
- await this.delayedDispatchSweep();
865
- }, true);
866
- this.cronProvider.createCronJob("_alepha:jobs:log-purge", "0 0 * * *", async () => {
867
- await this.logPurge();
792
+ if (!this.alepha.isServerless()) await this.sweep();
793
+ this.cronProvider.createCronJob("api:jobs:sweep", SWEEP_CRON, async () => {
794
+ await this.sweep();
868
795
  }, true);
869
796
  }
870
797
  });
@@ -891,7 +818,12 @@ var JobProvider = class JobProvider {
891
818
  //#endregion
892
819
  //#region ../../src/api/jobs/primitives/$job.ts
893
820
  /**
894
- * Job primitive for defining scheduled and on-demand tasks with payload validation and retry policies.
821
+ * Job primitive for defining scheduled (cron) or queued (push) tasks.
822
+ *
823
+ * A job must be either **cron-only** (pass `cron`) or **queue-only**
824
+ * (pass `schema`), never both. To run scheduled work that processes
825
+ * payloads, compose two jobs: a cron that pushes payloads, and a
826
+ * queue job that handles them.
895
827
  */
896
828
  const $job = (options) => {
897
829
  return createPrimitive(JobPrimitive, options);
@@ -899,7 +831,7 @@ const $job = (options) => {
899
831
  var JobPrimitive = class extends PipelinePrimitive {
900
832
  jobProvider = $inject(JobProvider);
901
833
  get name() {
902
- return `${this.config.service.name}.${this.config.propertyKey}`;
834
+ return this.options.name ?? `${this.config.service.name}.${this.config.propertyKey}`;
903
835
  }
904
836
  onInit() {
905
837
  const handler = this.handler.run.bind(this.handler);
@@ -909,173 +841,148 @@ var JobPrimitive = class extends PipelinePrimitive {
909
841
  });
910
842
  }
911
843
  /**
912
- * Push a single payload or an array of payloads.
844
+ * Push a single payload to the queue (queue-mode only).
913
845
  */
914
846
  async push(payload, options) {
915
- if (Array.isArray(payload)) return await Promise.all(payload.map((p) => this.jobProvider.push(this.name, p, options)));
916
847
  return this.jobProvider.push(this.name, payload, options);
917
848
  }
918
849
  /**
919
- * Push multiple payloads with per-item options.
850
+ * Push multiple payloads at once (queue-mode only).
851
+ * Batched INSERT + batched queue send when supported.
920
852
  */
921
853
  async pushMany(items) {
922
854
  return this.jobProvider.pushMany(this.name, items);
923
855
  }
924
856
  /**
925
- * Cancel a running or pending execution.
857
+ * Cancel a pending or running execution.
926
858
  */
927
859
  async cancel(executionId) {
928
860
  return this.jobProvider.cancel(executionId);
929
861
  }
930
862
  /**
931
- * Manually trigger the job (admin / CLI).
863
+ * Manually fire a cron-mode job, or trigger a queue-mode job with an explicit payload.
932
864
  */
933
865
  async trigger(context) {
934
866
  return this.jobProvider.trigger(this.name, context);
935
867
  }
936
- /**
937
- * Pause this job. Pushed items are still accepted but processing is held.
938
- */
939
- pause() {
940
- this.jobProvider.pauseJob(this.name);
941
- }
942
- /**
943
- * Resume a paused job and dispatch any pending items.
944
- */
945
- async resume() {
946
- return this.jobProvider.resumeJob(this.name);
947
- }
948
- /**
949
- * Whether this job is currently paused.
950
- */
951
- get paused() {
952
- return this.jobProvider.isJobPaused(this.name);
953
- }
954
868
  };
955
869
  $job[KIND] = JobPrimitive;
956
870
  //#endregion
957
871
  //#region ../../src/api/jobs/services/JobService.ts
872
+ /**
873
+ * Admin surface for the job system.
874
+ *
875
+ * Six methods: list jobs, list executions, get execution,
876
+ * trigger, retry, cancel. Everything else lives in events — any
877
+ * analytics/observability is an external concern that subscribes
878
+ * to `job:begin` / `job:success` / `job:error`.
879
+ */
958
880
  var JobService = class {
959
881
  alepha = $inject(Alepha);
960
- dt = $inject(DateTimeProvider);
961
882
  log = $logger();
962
883
  jobProvider = $inject(JobProvider);
963
- database = $inject(DatabaseProvider);
964
884
  executions = $repository(jobExecutionEntity);
965
- executionLogs = $repository(jobExecutionLogEntity);
966
885
  computeCan(status) {
967
886
  return {
968
- retry: status === "dead" || status === "cancelled",
969
- cancel: status === "pending" || status === "running" || status === "scheduled" || status === "retrying"
887
+ retry: status === "error" || status === "cancelled",
888
+ cancel: status === "pending" || status === "running" || status === "scheduled"
970
889
  };
971
890
  }
972
891
  /**
973
- * Convert an ISO date string to the raw SQL parameter format
974
- * expected by the current database dialect.
975
- *
976
- * - PostgreSQL: ISO string (timestamp comparison)
977
- * - SQLite: epoch milliseconds (integer comparison)
892
+ * List every registered job with recent ok/error counts and lastRun.
893
+ * One aggregate query covers all jobs.
978
894
  */
979
- toRawDate(iso) {
980
- return this.database.dialect === "sqlite" ? new Date(iso).getTime() : iso;
981
- }
982
- async getStats(days) {
983
- const jobs = this.jobProvider.getRegisteredJobs();
984
- const periodAgo = this.toRawDate(this.dt.now().subtract(days ?? 1, "day").toISOString());
985
- const row = (await this.executions.query((e) => sql`
895
+ async listJobs() {
896
+ const registry = this.jobProvider.getRegisteredJobs();
897
+ const aggRows = await this.executions.query((e) => sql`
986
898
  SELECT
987
- COUNT(*) FILTER (WHERE ${e.status} = 'running') AS running,
988
- COUNT(*) FILTER (WHERE ${e.status} = 'pending') AS pending,
989
- COUNT(*) FILTER (WHERE ${e.status} = 'scheduled') AS scheduled,
990
- COUNT(*) FILTER (WHERE ${e.status} = 'retrying') AS retrying,
991
- COUNT(*) FILTER (WHERE ${e.status} = 'dead') AS dead,
992
- COUNT(*) FILTER (WHERE ${e.status} = 'completed' AND ${e.completedAt} >= ${periodAgo}) AS completed_24h,
993
- COUNT(*) FILTER (WHERE ${e.status} = 'dead' AND ${e.completedAt} >= ${periodAgo}) AS failed_24h
899
+ ${e.jobName} AS job_name,
900
+ ${e.status} AS status,
901
+ COUNT(*) AS count,
902
+ MAX(${e.completedAt}) AS last_run
994
903
  FROM ${e}
904
+ WHERE ${e.status} IN ('ok', 'error')
905
+ GROUP BY ${e.jobName}, ${e.status}
995
906
  `, t.object({
996
- running: t.string(),
997
- pending: t.string(),
998
- scheduled: t.string(),
999
- retrying: t.string(),
1000
- dead: t.string(),
1001
- completed_24h: t.string(),
1002
- failed_24h: t.string()
1003
- })))[0];
1004
- return {
1005
- registered: jobs.size,
1006
- running: Number(row.running),
1007
- pending: Number(row.pending),
1008
- scheduled: Number(row.scheduled),
1009
- retrying: Number(row.retrying),
1010
- dead: Number(row.dead),
1011
- completed: Number(row.completed_24h),
1012
- failed: Number(row.failed_24h)
907
+ job_name: t.string(),
908
+ status: t.string(),
909
+ count: t.string(),
910
+ last_run: t.optional(t.nullable(t.union([t.string(), t.number()])))
911
+ }));
912
+ const toIso = (v) => {
913
+ if (v === null || v === void 0) return void 0;
914
+ if (typeof v === "number") return new Date(v).toISOString();
915
+ return v;
1013
916
  };
1014
- }
1015
- getRegistry() {
1016
- const jobs = this.jobProvider.getRegisteredJobs();
917
+ const byJob = /* @__PURE__ */ new Map();
918
+ for (const row of aggRows) {
919
+ const entry = byJob.get(row.job_name) ?? {
920
+ ok: 0,
921
+ error: 0
922
+ };
923
+ if (row.status === "ok") entry.ok = Number(row.count);
924
+ if (row.status === "error") entry.error = Number(row.count);
925
+ const iso = toIso(row.last_run);
926
+ if (iso && (!entry.lastRun || iso > entry.lastRun)) entry.lastRun = iso;
927
+ byJob.set(row.job_name, entry);
928
+ }
1017
929
  const result = [];
1018
- for (const [name, reg] of jobs) {
930
+ for (const [name, reg] of registry) {
1019
931
  const opts = reg.options;
1020
- const hasCron = Boolean(opts.cron);
1021
- const hasSchema = Boolean(opts.schema);
1022
- let type;
1023
- if (hasCron && hasSchema) type = "both";
1024
- else if (hasCron) type = "cron";
1025
- else type = "push";
1026
- const registration = {
932
+ const counts = byJob.get(name) ?? {
933
+ ok: 0,
934
+ error: 0
935
+ };
936
+ result.push({
1027
937
  name,
1028
- type,
1029
- priority: opts.priority ?? "normal",
1030
- concurrency: opts.concurrency ?? 1,
1031
- hasSchema,
938
+ description: opts.description,
939
+ type: reg.type,
1032
940
  cron: opts.cron,
941
+ priority: opts.priority ?? "normal",
1033
942
  timeout: opts.timeout ? String(opts.timeout) : void 0,
1034
943
  retry: opts.retry ? {
1035
944
  retries: opts.retry.retries,
1036
945
  hasBackoff: Boolean(opts.retry.backoff)
1037
946
  } : void 0,
1038
- paused: this.jobProvider.isJobPaused(name)
1039
- };
1040
- result.push(registration);
947
+ recent: counts
948
+ });
1041
949
  }
1042
950
  return result;
1043
951
  }
1044
- async findExecutions(query = {}) {
1045
- query.sort ??= "-createdAt";
952
+ /**
953
+ * Recent executions for a single job, ORDER BY startedAt DESC.
954
+ */
955
+ async getExecutions(jobName, query = {}) {
956
+ if (!this.jobProvider.getRegisteredJobs().has(jobName)) throw new NotFoundError(`Job not found: ${jobName}`);
1046
957
  const where = this.executions.createQueryWhere();
1047
- if (query.job) where.jobName = { eq: query.job };
958
+ where.jobName = { eq: jobName };
1048
959
  if (query.status) where.status = { eq: query.status };
1049
- if (query.priority) where.priority = { eq: {
1050
- critical: 0,
1051
- high: 1,
1052
- normal: 2,
1053
- low: 3
1054
- }[query.priority] };
1055
- if (query.from) where.createdAt = { gte: query.from };
1056
- if (query.to) where.createdAt = {
1057
- ...where.createdAt,
1058
- lte: query.to
1059
- };
1060
- const page = await this.executions.paginate(query, { where }, { count: true });
1061
- return {
1062
- ...page,
1063
- content: page.content.map((exec) => ({
1064
- ...exec,
1065
- can: this.computeCan(exec.status)
1066
- }))
1067
- };
960
+ return (await this.executions.findMany({
961
+ where,
962
+ orderBy: {
963
+ column: "startedAt",
964
+ direction: "desc"
965
+ },
966
+ limit: query.limit ?? 20
967
+ })).map((row) => ({
968
+ ...row,
969
+ can: this.computeCan(row.status)
970
+ }));
1068
971
  }
972
+ /**
973
+ * Full execution detail (includes captured logs).
974
+ */
1069
975
  async getExecution(id) {
1070
976
  const execution = await this.executions.findById(id);
1071
977
  if (!execution) throw new NotFoundError(`Execution not found: ${id}`);
1072
- const logRecord = await this.executionLogs.findById(id);
1073
978
  return {
1074
979
  ...execution,
1075
- can: this.computeCan(execution.status),
1076
- logs: logRecord?.logs
980
+ can: this.computeCan(execution.status)
1077
981
  };
1078
982
  }
983
+ /**
984
+ * Manual trigger (cron jobs) or push-with-payload (queue jobs).
985
+ */
1079
986
  async triggerJob(name, context) {
1080
987
  const job = this.alepha.primitives($job).find((j) => j.name === name);
1081
988
  if (!job) throw new NotFoundError(`Job not found: ${name}`);
@@ -1083,18 +990,21 @@ var JobService = class {
1083
990
  await job.trigger(context);
1084
991
  return { ok: true };
1085
992
  }
993
+ /**
994
+ * Retry a dead or cancelled execution by re-pushing with the original payload.
995
+ */
1086
996
  async retryExecution(id, context) {
1087
997
  const execution = await this.executions.findById(id);
1088
998
  if (!execution) throw new NotFoundError(`Execution not found: ${id}`);
1089
- if (execution.status !== "dead" && execution.status !== "cancelled") throw new AlephaError(`Cannot retry execution in '${execution.status}' status`);
999
+ if (execution.status !== "error" && execution.status !== "cancelled") throw new AlephaError(`Cannot retry execution in '${execution.status}' status`);
1000
+ const job = this.alepha.primitives($job).find((j) => j.name === execution.jobName);
1001
+ if (!job) throw new NotFoundError(`Job not found: ${execution.jobName}`);
1090
1002
  this.log.info(`Retrying execution ${id}`, {
1091
1003
  jobName: execution.jobName,
1092
1004
  previousStatus: execution.status,
1093
1005
  triggeredBy: context?.triggeredByName ?? context?.triggeredBy
1094
1006
  });
1095
- const job = this.alepha.primitives($job).find((j) => j.name === execution.jobName);
1096
- if (!job) throw new NotFoundError(`Job not found: ${execution.jobName}`);
1097
- if (execution.payload) await job.push(execution.payload, {});
1007
+ if (execution.payload) await job.push(execution.payload);
1098
1008
  else await job.trigger({
1099
1009
  triggeredBy: context?.triggeredBy,
1100
1010
  triggeredByName: context?.triggeredByName
@@ -1109,302 +1019,61 @@ var JobService = class {
1109
1019
  });
1110
1020
  return { ok: true };
1111
1021
  }
1112
- pauseJob(name, context) {
1113
- const job = this.alepha.primitives($job).find((j) => j.name === name);
1114
- if (!job) throw new NotFoundError(`Job not found: ${name}`);
1115
- this.log.info(`Pausing job '${name}'`, { pausedBy: context?.pausedByName ?? context?.pausedBy });
1116
- job.pause();
1117
- return { ok: true };
1118
- }
1119
- async resumeJob(name, context) {
1120
- const job = this.alepha.primitives($job).find((j) => j.name === name);
1121
- if (!job) throw new NotFoundError(`Job not found: ${name}`);
1122
- this.log.info(`Resuming job '${name}'`, { resumedBy: context?.resumedByName ?? context?.resumedBy });
1123
- await job.resume();
1124
- return { ok: true };
1125
- }
1126
- getPausedJobs() {
1127
- return this.jobProvider.getPausedJobs();
1128
- }
1129
- async getCronJobs() {
1130
- const jobs = this.jobProvider.getRegisteredJobs();
1131
- const cronJobNames = [];
1132
- for (const [name, reg] of jobs) if (reg.options.cron) cronJobNames.push(name);
1133
- const lastByJob = await this.getLastExecutionPerJob(cronJobNames);
1134
- const result = [];
1135
- for (const name of cronJobNames) {
1136
- const opts = jobs.get(name).options;
1137
- const last = lastByJob.get(name);
1138
- result.push({
1139
- name,
1140
- cron: opts.cron,
1141
- lock: opts.lock !== false,
1142
- priority: opts.priority ?? "normal",
1143
- concurrency: opts.concurrency ?? 1,
1144
- hasSchema: Boolean(opts.schema),
1145
- paused: this.jobProvider.isJobPaused(name),
1146
- lastExecution: last ? {
1147
- id: last.id,
1148
- status: last.status,
1149
- startedAt: last.started_at ?? void 0,
1150
- completedAt: last.completed_at ?? void 0,
1151
- error: last.error ?? void 0
1152
- } : void 0
1153
- });
1154
- }
1155
- return result;
1156
- }
1157
- async getQueueDepth() {
1158
- const jobs = this.jobProvider.getRegisteredJobs();
1159
- const rows = await this.executions.query((e) => sql`
1160
- SELECT
1161
- ${e.jobName} AS job_name,
1162
- COUNT(*) FILTER (WHERE ${e.status} = 'pending') AS pending,
1163
- COUNT(*) FILTER (WHERE ${e.status} = 'running') AS running,
1164
- COUNT(*) FILTER (WHERE ${e.status} = 'scheduled') AS scheduled,
1165
- COUNT(*) FILTER (WHERE ${e.status} = 'retrying') AS retrying,
1166
- COUNT(*) FILTER (WHERE ${e.status} = 'dead') AS dead
1167
- FROM ${e}
1168
- WHERE ${e.status} IN ('pending', 'running', 'scheduled', 'retrying', 'dead')
1169
- GROUP BY ${e.jobName}
1170
- `, t.object({
1171
- job_name: t.string(),
1172
- pending: t.string(),
1173
- running: t.string(),
1174
- scheduled: t.string(),
1175
- retrying: t.string(),
1176
- dead: t.string()
1177
- }));
1178
- const counts = new Map(rows.map((r) => [r.job_name, r]));
1179
- const result = [];
1180
- for (const [name, reg] of jobs) {
1181
- const row = counts.get(name);
1182
- result.push({
1183
- jobName: name,
1184
- pending: Number(row?.pending ?? 0),
1185
- running: Number(row?.running ?? 0),
1186
- scheduled: Number(row?.scheduled ?? 0),
1187
- retrying: Number(row?.retrying ?? 0),
1188
- dead: Number(row?.dead ?? 0),
1189
- concurrency: reg.options.concurrency ?? 1,
1190
- paused: this.jobProvider.isJobPaused(name)
1191
- });
1192
- }
1193
- return result;
1194
- }
1195
- async getActivity(days = 14) {
1196
- if (this.database.dialect === "sqlite") return this.getActivitySqlite(days);
1197
- return (await this.executions.query((e) => sql`
1198
- WITH date_series AS (
1199
- SELECT generate_series(
1200
- CURRENT_DATE - ${days - 1}::int,
1201
- CURRENT_DATE,
1202
- '1 day'::interval
1203
- )::date AS date
1204
- )
1205
- SELECT
1206
- ds.date::text AS date,
1207
- COALESCE(COUNT(*) FILTER (WHERE ${e.status} = 'completed'), 0) AS completed,
1208
- COALESCE(COUNT(*) FILTER (WHERE ${e.status} = 'dead'), 0) AS failed
1209
- FROM date_series ds
1210
- LEFT JOIN ${e} ON DATE(${e.completedAt}) = ds.date
1211
- AND ${e.status} IN ('completed', 'dead')
1212
- GROUP BY ds.date
1213
- ORDER BY ds.date ASC
1214
- `, t.object({
1215
- date: t.string(),
1216
- completed: t.string(),
1217
- failed: t.string()
1218
- }))).map((row) => ({
1219
- date: row.date,
1220
- completed: Number(row.completed),
1221
- failed: Number(row.failed)
1222
- }));
1223
- }
1224
- async getActivitySqlite(days = 14) {
1225
- const startDate = this.dt.now().subtract(days - 1, "day");
1226
- const where = this.executions.createQueryWhere();
1227
- where.status = { inArray: ["completed", "dead"] };
1228
- where.completedAt = { gte: startDate.startOf("day").toISOString() };
1229
- const executions = await this.executions.findMany({ where });
1230
- const byDate = /* @__PURE__ */ new Map();
1231
- for (let i = 0; i < days; i++) {
1232
- const date = startDate.add(i, "day").format("YYYY-MM-DD");
1233
- byDate.set(date, {
1234
- completed: 0,
1235
- failed: 0
1236
- });
1237
- }
1238
- for (const exec of executions) {
1239
- if (!exec.completedAt) continue;
1240
- const date = this.dt.of(exec.completedAt).format("YYYY-MM-DD");
1241
- const entry = byDate.get(date);
1242
- if (!entry) continue;
1243
- if (exec.status === "completed") entry.completed++;
1244
- else entry.failed++;
1245
- }
1246
- return [...byDate.entries()].map(([date, counts]) => ({
1247
- date,
1248
- ...counts
1249
- }));
1250
- }
1251
- async getTopFailures(days) {
1252
- const periodAgoIso = this.dt.now().subtract(days ?? 7, "day").toISOString();
1253
- if (this.database.dialect === "sqlite") return this.getTopFailuresSqlite(periodAgoIso);
1254
- return (await this.executions.query((e) => sql`
1255
- SELECT
1256
- ${e.jobName} AS job_name,
1257
- COUNT(*) AS failures,
1258
- (ARRAY_AGG(${e.error} ORDER BY ${e.completedAt} DESC))[1] AS last_error
1259
- FROM ${e}
1260
- WHERE ${e.status} = 'dead'
1261
- AND ${e.completedAt} >= ${periodAgoIso}
1262
- GROUP BY ${e.jobName}
1263
- ORDER BY failures DESC
1264
- `, t.object({
1265
- job_name: t.string(),
1266
- failures: t.string(),
1267
- last_error: t.optional(t.nullable(t.string()))
1268
- }))).map((row) => ({
1269
- jobName: row.job_name,
1270
- failures: Number(row.failures),
1271
- lastError: row.last_error ?? void 0
1272
- }));
1273
- }
1274
- async getTopFailuresSqlite(periodAgoIso) {
1275
- const where = this.executions.createQueryWhere();
1276
- where.status = { eq: "dead" };
1277
- where.completedAt = { gte: periodAgoIso };
1278
- const failures = await this.executions.findMany({
1279
- where,
1280
- orderBy: {
1281
- column: "completedAt",
1282
- direction: "desc"
1283
- }
1284
- });
1285
- const byJob = /* @__PURE__ */ new Map();
1286
- for (const exec of failures) {
1287
- const entry = byJob.get(exec.jobName) ?? { failures: 0 };
1288
- entry.failures++;
1289
- if (!entry.lastError) entry.lastError = exec.error ?? void 0;
1290
- byJob.set(exec.jobName, entry);
1291
- }
1292
- return [...byJob.entries()].map(([jobName, data]) => ({
1293
- jobName,
1294
- failures: data.failures,
1295
- lastError: data.lastError
1296
- })).sort((a, b) => b.failures - a.failures);
1297
- }
1298
- /**
1299
- * Fetch the most recent execution per job name.
1300
- *
1301
- * - PostgreSQL: uses `DISTINCT ON` for a single-pass query
1302
- * - SQLite: uses ORM queries (one per job name) since `DISTINCT ON` is not supported
1303
- */
1304
- async getLastExecutionPerJob(jobNames) {
1305
- if (jobNames.length === 0) return /* @__PURE__ */ new Map();
1306
- if (this.database.dialect === "sqlite") {
1307
- const result = /* @__PURE__ */ new Map();
1308
- for (const name of jobNames) {
1309
- const rows = await this.executions.findMany({
1310
- where: { jobName: { eq: name } },
1311
- orderBy: {
1312
- column: "createdAt",
1313
- direction: "desc"
1314
- },
1315
- limit: 1
1316
- });
1317
- if (rows[0]) result.set(name, {
1318
- id: rows[0].id,
1319
- job_name: rows[0].jobName,
1320
- status: rows[0].status,
1321
- started_at: rows[0].startedAt,
1322
- completed_at: rows[0].completedAt,
1323
- error: rows[0].error
1324
- });
1325
- }
1326
- return result;
1327
- }
1328
- const schema = t.object({
1329
- id: t.string(),
1330
- job_name: t.string(),
1331
- status: t.string(),
1332
- started_at: t.optional(t.nullable(t.string())),
1333
- completed_at: t.optional(t.nullable(t.string())),
1334
- error: t.optional(t.nullable(t.string()))
1335
- });
1336
- const rows = await this.executions.query((e) => sql`
1337
- SELECT DISTINCT ON (${e.jobName})
1338
- ${e.id}, ${e.jobName} AS job_name, ${e.status},
1339
- ${e.startedAt} AS started_at, ${e.completedAt} AS completed_at, ${e.error}
1340
- FROM ${e}
1341
- WHERE ${e.jobName} IN (${sql.join(jobNames.map((n) => sql`${n}`), sql`, `)})
1342
- ORDER BY ${e.jobName}, ${e.createdAt} DESC
1343
- `, schema);
1344
- return new Map(rows.map((r) => [r.job_name, r]));
1345
- }
1346
1022
  };
1347
1023
  //#endregion
1348
1024
  //#region ../../src/api/jobs/controllers/AdminJobController.ts
1025
+ /**
1026
+ * Minimal admin surface for the job system. Six endpoints.
1027
+ */
1349
1028
  var AdminJobController = class {
1350
1029
  url = "/jobs";
1351
1030
  group = "admin:jobs";
1352
1031
  jobService = $inject(JobService);
1353
- getJobStats = $action({
1354
- path: `${this.url}/stats`,
1355
- group: this.group,
1356
- use: [$secure({ permissions: ["admin:job:read"] })],
1357
- schema: {
1358
- query: jobActivityQuerySchema,
1359
- response: jobStatsSchema
1360
- },
1361
- handler: ({ query }) => this.jobService.getStats(query.days)
1362
- });
1363
- getJobRegistry = $action({
1032
+ listJobs = $action({
1364
1033
  path: this.url,
1365
1034
  group: this.group,
1366
1035
  use: [$secure({ permissions: ["admin:job:read"] })],
1367
1036
  schema: { response: t.array(jobRegistrationSchema) },
1368
- handler: () => this.jobService.getRegistry()
1037
+ handler: () => this.jobService.listJobs()
1369
1038
  });
1370
- findJobExecutions = $action({
1371
- path: `${this.url}/executions`,
1039
+ listExecutions = $action({
1040
+ path: `${this.url}/:name/executions`,
1372
1041
  group: this.group,
1373
1042
  use: [$secure({ permissions: ["admin:job:read"] })],
1374
1043
  schema: {
1044
+ params: t.object({ name: t.text() }),
1375
1045
  query: jobExecutionQuerySchema,
1376
- response: t.page(jobExecutionResourceSchema)
1046
+ response: t.array(jobExecutionResourceSchema)
1377
1047
  },
1378
- handler: ({ query }) => this.jobService.findExecutions(query)
1048
+ handler: ({ params, query }) => this.jobService.getExecutions(params.name, query)
1379
1049
  });
1380
- getJobExecution = $action({
1050
+ getExecution = $action({
1381
1051
  path: `${this.url}/executions/:id`,
1382
1052
  group: this.group,
1383
1053
  use: [$secure({ permissions: ["admin:job:read"] })],
1384
1054
  schema: {
1385
1055
  params: t.object({ id: t.uuid() }),
1386
- response: jobExecutionDetailResourceSchema
1056
+ response: jobExecutionResourceSchema
1387
1057
  },
1388
1058
  handler: ({ params }) => this.jobService.getExecution(params.id)
1389
1059
  });
1390
1060
  triggerJob = $action({
1391
1061
  method: "POST",
1392
- path: `${this.url}/trigger`,
1062
+ path: `${this.url}/:name/trigger`,
1393
1063
  group: this.group,
1394
1064
  use: [$secure({ permissions: ["admin:job:trigger"] })],
1395
1065
  schema: {
1066
+ params: t.object({ name: t.text() }),
1396
1067
  body: triggerJobSchema,
1397
1068
  response: okSchema
1398
1069
  },
1399
- handler: async ({ body, user }) => {
1400
- return this.jobService.triggerJob(body.name, {
1401
- payload: body.payload,
1402
- triggeredBy: user?.id,
1403
- triggeredByName: user?.name
1404
- });
1405
- }
1070
+ handler: ({ params, body, user }) => this.jobService.triggerJob(params.name, {
1071
+ payload: body.payload,
1072
+ triggeredBy: user?.id,
1073
+ triggeredByName: user?.name
1074
+ })
1406
1075
  });
1407
- retryJobExecution = $action({
1076
+ retryExecution = $action({
1408
1077
  method: "POST",
1409
1078
  path: `${this.url}/executions/:id/retry`,
1410
1079
  group: this.group,
@@ -1413,14 +1082,12 @@ var AdminJobController = class {
1413
1082
  params: t.object({ id: t.uuid() }),
1414
1083
  response: okSchema
1415
1084
  },
1416
- handler: async ({ params, user }) => {
1417
- return this.jobService.retryExecution(params.id, {
1418
- triggeredBy: user?.id,
1419
- triggeredByName: user?.name
1420
- });
1421
- }
1085
+ handler: ({ params, user }) => this.jobService.retryExecution(params.id, {
1086
+ triggeredBy: user?.id,
1087
+ triggeredByName: user?.name
1088
+ })
1422
1089
  });
1423
- cancelJobExecution = $action({
1090
+ cancelExecution = $action({
1424
1091
  method: "POST",
1425
1092
  path: `${this.url}/executions/:id/cancel`,
1426
1093
  group: this.group,
@@ -1429,101 +1096,25 @@ var AdminJobController = class {
1429
1096
  params: t.object({ id: t.uuid() }),
1430
1097
  response: okSchema
1431
1098
  },
1432
- handler: async ({ params, user }) => {
1433
- return this.jobService.cancelExecution(params.id, {
1434
- cancelledBy: user?.id,
1435
- cancelledByName: user?.name
1436
- });
1437
- }
1438
- });
1439
- getJobActivity = $action({
1440
- path: `${this.url}/activity`,
1441
- group: this.group,
1442
- use: [$secure({ permissions: ["admin:job:read"] })],
1443
- schema: {
1444
- query: jobActivityQuerySchema,
1445
- response: t.array(jobActivityPointSchema)
1446
- },
1447
- handler: ({ query }) => this.jobService.getActivity(query.days)
1448
- });
1449
- getCronJobs = $action({
1450
- path: `${this.url}/cron`,
1451
- group: this.group,
1452
- use: [$secure({ permissions: ["admin:job:read"] })],
1453
- schema: { response: t.array(jobCronInfoSchema) },
1454
- handler: () => this.jobService.getCronJobs()
1455
- });
1456
- getJobQueueDepth = $action({
1457
- path: `${this.url}/queue`,
1458
- group: this.group,
1459
- use: [$secure({ permissions: ["admin:job:read"] })],
1460
- schema: { response: t.array(jobQueueDepthSchema) },
1461
- handler: () => this.jobService.getQueueDepth()
1462
- });
1463
- getJobTopFailures = $action({
1464
- path: `${this.url}/failures`,
1465
- group: this.group,
1466
- use: [$secure({ permissions: ["admin:job:read"] })],
1467
- schema: {
1468
- query: jobActivityQuerySchema,
1469
- response: t.array(jobFailureSchema)
1470
- },
1471
- handler: ({ query }) => this.jobService.getTopFailures(query.days)
1472
- });
1473
- pauseJob = $action({
1474
- method: "POST",
1475
- path: `${this.url}/pause`,
1476
- group: this.group,
1477
- use: [$secure({ permissions: ["admin:job:trigger"] })],
1478
- schema: {
1479
- body: t.object({ name: t.text() }),
1480
- response: okSchema
1481
- },
1482
- handler: ({ body, user }) => {
1483
- return this.jobService.pauseJob(body.name, {
1484
- pausedBy: user?.id,
1485
- pausedByName: user?.name
1486
- });
1487
- }
1488
- });
1489
- resumeJob = $action({
1490
- method: "POST",
1491
- path: `${this.url}/resume`,
1492
- group: this.group,
1493
- use: [$secure({ permissions: ["admin:job:trigger"] })],
1494
- schema: {
1495
- body: t.object({ name: t.text() }),
1496
- response: okSchema
1497
- },
1498
- handler: async ({ body, user }) => {
1499
- return this.jobService.resumeJob(body.name, {
1500
- resumedBy: user?.id,
1501
- resumedByName: user?.name
1502
- });
1503
- }
1504
- });
1505
- getPausedJobs = $action({
1506
- path: `${this.url}/paused`,
1507
- group: this.group,
1508
- use: [$secure({ permissions: ["admin:job:read"] })],
1509
- schema: { response: t.array(t.text()) },
1510
- handler: () => this.jobService.getPausedJobs()
1099
+ handler: ({ params, user }) => this.jobService.cancelExecution(params.id, {
1100
+ cancelledBy: user?.id,
1101
+ cancelledByName: user?.name
1102
+ })
1511
1103
  });
1512
1104
  };
1513
1105
  //#endregion
1514
1106
  //#region ../../src/api/jobs/providers/JobQueueProvider.ts
1515
1107
  /**
1516
- * Optional queue-backed dispatch for the job system.
1108
+ * Plumbs outbox-style dispatch through `AlephaQueue`.
1517
1109
  *
1518
- * When registered, `JobProvider` will push work through this queue instead of
1519
- * executing inline. This is the default for long-running (non-serverless) environments.
1520
- * In serverless environments (Cloudflare Workers, Vercel), this provider is typically
1521
- * omitted so jobs execute inline without requiring an external queue resource.
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.
1522
1113
  */
1523
1114
  var JobQueueProvider = class {
1524
1115
  jobProvider = $inject(JobProvider);
1525
1116
  queue = $queue({
1526
- name: "_alepha:jobs:dispatch",
1117
+ name: "api:jobs:dispatch",
1527
1118
  schema: t.object({
1528
1119
  jobName: t.text(),
1529
1120
  executionId: t.text()
@@ -1532,15 +1123,12 @@ var JobQueueProvider = class {
1532
1123
  await this.jobProvider.processExecution(msg.payload.jobName, msg.payload.executionId);
1533
1124
  }
1534
1125
  });
1535
- onStart = $hook({
1536
- on: "start",
1537
- handler: async () => {
1538
- this.jobProvider.queueDispatch = (jobName, executionId) => this.push(jobName, executionId);
1539
- }
1540
- });
1541
- /**
1542
- * Push a job execution onto the queue for async processing.
1543
- */
1126
+ constructor() {
1127
+ this.wireDispatcher();
1128
+ }
1129
+ wireDispatcher() {
1130
+ this.jobProvider.queueDispatch = (jobName, executionId) => this.push(jobName, executionId);
1131
+ }
1544
1132
  async push(jobName, executionId) {
1545
1133
  await this.queue.push({
1546
1134
  jobName,
@@ -1550,17 +1138,17 @@ var JobQueueProvider = class {
1550
1138
  };
1551
1139
  //#endregion
1552
1140
  //#region ../../src/api/jobs/index.ts
1553
- const jobEnvSchema = t.object({ ALEPHA_JOBS_QUEUE: t.optional(t.integer({ description: "Set to 1 to always use queue, 0 to disable queue (default: auto-detect based on environment)" })) });
1554
1141
  /**
1555
- * Job execution framework — unified primitive for deferred, scheduled, and queued work.
1142
+ * Job execution framework — cron and durable queue work with a single primitive.
1556
1143
  *
1557
- * **Features:**
1558
- * - Push-based jobs with typed payloads
1559
- * - Cron scheduling with execution tracking
1560
- * - Retry with exponential backoff
1561
- * - Priority, delay, cancellation
1562
- * - Deduplication via unique keys
1563
- * - Per-execution log capture
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.
1147
+ *
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`.
1564
1152
  *
1565
1153
  * @module alepha.api.jobs
1566
1154
  */
@@ -1571,17 +1159,22 @@ const AlephaApiJobs = $module({
1571
1159
  JobProvider,
1572
1160
  JobService,
1573
1161
  AdminJobController
1574
- ],
1575
- variants: [JobQueueProvider],
1576
- register: (alepha) => {
1577
- const env = alepha.parseEnv(jobEnvSchema);
1578
- if (env.ALEPHA_JOBS_QUEUE === 1 ? true : env.ALEPHA_JOBS_QUEUE === 0 ? false : !alepha.isServerless()) {
1579
- alepha.with(AlephaQueue);
1580
- alepha.with(JobQueueProvider);
1581
- }
1582
- }
1162
+ ]
1163
+ });
1164
+ /**
1165
+ * Queue support for `$job`. Import alongside {@link AlephaApiJobs} when your
1166
+ * app declares queue-mode jobs (any `$job` with a `schema`).
1167
+ *
1168
+ * Adds `JobQueueProvider` which plumbs the outbox dispatch through `AlephaQueue`.
1169
+ *
1170
+ * @module alepha.api.jobs.queue
1171
+ */
1172
+ const AlephaApiJobsQueue = $module({
1173
+ name: "alepha.api.jobs.queue",
1174
+ imports: [AlephaApiJobs, AlephaQueue],
1175
+ services: [JobQueueProvider]
1583
1176
  });
1584
1177
  //#endregion
1585
- export { $job, AdminJobController, AlephaApiJobs, JobPrimitive, JobProvider, JobQueueProvider, JobService, jobActivityPointSchema, jobActivityQuerySchema, jobConfig, jobCronInfoSchema, jobExecutionCanSchema, jobExecutionDetailResourceSchema, jobExecutionEntity, jobExecutionLogEntity, jobExecutionQuerySchema, jobExecutionResourceSchema, jobFailureSchema, jobQueueDepthSchema, jobRegistrationSchema, jobStatsSchema, triggerJobSchema };
1178
+ export { $job, AdminJobController, AlephaApiJobs, AlephaApiJobsQueue, JobPrimitive, JobProvider, JobQueueProvider, JobService, PRIORITY_MAP, PRIORITY_REVERSE, jobConfig, jobExecutionEntity, jobExecutionQuerySchema, jobExecutionResourceSchema, jobRegistrationSchema, triggerJobSchema };
1586
1179
 
1587
1180
  //# sourceMappingURL=index.js.map