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,2899 +1,348 @@
1
- import { $inject, Alepha } from "alepha";
2
- import { $logger } from "alepha/logger";
1
+ import { Alepha, AlephaError, t } from "alepha";
3
2
  import { $repository } from "alepha/orm";
4
3
  import { AlephaOrmPostgres } from "alepha/orm/postgres";
5
- import { describe, expect, it, vi } from "vitest";
4
+ import { describe, it } from "vitest";
6
5
  import {
7
6
  $job,
8
- JobProvider,
9
- JobQueueProvider,
10
- JobService,
7
+ AlephaApiJobs,
8
+ AlephaApiJobsQueue,
11
9
  jobExecutionEntity,
12
- jobExecutionLogEntity,
13
10
  } from "../index.ts";
14
11
 
15
- // -----------------------------------------------------------------------------------------------------------------
12
+ const makeApp = () =>
13
+ Alepha.create()
14
+ .with(AlephaOrmPostgres)
15
+ .with(AlephaApiJobs)
16
+ .with(AlephaApiJobsQueue);
16
17
 
17
- const t = await import("alepha").then((m) => m.t);
18
-
19
- // -----------------------------------------------------------------------------------------------------------------
20
-
21
- describe("$job v2", () => {
22
- // ----- Basic functionality -----
23
-
24
- describe("basic functionality", () => {
25
- it("should push a single payload and execute handler", async () => {
26
- const handler = vi.fn();
27
-
28
- class App {
29
- repo = $repository(jobExecutionEntity);
30
- myJob = $job({
31
- schema: t.object({ userId: t.text() }),
32
- handler,
33
- });
34
- }
35
-
36
- const alepha = Alepha.create().with(AlephaOrmPostgres);
37
- const app = alepha.inject(App);
38
- await alepha.start();
39
-
40
- await app.myJob.push({ userId: "abc-123" });
41
-
42
- // Wait for async processing
43
- await vi.waitFor(() => {
44
- expect(handler).toHaveBeenCalledTimes(1);
45
- });
46
-
47
- const args = handler.mock.calls[0][0];
48
- expect(args.items).toHaveLength(1);
49
- expect(args.items[0].payload).toEqual({ userId: "abc-123" });
50
- expect(args.items[0].attempt).toBe(1);
51
- expect(args.items[0].id).toBeDefined();
52
- expect(args.now).toBeDefined();
53
- expect(args.signal).toBeInstanceOf(AbortSignal);
54
- });
55
-
56
- it("should push an array of payloads", async () => {
57
- const handler = vi.fn();
58
-
59
- class App {
60
- myJob = $job({
61
- schema: t.object({ id: t.text() }),
62
- handler,
63
- });
64
- }
65
-
66
- const alepha = Alepha.create().with(AlephaOrmPostgres);
67
- const app = alepha.inject(App);
68
- await alepha.start();
69
-
70
- const ids = await app.myJob.push([{ id: "1" }, { id: "2" }, { id: "3" }]);
71
-
72
- expect(ids).toHaveLength(3);
73
-
74
- await vi.waitFor(() => {
75
- expect(handler).toHaveBeenCalledTimes(3);
76
- });
77
- });
78
-
79
- it("should use default name from ClassName.propertyKey", async () => {
80
- class MyService {
81
- sendEmail = $job({
82
- schema: t.object({ to: t.text() }),
83
- handler: async () => {},
84
- });
85
- }
86
-
87
- const alepha = Alepha.create().with(AlephaOrmPostgres);
88
- const app = alepha.inject(MyService);
89
- await alepha.start();
90
-
91
- expect(app.sendEmail.name).toBe("MyService.sendEmail");
92
- });
93
-
94
- it("should handle cron-only job with empty items", async () => {
95
- const handler = vi.fn();
96
-
97
- class App {
98
- cronJob = $job({
99
- cron: "0 0 * * *",
100
- handler,
101
- });
102
- }
103
-
104
- const alepha = Alepha.create().with(AlephaOrmPostgres);
105
- const app = alepha.inject(App);
106
- await alepha.start();
107
-
108
- await app.cronJob.trigger();
109
-
110
- await vi.waitFor(() => {
111
- expect(handler).toHaveBeenCalledTimes(1);
112
- });
113
-
114
- const args = handler.mock.calls[0][0];
115
- expect(args.items).toEqual([]);
116
- expect(args.now).toBeDefined();
117
- expect(args.signal).toBeInstanceOf(AbortSignal);
118
- });
119
- });
120
-
121
- // ----- Execution tracking -----
122
-
123
- describe("execution tracking", () => {
124
- it("should transition through pending → running → completed", async () => {
125
- let statusDuringExecution: string | undefined;
126
-
127
- class App {
128
- repo = $repository(jobExecutionEntity);
129
- myJob = $job({
130
- schema: t.object({ value: t.text() }),
131
- handler: async () => {
132
- const executions = await this.repo.findMany({
133
- where: { jobName: "App.myJob", status: "running" },
134
- });
135
- statusDuringExecution = executions[0]?.status;
136
- },
137
- });
138
- }
139
-
140
- const alepha = Alepha.create().with(AlephaOrmPostgres);
141
- const app = alepha.inject(App);
142
- await alepha.start();
143
-
144
- await app.myJob.push({ value: "test" });
145
-
146
- await vi.waitFor(async () => {
147
- const executions = await app.repo.findMany({
148
- where: { jobName: "App.myJob", status: "completed" },
149
- });
150
- expect(executions).toHaveLength(1);
151
- });
152
-
153
- expect(statusDuringExecution).toBe("running");
154
- });
155
-
156
- it("should record failure with error message", async () => {
157
- class App {
158
- repo = $repository(jobExecutionEntity);
159
- failingJob = $job({
160
- schema: t.object({ value: t.text() }),
161
- handler: async () => {
162
- throw new Error("Something went wrong");
163
- },
164
- });
165
- }
166
-
167
- const alepha = Alepha.create().with(AlephaOrmPostgres);
168
- const app = alepha.inject(App);
169
- await alepha.start();
170
-
171
- await app.failingJob.push({ value: "test" });
172
-
173
- await vi.waitFor(async () => {
174
- const executions = await app.repo.findMany({
175
- where: { jobName: "App.failingJob", status: "dead" },
176
- });
177
- expect(executions).toHaveLength(1);
178
- expect(executions[0].error).toBe("Something went wrong");
179
- expect(executions[0].completedAt).toBeDefined();
180
- });
181
- });
182
-
183
- it("should set attempt to 1 on first execution", async () => {
184
- class App {
185
- repo = $repository(jobExecutionEntity);
186
- myJob = $job({
187
- schema: t.object({ value: t.text() }),
188
- handler: async () => {},
189
- });
190
- }
191
-
192
- const alepha = Alepha.create().with(AlephaOrmPostgres);
193
- const app = alepha.inject(App);
194
- await alepha.start();
195
-
196
- await app.myJob.push({ value: "test" });
197
-
198
- await vi.waitFor(async () => {
199
- const executions = await app.repo.findMany({
200
- where: { jobName: "App.myJob", status: "completed" },
201
- });
202
- expect(executions).toHaveLength(1);
203
- expect(executions[0].attempt).toBe(1);
204
- });
205
- });
206
-
207
- it("should set startedAt and completedAt timestamps", async () => {
208
- class App {
209
- repo = $repository(jobExecutionEntity);
210
- myJob = $job({
211
- schema: t.object({ value: t.text() }),
212
- handler: async () => {},
213
- });
214
- }
215
-
216
- const alepha = Alepha.create().with(AlephaOrmPostgres);
217
- const app = alepha.inject(App);
218
- await alepha.start();
219
-
220
- await app.myJob.push({ value: "test" });
221
-
222
- await vi.waitFor(async () => {
223
- const executions = await app.repo.findMany({
224
- where: { jobName: "App.myJob", status: "completed" },
225
- });
226
- expect(executions).toHaveLength(1);
227
- expect(executions[0].startedAt).toBeDefined();
228
- expect(executions[0].completedAt).toBeDefined();
229
- });
230
- });
231
-
232
- it("should set workerId on claim", async () => {
233
- class App {
234
- repo = $repository(jobExecutionEntity);
235
- myJob = $job({
236
- schema: t.object({ value: t.text() }),
237
- handler: async () => {},
238
- });
239
- }
240
-
241
- const alepha = Alepha.create().with(AlephaOrmPostgres);
242
- const app = alepha.inject(App);
243
- await alepha.start();
244
-
245
- await app.myJob.push({ value: "test" });
246
-
247
- await vi.waitFor(async () => {
248
- const executions = await app.repo.findMany({
249
- where: { jobName: "App.myJob", status: "completed" },
250
- });
251
- expect(executions).toHaveLength(1);
252
- expect(executions[0].workerId).toBeDefined();
253
- expect(executions[0].workerId!.length).toBeGreaterThan(0);
254
- });
255
- });
256
-
257
- it("should create separate execution records for each push", async () => {
258
- class App {
259
- repo = $repository(jobExecutionEntity);
260
- myJob = $job({
261
- schema: t.object({ n: t.integer() }),
262
- handler: async () => {},
263
- });
264
- }
265
-
266
- const alepha = Alepha.create().with(AlephaOrmPostgres);
267
- const app = alepha.inject(App);
268
- await alepha.start();
269
-
270
- await app.myJob.push({ n: 1 });
271
- await app.myJob.push({ n: 2 });
272
- await app.myJob.push({ n: 3 });
273
-
274
- await vi.waitFor(async () => {
275
- const executions = await app.repo.findMany({
276
- where: { jobName: "App.myJob", status: "completed" },
277
- });
278
- expect(executions).toHaveLength(3);
279
- });
280
- });
281
- });
282
-
283
- // ----- Log capture -----
284
-
285
- describe("log capture", () => {
286
- it("should capture handler logs to cold table", async () => {
287
- class App {
288
- log = $logger();
289
- repo = $repository(jobExecutionEntity);
290
- logRepo = $repository(jobExecutionLogEntity);
291
-
292
- loggingJob = $job({
293
- schema: t.object({ value: t.text() }),
294
- handler: async () => {
295
- this.log.info("Step 1 done");
296
- this.log.warn("Something slow");
297
- this.log.info("Step 2 done");
298
- },
299
- });
300
- }
301
-
302
- const alepha = Alepha.create().with(AlephaOrmPostgres);
303
- const app = alepha.inject(App);
304
- await alepha.start();
305
-
306
- await app.loggingJob.push({ value: "test" });
307
-
308
- await vi.waitFor(async () => {
309
- const executions = await app.repo.findMany({
310
- where: { jobName: "App.loggingJob", status: "completed" },
311
- });
312
- expect(executions).toHaveLength(1);
313
-
314
- const logEntry = await app.logRepo.findById(executions[0].id);
315
- expect(logEntry).toBeDefined();
316
- expect(logEntry!.logs).toBeDefined();
317
-
318
- const infoLogs = logEntry!.logs.filter((l) => l.level === "INFO");
319
- const warnLogs = logEntry!.logs.filter((l) => l.level === "WARN");
320
- expect(infoLogs.length).toBeGreaterThanOrEqual(2);
321
- expect(warnLogs).toHaveLength(1);
322
- });
323
- });
324
-
325
- it("should capture logs even on failure", async () => {
326
- class App {
327
- log = $logger();
328
- repo = $repository(jobExecutionEntity);
329
- logRepo = $repository(jobExecutionLogEntity);
330
-
331
- failingJob = $job({
332
- schema: t.object({ value: t.text() }),
333
- handler: async () => {
334
- this.log.info("Before error");
335
- throw new Error("boom");
336
- },
337
- });
338
- }
339
-
340
- const alepha = Alepha.create().with(AlephaOrmPostgres);
341
- const app = alepha.inject(App);
342
- await alepha.start();
343
-
344
- await app.failingJob.push({ value: "test" });
345
-
346
- await vi.waitFor(async () => {
347
- const executions = await app.repo.findMany({
348
- where: { jobName: "App.failingJob" },
349
- });
350
- expect(executions).toHaveLength(1);
351
-
352
- const logEntry = await app.logRepo.findById(executions[0].id);
353
- expect(logEntry).toBeDefined();
354
- expect(logEntry!.logs.some((l) => l.message === "Before error")).toBe(
355
- true,
356
- );
357
- });
358
- });
359
- });
360
-
361
- // ----- Retry policy -----
362
-
363
- describe("retry policy", () => {
364
- it("should reschedule on failure with retries configured", async () => {
365
- let callCount = 0;
366
-
367
- class App {
368
- repo = $repository(jobExecutionEntity);
369
- retryJob = $job({
370
- schema: t.object({ value: t.text() }),
371
- retry: { retries: 2, backoff: [1, "second"] },
372
- handler: async () => {
373
- callCount++;
374
- if (callCount <= 2) {
375
- throw new Error(`Fail #${callCount}`);
376
- }
377
- },
378
- });
379
- }
380
-
381
- const alepha = Alepha.create().with(AlephaOrmPostgres);
382
- const app = alepha.inject(App);
383
- await alepha.start();
384
-
385
- await app.retryJob.push({ value: "test" });
386
-
387
- // First execution fails → status becomes "retrying"
388
- await vi.waitFor(async () => {
389
- const executions = await app.repo.findMany({
390
- where: { jobName: "App.retryJob", status: "retrying" },
391
- });
392
- expect(executions).toHaveLength(1);
393
- expect(executions[0].attempt).toBe(1);
394
- expect(executions[0].scheduledAt).toBeDefined();
395
- });
396
- });
397
-
398
- it("should actually retry and complete after transient failures", async () => {
399
- let callCount = 0;
400
-
401
- class App {
402
- repo = $repository(jobExecutionEntity);
403
- retryJob = $job({
404
- schema: t.object({ value: t.text() }),
405
- retry: { retries: 2, backoff: [10, "millisecond"] },
406
- handler: async () => {
407
- callCount++;
408
- if (callCount <= 1) {
409
- throw new Error("transient failure");
410
- }
411
- },
412
- });
413
- }
414
-
415
- const alepha = Alepha.create().with(AlephaOrmPostgres);
416
- const app = alepha.inject(App);
417
- await alepha.start();
418
-
419
- await app.retryJob.push({ value: "test" });
420
-
421
- // Should eventually complete after retry
422
- await vi.waitFor(
423
- async () => {
424
- const executions = await app.repo.findMany({
425
- where: { jobName: "App.retryJob", status: "completed" },
426
- });
427
- expect(executions).toHaveLength(1);
428
- expect(executions[0].attempt).toBe(2);
429
- },
430
- { timeout: 5000 },
431
- );
432
-
433
- expect(callCount).toBe(2);
434
- });
435
-
436
- it("should retry until dead when all attempts fail", async () => {
437
- let callCount = 0;
438
-
439
- class App {
440
- repo = $repository(jobExecutionEntity);
441
- retryJob = $job({
442
- schema: t.object({ value: t.text() }),
443
- retry: { retries: 2, backoff: [10, "millisecond"] },
444
- handler: async () => {
445
- callCount++;
446
- throw new Error(`fail #${callCount}`);
447
- },
448
- });
449
- }
450
-
451
- const alepha = Alepha.create().with(AlephaOrmPostgres);
452
- const app = alepha.inject(App);
453
- await alepha.start();
454
-
455
- await app.retryJob.push({ value: "test" });
456
-
457
- // Should eventually go dead after all 3 attempts
458
- await vi.waitFor(
459
- async () => {
460
- const executions = await app.repo.findMany({
461
- where: { jobName: "App.retryJob", status: "dead" },
462
- });
463
- expect(executions).toHaveLength(1);
464
- expect(executions[0].attempt).toBe(3);
465
- expect(executions[0].error).toBe("fail #3");
466
- },
467
- { timeout: 5000 },
468
- );
469
-
470
- expect(callCount).toBe(3);
471
- });
472
-
473
- it("should set maxAttempts to retries + 1", async () => {
474
- class App {
475
- repo = $repository(jobExecutionEntity);
476
- retryJob = $job({
477
- schema: t.object({ value: t.text() }),
478
- retry: { retries: 3 },
479
- handler: async () => {
480
- throw new Error("always fail");
481
- },
482
- });
483
- }
484
-
485
- const alepha = Alepha.create().with(AlephaOrmPostgres);
486
- const app = alepha.inject(App);
487
- await alepha.start();
488
-
489
- await app.retryJob.push({ value: "test" });
490
-
491
- await vi.waitFor(async () => {
492
- const executions = await app.repo.findMany({
493
- where: { jobName: "App.retryJob" },
494
- });
495
- expect(executions).toHaveLength(1);
496
- expect(executions[0].maxAttempts).toBe(4);
497
- });
498
- });
499
-
500
- it("should mark as dead when all retries exhausted", async () => {
501
- class App {
502
- repo = $repository(jobExecutionEntity);
503
- retryJob = $job({
504
- schema: t.object({ value: t.text() }),
505
- retry: { retries: 0 },
506
- handler: async () => {
507
- throw new Error("always fail");
508
- },
509
- });
510
- }
511
-
512
- const alepha = Alepha.create().with(AlephaOrmPostgres);
513
- const app = alepha.inject(App);
514
- await alepha.start();
515
-
516
- await app.retryJob.push({ value: "test" });
517
-
518
- await vi.waitFor(async () => {
519
- const executions = await app.repo.findMany({
520
- where: { jobName: "App.retryJob", status: "dead" },
521
- });
522
- expect(executions).toHaveLength(1);
523
- expect(executions[0].error).toBe("always fail");
524
- });
525
- });
526
-
527
- it("should respect retry.when predicate", async () => {
528
- class SkippableError extends Error {
529
- constructor() {
530
- super("skip me");
531
- this.name = "SkippableError";
532
- }
533
- }
534
-
535
- class App {
536
- repo = $repository(jobExecutionEntity);
537
- retryJob = $job({
538
- schema: t.object({ value: t.text() }),
539
- retry: {
540
- retries: 3,
541
- when: (error) => !(error instanceof SkippableError),
542
- },
543
- handler: async () => {
544
- throw new SkippableError();
545
- },
546
- });
547
- }
548
-
549
- const alepha = Alepha.create().with(AlephaOrmPostgres);
550
- const app = alepha.inject(App);
551
- await alepha.start();
552
-
553
- await app.retryJob.push({ value: "test" });
554
-
555
- // Should go straight to dead (no retry for SkippableError)
556
- await vi.waitFor(async () => {
557
- const executions = await app.repo.findMany({
558
- where: { jobName: "App.retryJob", status: "dead" },
559
- });
560
- expect(executions).toHaveLength(1);
561
- expect(executions[0].attempt).toBe(1);
562
- });
563
- });
564
-
565
- it("should compute exponential backoff", async () => {
566
- class App {
567
- repo = $repository(jobExecutionEntity);
568
- retryJob = $job({
569
- schema: t.object({ value: t.text() }),
570
- retry: {
571
- retries: 3,
572
- backoff: {
573
- initial: [1, "second"],
574
- factor: 2,
575
- max: [30, "second"],
576
- },
577
- },
578
- handler: async () => {
579
- throw new Error("fail");
580
- },
581
- });
582
- }
583
-
584
- const alepha = Alepha.create().with(AlephaOrmPostgres);
585
- const app = alepha.inject(App);
586
- await alepha.start();
587
-
588
- await app.retryJob.push({ value: "test" });
589
-
590
- await vi.waitFor(async () => {
591
- const executions = await app.repo.findMany({
592
- where: { jobName: "App.retryJob", status: "retrying" },
593
- });
594
- expect(executions).toHaveLength(1);
595
- expect(executions[0].scheduledAt).toBeDefined();
596
- });
597
- });
598
- });
599
-
600
- // ----- Priority -----
601
-
602
- describe("priority", () => {
603
- it("should store numeric priority from string", async () => {
604
- class App {
605
- repo = $repository(jobExecutionEntity);
606
- highPriorityJob = $job({
607
- schema: t.object({ value: t.text() }),
608
- priority: "high",
609
- handler: async () => {},
610
- });
611
- }
612
-
613
- const alepha = Alepha.create().with(AlephaOrmPostgres);
614
- const app = alepha.inject(App);
615
- await alepha.start();
616
-
617
- await app.highPriorityJob.push({ value: "test" });
618
-
619
- await vi.waitFor(async () => {
620
- const executions = await app.repo.findMany({
621
- where: { jobName: "App.highPriorityJob" },
622
- });
623
- expect(executions).toHaveLength(1);
624
- expect(executions[0].priority).toBe(1); // high = 1
625
- });
626
- });
627
-
628
- it("should allow per-push priority override", async () => {
629
- class App {
630
- repo = $repository(jobExecutionEntity);
631
- myJob = $job({
632
- schema: t.object({ value: t.text() }),
633
- priority: "low",
634
- handler: async () => {},
635
- });
636
- }
637
-
638
- const alepha = Alepha.create().with(AlephaOrmPostgres);
639
- const app = alepha.inject(App);
640
- await alepha.start();
641
-
642
- await app.myJob.push({ value: "test" }, { priority: "critical" });
643
-
644
- await vi.waitFor(async () => {
645
- const executions = await app.repo.findMany({
646
- where: { jobName: "App.myJob" },
647
- });
648
- expect(executions).toHaveLength(1);
649
- expect(executions[0].priority).toBe(0); // critical = 0
650
- });
651
- });
652
- });
653
-
654
- // ----- Deduplication -----
655
-
656
- describe("deduplication (unique key)", () => {
657
- it("should return existing ID when pushing with same key", async () => {
658
- class App {
659
- repo = $repository(jobExecutionEntity);
660
- myJob = $job({
661
- schema: t.object({ value: t.text() }),
662
- handler: async () => {},
663
- });
664
- }
665
-
666
- const alepha = Alepha.create().with(AlephaOrmPostgres);
667
- const app = alepha.inject(App);
668
- await alepha.start();
669
-
670
- // Use delay so jobs stay "scheduled" and key is not cleared
671
- const id1 = await app.myJob.push(
672
- { value: "first" },
673
- { key: "unique-key", delay: [1, "hour"] },
674
- );
675
- const id2 = await app.myJob.push(
676
- { value: "second" },
677
- { key: "unique-key", delay: [1, "hour"] },
678
- );
679
-
680
- expect(id1).toBe(id2);
681
-
682
- // Only one execution should exist
683
- const executions = await app.repo.findMany({
684
- where: { jobName: "App.myJob" },
685
- });
686
- expect(executions).toHaveLength(1);
687
- });
688
-
689
- it("should allow same key after completion (key set to null)", async () => {
690
- class App {
691
- repo = $repository(jobExecutionEntity);
692
- myJob = $job({
693
- schema: t.object({ value: t.text() }),
694
- handler: async () => {},
695
- });
696
- }
697
-
698
- const alepha = Alepha.create().with(AlephaOrmPostgres);
699
- const app = alepha.inject(App);
700
- await alepha.start();
701
-
702
- const id1 = (await app.myJob.push(
703
- { value: "first" },
704
- { key: "reuse-key" },
705
- )) as string;
706
-
707
- // Wait for completion (key gets set to null)
708
- await vi.waitFor(async () => {
709
- const exec = await app.repo.findById(id1);
710
- expect(exec?.status).toBe("completed");
711
- expect(exec?.key).toBeNull();
712
- });
713
-
714
- // Push again with same key — should create new execution
715
- const id2 = await app.myJob.push(
716
- { value: "second" },
717
- { key: "reuse-key" },
718
- );
719
-
720
- expect(id2).not.toBe(id1);
721
- });
722
- });
723
-
724
- // ----- Delayed execution -----
725
-
726
- describe("delayed execution", () => {
727
- it("should create scheduled execution with delay", async () => {
728
- class App {
729
- repo = $repository(jobExecutionEntity);
730
- myJob = $job({
731
- schema: t.object({ value: t.text() }),
732
- handler: async () => {},
733
- });
734
- }
735
-
736
- const alepha = Alepha.create().with(AlephaOrmPostgres);
737
- const app = alepha.inject(App);
738
- await alepha.start();
739
-
740
- await app.myJob.push({ value: "test" }, { delay: [1, "hour"] });
741
-
742
- const executions = await app.repo.findMany({
743
- where: { jobName: "App.myJob", status: "scheduled" },
744
- });
745
-
746
- expect(executions).toHaveLength(1);
747
- expect(executions[0].scheduledAt).toBeDefined();
748
- });
749
-
750
- it("should create scheduled execution with scheduledAt", async () => {
751
- const futureDate = new Date("2030-01-01T00:00:00Z");
752
-
753
- class App {
754
- repo = $repository(jobExecutionEntity);
755
- myJob = $job({
756
- schema: t.object({ value: t.text() }),
757
- handler: async () => {},
758
- });
759
- }
760
-
761
- const alepha = Alepha.create().with(AlephaOrmPostgres);
762
- const app = alepha.inject(App);
763
- await alepha.start();
764
-
765
- await app.myJob.push({ value: "test" }, { scheduledAt: futureDate });
766
-
767
- const executions = await app.repo.findMany({
768
- where: { jobName: "App.myJob", status: "scheduled" },
769
- });
770
-
771
- expect(executions).toHaveLength(1);
772
- expect(executions[0].scheduledAt).toBeDefined();
773
- });
774
- });
775
-
776
- // ----- Cancellation -----
777
-
778
- describe("cancellation", () => {
779
- it("should cancel a pending execution", async () => {
780
- class App {
781
- repo = $repository(jobExecutionEntity);
782
- myJob = $job({
783
- schema: t.object({ value: t.text() }),
784
- handler: async () => {},
785
- });
786
- }
787
-
788
- const alepha = Alepha.create().with(AlephaOrmPostgres);
789
- const app = alepha.inject(App);
790
- await alepha.start();
791
-
792
- // Push with delay so it stays pending
793
- const id = (await app.myJob.push(
794
- { value: "test" },
795
- { delay: [1, "hour"] },
796
- )) as string;
797
-
798
- await app.myJob.cancel(id);
799
-
800
- const execution = await app.repo.findById(id);
801
- expect(execution?.status).toBe("cancelled");
802
- });
803
-
804
- it("should record cancelledBy on cancellation", async () => {
805
- class App {
806
- repo = $repository(jobExecutionEntity);
807
- myJob = $job({
808
- schema: t.object({ value: t.text() }),
809
- handler: async () => {},
810
- });
811
- }
812
-
813
- const alepha = Alepha.create().with(AlephaOrmPostgres);
814
- const app = alepha.inject(App);
815
- await alepha.start();
816
-
817
- const id = (await app.myJob.push(
818
- { value: "test" },
819
- { delay: [1, "hour"] },
820
- )) as string;
821
-
822
- // Use provider directly to pass cancel context
823
- const provider = alepha.inject(JobProvider);
824
- await provider.cancel(id, {
825
- cancelledBy: "user-123",
826
- cancelledByName: "John Doe",
827
- });
828
-
829
- const execution = await app.repo.findById(id);
830
- expect(execution?.cancelledBy).toBe("user-123");
831
- expect(execution?.cancelledByName).toBe("John Doe");
832
- });
833
-
834
- it("should throw when cancelling a completed execution", async () => {
835
- class App {
836
- repo = $repository(jobExecutionEntity);
837
- myJob = $job({
838
- schema: t.object({ value: t.text() }),
839
- handler: async () => {},
840
- });
841
- }
842
-
843
- const alepha = Alepha.create().with(AlephaOrmPostgres);
844
- const app = alepha.inject(App);
845
- await alepha.start();
846
-
847
- const id = (await app.myJob.push({ value: "test" })) as string;
848
-
849
- await vi.waitFor(async () => {
850
- const exec = await app.repo.findById(id);
851
- expect(exec?.status).toBe("completed");
852
- });
853
-
854
- await expect(app.myJob.cancel(id)).rejects.toThrowError(/Cannot cancel/);
855
- });
856
-
857
- it("should be idempotent for already cancelled executions", async () => {
858
- class App {
859
- repo = $repository(jobExecutionEntity);
860
- myJob = $job({
861
- schema: t.object({ value: t.text() }),
862
- handler: async () => {},
863
- });
864
- }
865
-
866
- const alepha = Alepha.create().with(AlephaOrmPostgres);
867
- const app = alepha.inject(App);
868
- await alepha.start();
869
-
870
- const id = (await app.myJob.push(
871
- { value: "test" },
872
- { delay: [1, "hour"] },
873
- )) as string;
874
-
875
- await app.myJob.cancel(id);
876
-
877
- // Second cancel should throw (already cancelled)
878
- await expect(app.myJob.cancel(id)).rejects.toThrowError(/Cannot cancel/);
879
- });
880
-
881
- it("should trigger AbortSignal on running job cancellation", async () => {
882
- let signalAborted = false;
883
-
884
- class App {
885
- repo = $repository(jobExecutionEntity);
886
- myJob = $job({
887
- schema: t.object({ value: t.text() }),
888
- handler: async ({ signal }) => {
889
- // Long running handler
890
- await new Promise<void>((resolve) => {
891
- const check = () => {
892
- if (signal.aborted) {
893
- signalAborted = true;
894
- resolve();
895
- } else {
896
- setTimeout(check, 10);
897
- }
898
- };
899
- check();
900
- });
901
- },
902
- });
903
- }
904
-
905
- const alepha = Alepha.create().with(AlephaOrmPostgres);
906
- const app = alepha.inject(App);
907
- await alepha.start();
908
-
909
- const id = (await app.myJob.push({ value: "test" })) as string;
910
-
911
- // Wait for it to start running
912
- await vi.waitFor(async () => {
913
- const exec = await app.repo.findById(id);
914
- expect(exec?.status).toBe("running");
915
- });
916
-
917
- await app.myJob.cancel(id);
918
-
919
- await vi.waitFor(() => {
920
- expect(signalAborted).toBe(true);
921
- });
922
- });
923
- });
924
-
925
- // ----- Timeout -----
926
-
927
- describe("timeout", () => {
928
- it("should abort handler when timeout expires", async () => {
929
- let wasAborted = false;
930
-
931
- class App {
932
- repo = $repository(jobExecutionEntity);
933
- timeoutJob = $job({
934
- schema: t.object({ value: t.text() }),
935
- timeout: [50, "millisecond"],
936
- handler: async ({ signal }) => {
937
- await new Promise<void>((resolve) => {
938
- const check = () => {
939
- if (signal.aborted) {
940
- wasAborted = true;
941
- resolve();
942
- } else {
943
- setTimeout(check, 10);
944
- }
945
- };
946
- check();
947
- });
948
- throw new Error("Timed out by signal");
949
- },
950
- });
951
- }
952
-
953
- const alepha = Alepha.create().with(AlephaOrmPostgres);
954
- const app = alepha.inject(App);
955
- await alepha.start();
956
-
957
- await app.timeoutJob.push({ value: "test" });
958
-
959
- await vi.waitFor(
960
- () => {
961
- expect(wasAborted).toBe(true);
962
- },
963
- { timeout: 2000 },
964
- );
965
- });
966
- });
967
-
968
- // ----- Events -----
969
-
970
- describe("event emission", () => {
971
- it("should emit job:begin event", async () => {
972
- const beginHandler = vi.fn();
973
-
974
- class App {
975
- myJob = $job({
976
- schema: t.object({ value: t.text() }),
977
- handler: async () => {},
978
- });
979
- }
980
-
981
- const alepha = Alepha.create().with(AlephaOrmPostgres);
982
- const app = alepha.inject(App);
983
- alepha.events.on("job:begin", beginHandler);
984
- await alepha.start();
985
-
986
- await app.myJob.push({ value: "test" });
987
-
988
- await vi.waitFor(() => {
989
- expect(beginHandler).toHaveBeenCalledTimes(1);
990
- expect(beginHandler).toHaveBeenCalledWith(
991
- expect.objectContaining({
992
- name: "App.myJob",
993
- executionId: expect.any(String),
994
- }),
995
- );
996
- });
997
- });
998
-
999
- it("should emit job:success on completion", async () => {
1000
- const successHandler = vi.fn();
1001
-
1002
- class App {
1003
- myJob = $job({
1004
- schema: t.object({ value: t.text() }),
1005
- handler: async () => {},
1006
- });
1007
- }
1008
-
1009
- const alepha = Alepha.create().with(AlephaOrmPostgres);
1010
- const app = alepha.inject(App);
1011
- alepha.events.on("job:success", successHandler);
1012
- await alepha.start();
1013
-
1014
- await app.myJob.push({ value: "test" });
1015
-
1016
- await vi.waitFor(() => {
1017
- expect(successHandler).toHaveBeenCalledTimes(1);
1018
- });
1019
- });
1020
-
1021
- it("should emit job:error on failure", async () => {
1022
- const errorHandler = vi.fn();
1023
-
1024
- class App {
1025
- myJob = $job({
1026
- schema: t.object({ value: t.text() }),
1027
- handler: async () => {
1028
- throw new Error("test error");
1029
- },
1030
- });
1031
- }
1032
-
1033
- const alepha = Alepha.create().with(AlephaOrmPostgres);
1034
- const app = alepha.inject(App);
1035
- alepha.events.on("job:error", errorHandler);
1036
- await alepha.start();
1037
-
1038
- await app.myJob.push({ value: "test" });
1039
-
1040
- await vi.waitFor(() => {
1041
- expect(errorHandler).toHaveBeenCalledTimes(1);
1042
- expect(errorHandler).toHaveBeenCalledWith(
1043
- expect.objectContaining({
1044
- name: "App.myJob",
1045
- error: expect.objectContaining({
1046
- message: "test error",
1047
- }),
1048
- }),
1049
- );
1050
- });
1051
- });
1052
-
1053
- it("should emit job:end for all outcomes", async () => {
1054
- const endHandler = vi.fn();
1055
-
1056
- class App {
1057
- successJob = $job({
1058
- schema: t.object({ value: t.text() }),
1059
- handler: async () => {},
1060
- });
1061
- failJob = $job({
1062
- schema: t.object({ value: t.text() }),
1063
- handler: async () => {
1064
- throw new Error("fail");
1065
- },
1066
- });
1067
- }
1068
-
1069
- const alepha = Alepha.create().with(AlephaOrmPostgres);
1070
- const app = alepha.inject(App);
1071
- alepha.events.on("job:end", endHandler);
1072
- await alepha.start();
1073
-
1074
- await app.successJob.push({ value: "test" });
1075
- await app.failJob.push({ value: "test" });
1076
-
1077
- await vi.waitFor(() => {
1078
- expect(endHandler).toHaveBeenCalledTimes(2);
1079
- });
1080
- });
1081
- });
1082
-
1083
- // ----- pushMany -----
1084
-
1085
- describe("pushMany", () => {
1086
- it("should push multiple items with per-item options", async () => {
1087
- class App {
1088
- repo = $repository(jobExecutionEntity);
1089
- myJob = $job({
1090
- schema: t.object({ value: t.text() }),
1091
- handler: async () => {},
1092
- });
1093
- }
1094
-
1095
- const alepha = Alepha.create().with(AlephaOrmPostgres);
1096
- const app = alepha.inject(App);
1097
- await alepha.start();
1098
-
1099
- const ids = await app.myJob.pushMany([
1100
- { payload: { value: "a" }, key: "key-a", delay: [30, "minute"] },
1101
- {
1102
- payload: { value: "b" },
1103
- delay: [30, "minute"],
1104
- },
1105
- ]);
1106
-
1107
- expect(ids).toHaveLength(2);
1108
-
1109
- const exec1 = await app.repo.findById(ids[0]);
1110
- expect(exec1?.key).toBe("key-a");
1111
- expect(exec1?.status).toBe("scheduled");
1112
-
1113
- const exec2 = await app.repo.findById(ids[1]);
1114
- expect(exec2?.status).toBe("scheduled");
1115
- expect(exec2?.scheduledAt).toBeDefined();
1116
- });
1117
- });
1118
-
1119
- // ----- Edge cases -----
1120
-
1121
- describe("edge cases", () => {
1122
- it("should throw when pushing to a job without schema", async () => {
1123
- class App {
1124
- cronOnly = $job({
1125
- cron: "0 0 * * *",
1126
- handler: async () => {},
1127
- });
1128
- }
1129
-
1130
- const alepha = Alepha.create().with(AlephaOrmPostgres);
1131
- const app = alepha.inject(App);
1132
- await alepha.start();
1133
-
1134
- await expect(
1135
- app.cronOnly.push({ anything: "value" }),
1136
- ).rejects.toThrowError(/no schema defined/);
1137
- });
1138
-
1139
- it("should handle non-Error thrown objects", async () => {
1140
- class App {
1141
- repo = $repository(jobExecutionEntity);
1142
- myJob = $job({
1143
- schema: t.object({ value: t.text() }),
1144
- handler: async () => {
1145
- throw "string error";
1146
- },
1147
- });
1148
- }
1149
-
1150
- const alepha = Alepha.create().with(AlephaOrmPostgres);
1151
- const app = alepha.inject(App);
1152
- await alepha.start();
1153
-
1154
- await app.myJob.push({ value: "test" });
1155
-
1156
- await vi.waitFor(async () => {
1157
- const executions = await app.repo.findMany({
1158
- where: { jobName: "App.myJob", status: "dead" },
1159
- });
1160
- expect(executions).toHaveLength(1);
1161
- expect(executions[0].error).toBe("string error");
1162
- });
1163
- });
1164
-
1165
- it("should push empty array as no-op", async () => {
1166
- const handler = vi.fn();
1167
-
1168
- class App {
1169
- myJob = $job({
1170
- schema: t.object({ value: t.text() }),
1171
- handler,
1172
- });
1173
- }
1174
-
1175
- const alepha = Alepha.create().with(AlephaOrmPostgres);
1176
- const app = alepha.inject(App);
1177
- await alepha.start();
1178
-
1179
- const ids = await app.myJob.push([]);
1180
- expect(ids).toEqual([]);
1181
- expect(handler).not.toHaveBeenCalled();
1182
- });
1183
-
1184
- it("should trigger cron-only job manually", async () => {
1185
- const handler = vi.fn();
1186
-
1187
- class App {
1188
- repo = $repository(jobExecutionEntity);
1189
- cronJob = $job({
1190
- cron: "0 0 * * *",
1191
- handler,
1192
- });
1193
- }
1194
-
1195
- const alepha = Alepha.create().with(AlephaOrmPostgres);
1196
- const app = alepha.inject(App);
1197
- await alepha.start();
1198
-
1199
- await app.cronJob.trigger({
1200
- triggeredBy: "admin-123",
1201
- triggeredByName: "Admin User",
1202
- });
1203
-
1204
- await vi.waitFor(() => {
1205
- expect(handler).toHaveBeenCalledTimes(1);
1206
- });
1207
-
1208
- await vi.waitFor(async () => {
1209
- const executions = await app.repo.findMany({
1210
- where: { jobName: "App.cronJob", status: "completed" },
1211
- });
1212
- expect(executions).toHaveLength(1);
1213
- expect(executions[0].triggeredBy).toBe("admin-123");
1214
- expect(executions[0].triggeredByName).toBe("Admin User");
1215
- });
1216
- });
1217
-
1218
- it("should trigger push-based job manually with payload", async () => {
1219
- const handler = vi.fn();
1220
-
1221
- class App {
1222
- repo = $repository(jobExecutionEntity);
1223
- myJob = $job({
1224
- schema: t.object({ userId: t.text() }),
1225
- handler,
1226
- });
1227
- }
1228
-
1229
- const alepha = Alepha.create().with(AlephaOrmPostgres);
1230
- const app = alepha.inject(App);
1231
- await alepha.start();
1232
-
1233
- await app.myJob.trigger({
1234
- payload: { userId: "user-123" },
1235
- triggeredBy: "admin",
1236
- triggeredByName: "Admin",
1237
- });
1238
-
1239
- await vi.waitFor(() => {
1240
- expect(handler).toHaveBeenCalledTimes(1);
1241
- const args = handler.mock.calls[0][0];
1242
- expect(args.items[0].payload).toEqual({ userId: "user-123" });
1243
- });
1244
- });
1245
- });
1246
-
1247
- // ----- Resource can field -----
1248
-
1249
- describe("execution resource can field", () => {
1250
- it("should set can.retry=true for dead executions", async () => {
1251
- class App {
1252
- repo = $repository(jobExecutionEntity);
1253
- jobService = $inject(JobService);
1254
- failingJob = $job({
1255
- schema: t.object({ value: t.text() }),
1256
- handler: async () => {
1257
- throw new Error("fail");
1258
- },
1259
- });
1260
- }
1261
-
1262
- const alepha = Alepha.create().with(AlephaOrmPostgres);
1263
- const app = alepha.inject(App);
1264
- await alepha.start();
1265
-
1266
- await app.failingJob.push({ value: "test" });
1267
-
1268
- await vi.waitFor(async () => {
1269
- const executions = await app.repo.findMany({
1270
- where: { jobName: "App.failingJob", status: "dead" },
1271
- });
1272
- expect(executions).toHaveLength(1);
1273
- });
1274
-
1275
- const page = await app.jobService.findExecutions({
1276
- job: "App.failingJob",
1277
- });
1278
- const exec = page.content[0];
1279
- expect(exec.can).toEqual({ retry: true, cancel: false });
1280
- });
1281
-
1282
- it("should set can.cancel=true for scheduled executions", async () => {
1283
- class App {
1284
- repo = $repository(jobExecutionEntity);
1285
- jobService = $inject(JobService);
1286
- myJob = $job({
1287
- schema: t.object({ value: t.text() }),
1288
- handler: async () => {},
1289
- });
1290
- }
1291
-
1292
- const alepha = Alepha.create().with(AlephaOrmPostgres);
1293
- const app = alepha.inject(App);
1294
- await alepha.start();
1295
-
1296
- await app.myJob.push({ value: "test" }, { delay: [1, "hour"] });
1297
-
1298
- const page = await app.jobService.findExecutions({ job: "App.myJob" });
1299
- const exec = page.content[0];
1300
- expect(exec.can).toEqual({ retry: false, cancel: true });
1301
- });
1302
-
1303
- it("should set can.retry=false and can.cancel=false for completed executions", async () => {
1304
- class App {
1305
- repo = $repository(jobExecutionEntity);
1306
- jobService = $inject(JobService);
1307
- myJob = $job({
1308
- schema: t.object({ value: t.text() }),
1309
- handler: async () => {},
1310
- });
1311
- }
1312
-
1313
- const alepha = Alepha.create().with(AlephaOrmPostgres);
1314
- const app = alepha.inject(App);
1315
- await alepha.start();
1316
-
1317
- await app.myJob.push({ value: "test" });
1318
-
1319
- await vi.waitFor(async () => {
1320
- const executions = await app.repo.findMany({
1321
- where: { jobName: "App.myJob", status: "completed" },
1322
- });
1323
- expect(executions).toHaveLength(1);
1324
- });
1325
-
1326
- const page = await app.jobService.findExecutions({ job: "App.myJob" });
1327
- const exec = page.content[0];
1328
- expect(exec.can).toEqual({ retry: false, cancel: false });
1329
- });
1330
-
1331
- it("should include can field in getExecution detail", async () => {
1332
- class App {
1333
- repo = $repository(jobExecutionEntity);
1334
- jobService = $inject(JobService);
1335
- myJob = $job({
1336
- schema: t.object({ value: t.text() }),
1337
- handler: async () => {},
1338
- });
1339
- }
1340
-
1341
- const alepha = Alepha.create().with(AlephaOrmPostgres);
1342
- const app = alepha.inject(App);
1343
- await alepha.start();
1344
-
1345
- const id = (await app.myJob.push(
1346
- { value: "test" },
1347
- { delay: [1, "hour"] },
1348
- )) as string;
1349
-
1350
- const detail = await app.jobService.getExecution(id);
1351
- expect(detail.can).toEqual({ retry: false, cancel: true });
1352
- });
1353
- });
1354
-
1355
- // ----- Keyed job immediate dispatch -----
1356
-
1357
- describe("keyed job immediate dispatch", () => {
1358
- it("should dispatch and complete a keyed job without delay", async () => {
1359
- const handler = vi.fn();
1360
-
1361
- class App {
1362
- repo = $repository(jobExecutionEntity);
1363
- myJob = $job({
1364
- schema: t.object({ value: t.text() }),
1365
- handler,
1366
- });
1367
- }
1368
-
1369
- const alepha = Alepha.create().with(AlephaOrmPostgres);
1370
- const app = alepha.inject(App);
1371
- await alepha.start();
1372
-
1373
- const id = await app.myJob.push({ value: "test" }, { key: "my-key" });
1374
-
1375
- await vi.waitFor(async () => {
1376
- const exec = await app.repo.findById(id as string);
1377
- expect(exec?.status).toBe("completed");
1378
- });
1379
-
1380
- expect(handler).toHaveBeenCalledTimes(1);
1381
- });
1382
-
1383
- it("should not re-dispatch on duplicate keyed push", async () => {
1384
- const handler = vi.fn();
1385
-
1386
- class App {
1387
- repo = $repository(jobExecutionEntity);
1388
- myJob = $job({
1389
- schema: t.object({ value: t.text() }),
1390
- handler,
1391
- });
1392
- }
1393
-
1394
- const alepha = Alepha.create().with(AlephaOrmPostgres);
1395
- const app = alepha.inject(App);
1396
- await alepha.start();
1397
-
1398
- // First push with delay (stays scheduled, key active)
1399
- const id1 = await app.myJob.push(
1400
- { value: "first" },
1401
- { key: "dup-key", delay: [1, "hour"] },
1402
- );
1403
-
1404
- // Second push: same key, should return same ID and not re-dispatch
1405
- const id2 = await app.myJob.push(
1406
- { value: "second" },
1407
- { key: "dup-key", delay: [1, "hour"] },
1408
- );
1409
-
1410
- expect(id1).toBe(id2);
1411
- expect(handler).not.toHaveBeenCalled();
1412
- });
1413
- });
1414
-
1415
- // ----- Payload validation -----
1416
-
1417
- describe("payload validation", () => {
1418
- it("should reject invalid payload", async () => {
1419
- class App {
1420
- myJob = $job({
1421
- schema: t.object({ userId: t.text() }),
1422
- handler: async () => {},
1423
- });
1424
- }
1425
-
1426
- const alepha = Alepha.create().with(AlephaOrmPostgres);
1427
- const app = alepha.inject(App);
1428
- await alepha.start();
1429
-
1430
- await expect(app.myJob.push({ wrong: 123 } as any)).rejects.toThrow();
1431
- });
1432
- });
1433
-
1434
- // ----- Cancel edge cases -----
1435
-
1436
- describe("cancel edge cases", () => {
1437
- it("should throw when cancelling non-existent execution", async () => {
1438
- class App {
1439
- myJob = $job({
1440
- schema: t.object({ value: t.text() }),
1441
- handler: async () => {},
1442
- });
1443
- }
1444
-
1445
- const alepha = Alepha.create().with(AlephaOrmPostgres);
1446
- alepha.inject(App);
1447
- await alepha.start();
1448
-
1449
- const provider = alepha.inject(JobProvider);
1450
- await expect(
1451
- provider.cancel("00000000-0000-0000-0000-000000000000"),
1452
- ).rejects.toThrow(/not found/i);
1453
- });
1454
- });
1455
-
1456
- // ----- Key lifecycle -----
1457
-
1458
- describe("key lifecycle", () => {
1459
- it("should clear key on dead status", async () => {
1460
- class App {
1461
- repo = $repository(jobExecutionEntity);
1462
- myJob = $job({
1463
- schema: t.object({ value: t.text() }),
1464
- handler: async () => {
1465
- throw new Error("always fail");
1466
- },
1467
- });
1468
- }
1469
-
1470
- const alepha = Alepha.create().with(AlephaOrmPostgres);
1471
- const app = alepha.inject(App);
1472
- await alepha.start();
1473
-
1474
- const id = await app.myJob.push({ value: "test" }, { key: "dead-key" });
1475
-
1476
- await vi.waitFor(async () => {
1477
- const exec = await app.repo.findById(id as string);
1478
- expect(exec?.status).toBe("dead");
1479
- expect(exec?.key).toBeNull();
1480
- });
1481
- });
1482
-
1483
- it("should clear key on cancellation", async () => {
1484
- class App {
1485
- repo = $repository(jobExecutionEntity);
1486
- myJob = $job({
1487
- schema: t.object({ value: t.text() }),
1488
- handler: async () => {},
1489
- });
1490
- }
1491
-
1492
- const alepha = Alepha.create().with(AlephaOrmPostgres);
1493
- const app = alepha.inject(App);
1494
- await alepha.start();
1495
-
1496
- const id = await app.myJob.push(
1497
- { value: "test" },
1498
- { key: "cancel-key", delay: [1, "hour"] },
1499
- );
1500
-
1501
- await app.myJob.cancel(id as string);
1502
-
1503
- const exec = await app.repo.findById(id as string);
1504
- expect(exec?.key).toBeNull();
1505
- });
1506
- });
1507
-
1508
- // ----- Registration -----
1509
-
1510
- describe("registration", () => {
1511
- it("should throw on duplicate job registration", async () => {
1512
- class App {
1513
- job1 = $job({
1514
- schema: t.object({ value: t.text() }),
1515
- handler: async () => {},
1516
- });
1517
- }
1518
-
1519
- const alepha = Alepha.create().with(AlephaOrmPostgres);
1520
- alepha.inject(App);
1521
-
1522
- const provider = alepha.inject(JobProvider);
1523
- expect(() =>
1524
- provider.registerJob("App.job1", {
1525
- handler: async () => {},
1526
- }),
1527
- ).toThrow(/already registered/i);
1528
- });
1529
- });
1530
-
1531
- // ----- Recovery sweep -----
1532
-
1533
- describe("recovery sweep", () => {
1534
- it("should re-dispatch stale pending jobs", async () => {
1535
- const handler = vi.fn();
1536
-
1537
- class App {
1538
- repo = $repository(jobExecutionEntity);
1539
- myJob = $job({
1540
- schema: t.object({ value: t.text() }),
1541
- handler,
1542
- });
1543
- }
1544
-
1545
- const alepha = Alepha.create().with(AlephaOrmPostgres);
1546
- const app = alepha.inject(App);
1547
- await alepha.start();
1548
-
1549
- // Insert a stale pending record (10 min old, threshold is 5 min)
1550
- const staleTime = new Date(Date.now() - 600_000).toISOString();
1551
- await app.repo.create({
1552
- jobName: "App.myJob",
1553
- payload: { value: "stale" },
1554
- status: "pending",
1555
- priority: 2,
1556
- maxAttempts: 1,
1557
- createdAt: staleTime,
1558
- updatedAt: staleTime,
1559
- });
1560
-
1561
- // Trigger recovery sweep directly
1562
- const provider = alepha.inject(JobProvider) as any;
1563
- await provider.recoverySweep();
1564
-
1565
- await vi.waitFor(
1566
- async () => {
1567
- const completed = await app.repo.findMany({
1568
- where: { jobName: "App.myJob", status: "completed" },
1569
- });
1570
- expect(completed).toHaveLength(1);
1571
- },
1572
- { timeout: 5000 },
1573
- );
1574
- });
1575
-
1576
- it("should mark crashed running jobs as failed", async () => {
1577
- class App {
1578
- repo = $repository(jobExecutionEntity);
1579
- myJob = $job({
1580
- schema: t.object({ value: t.text() }),
1581
- handler: async () => {},
1582
- });
1583
- }
1584
-
1585
- const alepha = Alepha.create().with(AlephaOrmPostgres);
1586
- const app = alepha.inject(App);
1587
- await alepha.start();
1588
-
1589
- // Insert a crashed running record (1 hour old, threshold is 30 min)
1590
- const crashTime = new Date(Date.now() - 3_600_000).toISOString();
1591
- await app.repo.create({
1592
- jobName: "App.myJob",
1593
- payload: { value: "crashed" },
1594
- status: "running",
1595
- priority: 2,
1596
- attempt: 1,
1597
- maxAttempts: 1,
1598
- startedAt: crashTime,
1599
- workerId: "dead-worker",
1600
- createdAt: crashTime,
1601
- updatedAt: crashTime,
1602
- });
1603
-
1604
- const provider = alepha.inject(JobProvider) as any;
1605
- await provider.recoverySweep();
1606
-
1607
- await vi.waitFor(async () => {
1608
- const dead = await app.repo.findMany({
1609
- where: { jobName: "App.myJob", status: "dead" },
1610
- });
1611
- expect(dead).toHaveLength(1);
1612
- expect(dead[0].error).toContain("crashed");
1613
- });
1614
- });
1615
- });
1616
-
1617
- // ----- Delayed dispatch sweep -----
1618
-
1619
- describe("delayed dispatch sweep", () => {
1620
- it("should dispatch scheduled jobs when due", async () => {
1621
- const handler = vi.fn();
1622
-
1623
- class App {
1624
- repo = $repository(jobExecutionEntity);
1625
- myJob = $job({
1626
- schema: t.object({ value: t.text() }),
1627
- handler,
1628
- });
1629
- }
1630
-
1631
- const alepha = Alepha.create().with(AlephaOrmPostgres);
1632
- const app = alepha.inject(App);
1633
- await alepha.start();
1634
-
1635
- // Insert a scheduled job with scheduledAt in the past
1636
- const pastTime = new Date(Date.now() - 60_000).toISOString();
1637
- await app.repo.create({
1638
- jobName: "App.myJob",
1639
- payload: { value: "delayed" },
1640
- status: "scheduled",
1641
- priority: 2,
1642
- maxAttempts: 1,
1643
- scheduledAt: pastTime,
1644
- });
1645
-
1646
- const provider = alepha.inject(JobProvider) as any;
1647
- await provider.delayedDispatchSweep();
1648
-
1649
- await vi.waitFor(
1650
- async () => {
1651
- const completed = await app.repo.findMany({
1652
- where: { jobName: "App.myJob", status: "completed" },
1653
- });
1654
- expect(completed).toHaveLength(1);
1655
- },
1656
- { timeout: 5000 },
1657
- );
1658
- });
1659
-
1660
- it("should dispatch retrying jobs when due", async () => {
1661
- const handler = vi.fn();
1662
-
1663
- class App {
1664
- repo = $repository(jobExecutionEntity);
1665
- myJob = $job({
1666
- schema: t.object({ value: t.text() }),
1667
- handler,
1668
- });
1669
- }
1670
-
1671
- const alepha = Alepha.create().with(AlephaOrmPostgres);
1672
- const app = alepha.inject(App);
1673
- await alepha.start();
1674
-
1675
- const pastTime = new Date(Date.now() - 60_000).toISOString();
1676
- await app.repo.create({
1677
- jobName: "App.myJob",
1678
- payload: { value: "retry" },
1679
- status: "retrying",
1680
- priority: 2,
1681
- attempt: 1,
1682
- maxAttempts: 3,
1683
- scheduledAt: pastTime,
1684
- });
1685
-
1686
- const provider = alepha.inject(JobProvider) as any;
1687
- await provider.delayedDispatchSweep();
1688
-
1689
- await vi.waitFor(
1690
- async () => {
1691
- const completed = await app.repo.findMany({
1692
- where: { jobName: "App.myJob", status: "completed" },
1693
- });
1694
- expect(completed).toHaveLength(1);
1695
- },
1696
- { timeout: 5000 },
1697
- );
1698
- });
1699
- });
1700
-
1701
- // ----- Timeout + retry integration -----
1702
-
1703
- describe("timeout + retry integration", () => {
1704
- it("should retry after timeout failure", async () => {
1705
- let callCount = 0;
1706
-
1707
- class App {
1708
- repo = $repository(jobExecutionEntity);
1709
- myJob = $job({
1710
- schema: t.object({ value: t.text() }),
1711
- timeout: [50, "millisecond"],
1712
- retry: { retries: 1, backoff: [10, "millisecond"] },
1713
- handler: async ({ signal }) => {
1714
- callCount++;
1715
- if (callCount === 1) {
1716
- // First call: hang until timeout
1717
- await new Promise<void>((resolve) => {
1718
- const check = () => {
1719
- if (signal.aborted) resolve();
1720
- else setTimeout(check, 10);
1721
- };
1722
- check();
1723
- });
1724
- throw new Error("timed out");
1725
- }
1726
- },
1727
- });
1728
- }
1729
-
1730
- const alepha = Alepha.create().with(AlephaOrmPostgres);
1731
- const app = alepha.inject(App);
1732
- await alepha.start();
1733
-
1734
- await app.myJob.push({ value: "test" });
1735
-
1736
- await vi.waitFor(
1737
- async () => {
1738
- const completed = await app.repo.findMany({
1739
- where: { jobName: "App.myJob", status: "completed" },
1740
- });
1741
- expect(completed).toHaveLength(1);
1742
- expect(completed[0].attempt).toBe(2);
1743
- },
1744
- { timeout: 5000 },
1745
- );
1746
-
1747
- expect(callCount).toBe(2);
1748
- });
1749
- });
1750
-
1751
- // ----- Log truncation -----
1752
-
1753
- describe("log truncation", () => {
1754
- it("should truncate logs exceeding maxEntries", async () => {
1755
- class App {
1756
- log = $logger();
1757
- repo = $repository(jobExecutionEntity);
1758
- logRepo = $repository(jobExecutionLogEntity);
1759
- verboseJob = $job({
1760
- schema: t.object({ value: t.text() }),
1761
- handler: async () => {
1762
- for (let i = 0; i < 200; i++) {
1763
- this.log.info(`Log entry ${i}`);
1764
- }
1765
- },
1766
- });
1767
- }
1768
-
1769
- const alepha = Alepha.create().with(AlephaOrmPostgres);
1770
- const app = alepha.inject(App);
1771
- await alepha.start();
1772
-
1773
- await app.verboseJob.push({ value: "test" });
1774
-
1775
- await vi.waitFor(async () => {
1776
- const executions = await app.repo.findMany({
1777
- where: { jobName: "App.verboseJob", status: "completed" },
1778
- });
1779
- expect(executions).toHaveLength(1);
1780
-
1781
- const logEntry = await app.logRepo.findById(executions[0].id);
1782
- expect(logEntry).toBeDefined();
1783
- // Default maxEntries is 100, plus 1 truncation warning
1784
- expect(logEntry!.logs.length).toBeLessThanOrEqual(101);
1785
- expect(logEntry!.logs[logEntry!.logs.length - 1].message).toContain(
1786
- "truncated",
1787
- );
1788
- });
1789
- });
1790
- });
1791
-
1792
- // ----- job:cancel event -----
1793
-
1794
- describe("job:cancel event", () => {
1795
- it("should emit job:cancel when a running job is cancelled", async () => {
1796
- const cancelHandler = vi.fn();
1797
-
1798
- class App {
1799
- repo = $repository(jobExecutionEntity);
1800
- myJob = $job({
1801
- schema: t.object({ value: t.text() }),
1802
- handler: async ({ signal }) => {
1803
- await new Promise<void>((resolve) => {
1804
- const check = () => {
1805
- if (signal.aborted) resolve();
1806
- else setTimeout(check, 10);
1807
- };
1808
- check();
1809
- });
1810
- // Delay to let cancel()'s DB update complete before the catch
1811
- // block checks execution status (avoids race condition)
1812
- await new Promise((r) => setTimeout(r, 50));
1813
- throw new Error("cancelled");
1814
- },
1815
- });
1816
- }
1817
-
1818
- const alepha = Alepha.create().with(AlephaOrmPostgres);
1819
- const app = alepha.inject(App);
1820
- alepha.events.on("job:cancel", cancelHandler);
1821
- await alepha.start();
1822
-
1823
- const id = (await app.myJob.push({ value: "test" })) as string;
1824
-
1825
- await vi.waitFor(async () => {
1826
- const exec = await app.repo.findById(id);
1827
- expect(exec?.status).toBe("running");
1828
- });
1829
-
1830
- await app.myJob.cancel(id);
1831
-
1832
- await vi.waitFor(() => {
1833
- expect(cancelHandler).toHaveBeenCalledTimes(1);
1834
- expect(cancelHandler).toHaveBeenCalledWith(
1835
- expect.objectContaining({
1836
- name: "App.myJob",
1837
- executionId: id,
1838
- }),
1839
- );
18
+ describe("$job registration validation", () => {
19
+ it("rejects jobs declaring both cron and schema", async ({ expect }) => {
20
+ const alepha = makeApp();
21
+ class App {
22
+ bad = $job({
23
+ cron: "* * * * *",
24
+ schema: t.object({ id: t.text() }),
25
+ handler: async () => {},
1840
26
  });
1841
- });
1842
- });
1843
-
1844
- // ----- JobService -----
1845
-
1846
- describe("JobService", () => {
1847
- it("should retry a dead execution", async () => {
1848
- let callCount = 0;
1849
-
1850
- class App {
1851
- repo = $repository(jobExecutionEntity);
1852
- jobService = $inject(JobService);
1853
- myJob = $job({
1854
- schema: t.object({ value: t.text() }),
1855
- handler: async () => {
1856
- callCount++;
1857
- if (callCount === 1) throw new Error("first fail");
1858
- },
1859
- });
1860
- }
1861
-
1862
- const alepha = Alepha.create().with(AlephaOrmPostgres);
1863
- const app = alepha.inject(App);
1864
- await alepha.start();
1865
-
1866
- await app.myJob.push({ value: "test" });
1867
-
1868
- await vi.waitFor(async () => {
1869
- const dead = await app.repo.findMany({
1870
- where: { jobName: "App.myJob", status: "dead" },
1871
- });
1872
- expect(dead).toHaveLength(1);
1873
- });
1874
-
1875
- const dead = await app.repo.findMany({
1876
- where: { jobName: "App.myJob", status: "dead" },
1877
- });
1878
- await app.jobService.retryExecution(dead[0].id);
1879
-
1880
- await vi.waitFor(async () => {
1881
- const completed = await app.repo.findMany({
1882
- where: { jobName: "App.myJob", status: "completed" },
1883
- });
1884
- expect(completed).toHaveLength(1);
1885
- });
1886
-
1887
- expect(callCount).toBe(2);
1888
- });
1889
-
1890
- it("should throw when retrying a running execution", async () => {
1891
- class App {
1892
- repo = $repository(jobExecutionEntity);
1893
- jobService = $inject(JobService);
1894
- myJob = $job({
1895
- schema: t.object({ value: t.text() }),
1896
- handler: async ({ signal }) => {
1897
- await new Promise<void>((resolve) => {
1898
- const check = () => {
1899
- if (signal.aborted) resolve();
1900
- else setTimeout(check, 10);
1901
- };
1902
- check();
1903
- });
1904
- },
1905
- });
1906
- }
1907
-
1908
- const alepha = Alepha.create().with(AlephaOrmPostgres);
1909
- const app = alepha.inject(App);
1910
- await alepha.start();
1911
-
1912
- const id = (await app.myJob.push({ value: "test" })) as string;
1913
-
1914
- await vi.waitFor(async () => {
1915
- const exec = await app.repo.findById(id);
1916
- expect(exec?.status).toBe("running");
1917
- });
1918
-
1919
- await expect(app.jobService.retryExecution(id)).rejects.toThrow(
1920
- /Cannot retry/,
1921
- );
1922
-
1923
- // Cleanup
1924
- await app.myJob.cancel(id);
1925
- });
27
+ }
28
+ expect(() => alepha.inject(App)).toThrow(AlephaError);
1926
29
  });
1927
30
 
1928
- // ----- Inline execution (no queue) -----
1929
-
1930
- describe("inline execution (ALEPHA_JOBS_QUEUE=0)", () => {
1931
- it("should execute jobs inline without queue provider", async () => {
1932
- const handler = vi.fn();
1933
-
1934
- class App {
1935
- repo = $repository(jobExecutionEntity);
1936
- myJob = $job({
1937
- schema: t.object({ userId: t.text() }),
1938
- handler,
1939
- });
1940
- }
1941
-
1942
- const alepha = Alepha.create({
1943
- env: { ALEPHA_JOBS_QUEUE: 0 },
1944
- }).with(AlephaOrmPostgres);
1945
- const app = alepha.inject(App);
1946
- await alepha.start();
1947
-
1948
- // JobQueueProvider should NOT be registered
1949
- expect(alepha.has(JobQueueProvider)).toBe(false);
1950
-
1951
- await app.myJob.push({ userId: "inline-1" });
1952
-
1953
- await vi.waitFor(() => {
1954
- expect(handler).toHaveBeenCalledTimes(1);
1955
- });
1956
-
1957
- const args = handler.mock.calls[0][0];
1958
- expect(args.items).toHaveLength(1);
1959
- expect(args.items[0].payload).toEqual({ userId: "inline-1" });
1960
- });
1961
-
1962
- it("should handle multiple inline pushes", async () => {
1963
- const handler = vi.fn();
1964
-
1965
- class App {
1966
- myJob = $job({
1967
- schema: t.object({ id: t.text() }),
1968
- handler,
1969
- });
1970
- }
1971
-
1972
- const alepha = Alepha.create({
1973
- env: { ALEPHA_JOBS_QUEUE: 0 },
1974
- }).with(AlephaOrmPostgres);
1975
- const app = alepha.inject(App);
1976
- await alepha.start();
1977
-
1978
- const ids = await app.myJob.push([{ id: "1" }, { id: "2" }, { id: "3" }]);
1979
- expect(ids).toHaveLength(3);
1980
-
1981
- await vi.waitFor(() => {
1982
- expect(handler).toHaveBeenCalledTimes(3);
1983
- });
1984
- });
1985
-
1986
- it("should retry inline jobs on failure", async () => {
1987
- let attempt = 0;
1988
- const handler = vi.fn(async () => {
1989
- attempt++;
1990
- if (attempt < 3) {
1991
- throw new Error("temporary failure");
1992
- }
31
+ it("rejects jobs with neither cron nor schema", async ({ expect }) => {
32
+ const alepha = makeApp();
33
+ class App {
34
+ bad = $job({
35
+ handler: async () => {},
1993
36
  });
1994
-
1995
- class App {
1996
- repo = $repository(jobExecutionEntity);
1997
- myJob = $job({
1998
- schema: t.object({ value: t.text() }),
1999
- retry: { retries: 3, backoff: [100, "millisecond"] },
2000
- handler,
2001
- });
2002
- }
2003
-
2004
- const alepha = Alepha.create({
2005
- env: { ALEPHA_JOBS_QUEUE: 0 },
2006
- }).with(AlephaOrmPostgres);
2007
- const app = alepha.inject(App);
2008
- await alepha.start();
2009
-
2010
- const id = (await app.myJob.push({ value: "retry-test" })) as string;
2011
-
2012
- await vi.waitFor(
2013
- async () => {
2014
- const exec = await app.repo.findById(id);
2015
- expect(exec?.status).toBe("completed");
2016
- },
2017
- { timeout: 5000 },
2018
- );
2019
-
2020
- expect(handler).toHaveBeenCalledTimes(3);
2021
- });
37
+ }
38
+ expect(() => alepha.inject(App)).toThrow(AlephaError);
2022
39
  });
40
+ });
2023
41
 
2024
- // ----- Concurrency enforcement -----
2025
-
2026
- describe("concurrency enforcement", () => {
2027
- it("should execute serially with concurrency: 1", async () => {
2028
- let maxRunning = 0;
2029
- let currentRunning = 0;
2030
-
2031
- class App {
2032
- repo = $repository(jobExecutionEntity);
2033
- myJob = $job({
2034
- schema: t.object({ id: t.text() }),
2035
- concurrency: 1,
2036
- handler: async () => {
2037
- currentRunning++;
2038
- maxRunning = Math.max(maxRunning, currentRunning);
2039
- await new Promise((r) => setTimeout(r, 50));
2040
- currentRunning--;
2041
- },
2042
- });
2043
- }
2044
-
2045
- const alepha = Alepha.create().with(AlephaOrmPostgres);
2046
- const app = alepha.inject(App);
2047
- await alepha.start();
2048
-
2049
- await app.myJob.push([{ id: "1" }, { id: "2" }, { id: "3" }]);
2050
-
2051
- await vi.waitFor(
2052
- async () => {
2053
- const completed = await app.repo.findMany({
2054
- where: { jobName: "App.myJob", status: "completed" },
2055
- });
2056
- expect(completed).toHaveLength(3);
2057
- },
2058
- { timeout: 10_000 },
2059
- );
2060
-
2061
- expect(maxRunning).toBe(1);
2062
- });
2063
-
2064
- it("should allow parallel execution up to concurrency limit", async () => {
2065
- let maxRunning = 0;
2066
- let currentRunning = 0;
2067
-
2068
- class App {
2069
- repo = $repository(jobExecutionEntity);
2070
- myJob = $job({
2071
- schema: t.object({ id: t.text() }),
2072
- concurrency: 2,
2073
- handler: async () => {
2074
- currentRunning++;
2075
- maxRunning = Math.max(maxRunning, currentRunning);
2076
- await new Promise((r) => setTimeout(r, 50));
2077
- currentRunning--;
2078
- },
2079
- });
2080
- }
2081
-
2082
- const alepha = Alepha.create().with(AlephaOrmPostgres);
2083
- const app = alepha.inject(App);
2084
- await alepha.start();
2085
-
2086
- await app.myJob.push([
2087
- { id: "1" },
2088
- { id: "2" },
2089
- { id: "3" },
2090
- { id: "4" },
2091
- ]);
2092
-
2093
- await vi.waitFor(
2094
- async () => {
2095
- const completed = await app.repo.findMany({
2096
- where: { jobName: "App.myJob", status: "completed" },
2097
- });
2098
- expect(completed).toHaveLength(4);
2099
- },
2100
- { timeout: 10_000 },
2101
- );
2102
-
2103
- expect(maxRunning).toBeLessThanOrEqual(2);
2104
- });
2105
-
2106
- it("should dispatch next pending after completion", async () => {
2107
- const order: string[] = [];
2108
-
2109
- class App {
2110
- repo = $repository(jobExecutionEntity);
2111
- myJob = $job({
2112
- schema: t.object({ id: t.text() }),
2113
- concurrency: 1,
2114
- handler: async ({ items }) => {
2115
- order.push(items[0]?.payload.id ?? "cron");
2116
- },
2117
- });
2118
- }
2119
-
2120
- const alepha = Alepha.create().with(AlephaOrmPostgres);
2121
- const app = alepha.inject(App);
2122
- await alepha.start();
2123
-
2124
- await app.myJob.push({ id: "a" });
2125
- await app.myJob.push({ id: "b" });
2126
-
2127
- await vi.waitFor(
2128
- async () => {
2129
- const completed = await app.repo.findMany({
2130
- where: { jobName: "App.myJob", status: "completed" },
2131
- });
2132
- expect(completed).toHaveLength(2);
42
+ // ---------------------------------------------------------------------------
43
+
44
+ describe("$job — cron mode", () => {
45
+ it("runs handler inline on trigger, no DB row on success by default", async ({
46
+ expect,
47
+ }) => {
48
+ const alepha = makeApp();
49
+ let calls = 0;
50
+ class App {
51
+ executions = $repository(jobExecutionEntity);
52
+ tick = $job({
53
+ cron: "0 0 * * *",
54
+ handler: async () => {
55
+ calls++;
2133
56
  },
2134
- { timeout: 10_000 },
2135
- );
2136
-
2137
- expect(order).toEqual(["a", "b"]);
2138
- });
2139
- });
2140
-
2141
- // ----- Pause / Resume -----
2142
-
2143
- describe("pause / resume", () => {
2144
- it("should not dispatch while paused", async () => {
2145
- const handler = vi.fn();
2146
-
2147
- class App {
2148
- repo = $repository(jobExecutionEntity);
2149
- myJob = $job({
2150
- schema: t.object({ value: t.text() }),
2151
- handler,
2152
- });
2153
- }
2154
-
2155
- const alepha = Alepha.create().with(AlephaOrmPostgres);
2156
- const app = alepha.inject(App);
2157
- await alepha.start();
2158
-
2159
- app.myJob.pause();
2160
- expect(app.myJob.paused).toBe(true);
2161
-
2162
- await app.myJob.push({ value: "test" });
2163
-
2164
- // Item should stay pending
2165
- const executions = await app.repo.findMany({
2166
- where: { jobName: "App.myJob" },
2167
57
  });
2168
- expect(executions).toHaveLength(1);
2169
- expect(executions[0].status).toBe("pending");
2170
- expect(handler).not.toHaveBeenCalled();
2171
-
2172
- // Cleanup: resume so nothing leaks
2173
- await app.myJob.resume();
2174
- });
2175
-
2176
- it("should accept pushes while paused and process on resume", async () => {
2177
- const handler = vi.fn();
2178
-
2179
- class App {
2180
- repo = $repository(jobExecutionEntity);
2181
- myJob = $job({
2182
- schema: t.object({ value: t.text() }),
2183
- handler,
2184
- });
2185
- }
2186
-
2187
- const alepha = Alepha.create().with(AlephaOrmPostgres);
2188
- const app = alepha.inject(App);
2189
- await alepha.start();
2190
-
2191
- app.myJob.pause();
2192
-
2193
- await app.myJob.push({ value: "a" });
2194
- await app.myJob.push({ value: "b" });
2195
-
2196
- expect(handler).not.toHaveBeenCalled();
2197
-
2198
- await app.myJob.resume();
2199
- expect(app.myJob.paused).toBe(false);
2200
-
2201
- await vi.waitFor(
2202
- async () => {
2203
- const completed = await app.repo.findMany({
2204
- where: { jobName: "App.myJob", status: "completed" },
2205
- });
2206
- expect(completed).toHaveLength(2);
2207
- },
2208
- { timeout: 10_000 },
2209
- );
2210
-
2211
- expect(handler).toHaveBeenCalledTimes(2);
2212
- });
2213
-
2214
- it("should report paused status via JobService registry", async () => {
2215
- class App {
2216
- jobService = $inject(JobService);
2217
- myJob = $job({
2218
- schema: t.object({ value: t.text() }),
2219
- handler: async () => {},
2220
- });
2221
- }
2222
-
2223
- const alepha = Alepha.create().with(AlephaOrmPostgres);
2224
- const app = alepha.inject(App);
2225
- await alepha.start();
2226
-
2227
- const before = app.jobService.getRegistry();
2228
- expect(before[0].paused).toBe(false);
2229
-
2230
- app.myJob.pause();
2231
-
2232
- const after = app.jobService.getRegistry();
2233
- expect(after[0].paused).toBe(true);
2234
-
2235
- await app.myJob.resume();
2236
- });
2237
-
2238
- it("should expose paused jobs via getPausedJobs", async () => {
2239
- class App {
2240
- jobService = $inject(JobService);
2241
- jobA = $job({
2242
- schema: t.object({ v: t.text() }),
2243
- handler: async () => {},
2244
- });
2245
- jobB = $job({
2246
- schema: t.object({ v: t.text() }),
2247
- handler: async () => {},
2248
- });
2249
- }
2250
-
2251
- const alepha = Alepha.create().with(AlephaOrmPostgres);
2252
- const app = alepha.inject(App);
2253
- await alepha.start();
2254
-
2255
- app.jobA.pause();
2256
- expect(app.jobService.getPausedJobs()).toEqual(["App.jobA"]);
2257
-
2258
- app.jobB.pause();
2259
- expect(app.jobService.getPausedJobs()).toHaveLength(2);
2260
-
2261
- await app.jobA.resume();
2262
- expect(app.jobService.getPausedJobs()).toEqual(["App.jobB"]);
2263
-
2264
- await app.jobB.resume();
2265
- });
2266
- });
2267
-
2268
- // ----- Priority-ordered dispatch -----
2269
-
2270
- describe("priority-ordered dispatch", () => {
2271
- it("should dispatch higher priority jobs first", async () => {
2272
- const order: string[] = [];
2273
-
2274
- class App {
2275
- repo = $repository(jobExecutionEntity);
2276
- myJob = $job({
2277
- schema: t.object({ label: t.text() }),
2278
- concurrency: 1,
2279
- handler: async ({ items }) => {
2280
- order.push(items[0].payload.label);
2281
- },
2282
- });
2283
- }
2284
-
2285
- const alepha = Alepha.create().with(AlephaOrmPostgres);
2286
- const app = alepha.inject(App);
2287
- await alepha.start();
2288
-
2289
- // Pause to queue items without dispatching
2290
- app.myJob.pause();
2291
-
2292
- await app.myJob.push({ label: "low" }, { priority: "low" });
2293
- await app.myJob.push({ label: "critical" }, { priority: "critical" });
2294
- await app.myJob.push({ label: "normal" });
2295
-
2296
- // Resume triggers dispatch in priority order
2297
- await app.myJob.resume();
2298
-
2299
- await vi.waitFor(
2300
- async () => {
2301
- const completed = await app.repo.findMany({
2302
- where: { jobName: "App.myJob", status: "completed" },
2303
- });
2304
- expect(completed).toHaveLength(3);
2305
- },
2306
- { timeout: 10_000 },
2307
- );
2308
-
2309
- expect(order).toEqual(["critical", "normal", "low"]);
2310
- });
2311
- });
2312
-
2313
- // ----- Bulk pushMany -----
2314
-
2315
- describe("bulk pushMany", () => {
2316
- it("should bulk-create non-keyed items", async () => {
2317
- const handler = vi.fn();
2318
-
2319
- class App {
2320
- repo = $repository(jobExecutionEntity);
2321
- myJob = $job({
2322
- schema: t.object({ n: t.integer() }),
2323
- handler,
2324
- });
2325
- }
2326
-
2327
- const alepha = Alepha.create().with(AlephaOrmPostgres);
2328
- const app = alepha.inject(App);
2329
- await alepha.start();
2330
-
2331
- const ids = await app.myJob.pushMany([
2332
- { payload: { n: 1 } },
2333
- { payload: { n: 2 } },
2334
- { payload: { n: 3 } },
2335
- ]);
2336
-
2337
- expect(ids).toHaveLength(3);
2338
-
2339
- await vi.waitFor(
2340
- async () => {
2341
- const completed = await app.repo.findMany({
2342
- where: { jobName: "App.myJob", status: "completed" },
2343
- });
2344
- expect(completed).toHaveLength(3);
58
+ }
59
+ const app = alepha.inject(App);
60
+ await alepha.start();
61
+ await app.tick.trigger();
62
+ expect(calls).toBe(1);
63
+ const rows = await app.executions.findMany({
64
+ where: { jobName: { eq: "App.tick" } },
65
+ });
66
+ expect(rows).toHaveLength(0);
67
+ });
68
+
69
+ it("records an error row when cron handler throws", async ({ expect }) => {
70
+ const alepha = makeApp();
71
+ class App {
72
+ executions = $repository(jobExecutionEntity);
73
+ tick = $job({
74
+ cron: "0 0 * * *",
75
+ handler: async () => {
76
+ throw new Error("boom");
2345
77
  },
2346
- { timeout: 10_000 },
2347
- );
2348
- });
2349
-
2350
- it("should handle mixed keyed and non-keyed items", async () => {
2351
- class App {
2352
- repo = $repository(jobExecutionEntity);
2353
- myJob = $job({
2354
- schema: t.object({ v: t.text() }),
2355
- handler: async () => {},
2356
- });
2357
- }
2358
-
2359
- const alepha = Alepha.create().with(AlephaOrmPostgres);
2360
- const app = alepha.inject(App);
2361
- await alepha.start();
2362
-
2363
- const ids = await app.myJob.pushMany([
2364
- { payload: { v: "keyed" }, key: "k1", delay: [1, "hour"] },
2365
- { payload: { v: "bulk1" } },
2366
- { payload: { v: "bulk2" } },
2367
- ]);
2368
-
2369
- expect(ids).toHaveLength(3);
2370
-
2371
- const keyed = await app.repo.findById(ids[0]);
2372
- expect(keyed?.key).toBe("k1");
2373
- expect(keyed?.status).toBe("scheduled");
2374
-
2375
- await vi.waitFor(async () => {
2376
- const completed = await app.repo.findMany({
2377
- where: { jobName: "App.myJob", status: "completed" },
2378
- });
2379
- expect(completed).toHaveLength(2);
2380
- });
2381
- });
2382
-
2383
- it("should reject all items if one payload is invalid", async () => {
2384
- class App {
2385
- repo = $repository(jobExecutionEntity);
2386
- myJob = $job({
2387
- schema: t.object({ n: t.integer() }),
2388
- handler: async () => {},
2389
- });
2390
- }
2391
-
2392
- const alepha = Alepha.create().with(AlephaOrmPostgres);
2393
- const app = alepha.inject(App);
2394
- await alepha.start();
2395
-
2396
- await expect(
2397
- app.myJob.pushMany([
2398
- { payload: { n: 1 } },
2399
- { payload: { n: "not-a-number" as any } },
2400
- ]),
2401
- ).rejects.toThrow();
2402
-
2403
- // First item should not have been created either (validation is upfront)
2404
- const all = await app.repo.findMany({
2405
- where: { jobName: "App.myJob" },
2406
- });
2407
- expect(all).toHaveLength(0);
2408
- });
2409
-
2410
- it("should return empty array for empty input", async () => {
2411
- class App {
2412
- myJob = $job({
2413
- schema: t.object({ v: t.text() }),
2414
- handler: async () => {},
2415
- });
2416
- }
2417
-
2418
- const alepha = Alepha.create().with(AlephaOrmPostgres);
2419
- const app = alepha.inject(App);
2420
- await alepha.start();
2421
-
2422
- const ids = await app.myJob.pushMany([]);
2423
- expect(ids).toEqual([]);
2424
- });
2425
- });
2426
-
2427
- // ----- Graceful drain -----
2428
-
2429
- describe("graceful drain", () => {
2430
- it("should wait for in-flight jobs before aborting on stop", async () => {
2431
- let completed = false;
2432
-
2433
- class App {
2434
- repo = $repository(jobExecutionEntity);
2435
- myJob = $job({
2436
- schema: t.object({ value: t.text() }),
2437
- handler: async () => {
2438
- await new Promise((r) => setTimeout(r, 100));
2439
- completed = true;
2440
- },
2441
- });
2442
- }
2443
-
2444
- const alepha = Alepha.create().with(AlephaOrmPostgres);
2445
- const app = alepha.inject(App);
2446
- await alepha.start();
2447
-
2448
- await app.myJob.push({ value: "test" });
2449
-
2450
- await vi.waitFor(async () => {
2451
- const running = await app.repo.findMany({
2452
- where: { jobName: "App.myJob", status: "running" },
2453
- });
2454
- expect(running).toHaveLength(1);
2455
78
  });
2456
-
2457
- // Stop should drain (wait for the in-flight job)
2458
- await alepha.stop();
2459
-
2460
- expect(completed).toBe(true);
2461
- });
2462
-
2463
- it("should stop cleanly when no jobs are in-flight", async () => {
2464
- class App {
2465
- myJob = $job({
2466
- schema: t.object({ value: t.text() }),
2467
- handler: async () => {},
2468
- });
2469
- }
2470
-
2471
- const alepha = Alepha.create().with(AlephaOrmPostgres);
2472
- alepha.inject(App);
2473
- await alepha.start();
2474
- await alepha.stop();
2475
- });
79
+ }
80
+ const app = alepha.inject(App);
81
+ await alepha.start();
82
+ await app.tick.trigger();
83
+ const rows = await app.executions.findMany({
84
+ where: { jobName: { eq: "App.tick" } },
85
+ });
86
+ expect(rows).toHaveLength(1);
87
+ expect(rows[0].status).toBe("error");
88
+ expect(rows[0].error).toBe("boom");
89
+ });
90
+
91
+ it("records a success row when record: 'all'", async ({ expect }) => {
92
+ const alepha = makeApp();
93
+ class App {
94
+ executions = $repository(jobExecutionEntity);
95
+ tick = $job({
96
+ cron: "0 0 * * *",
97
+ record: "all",
98
+ handler: async () => {},
99
+ });
100
+ }
101
+ const app = alepha.inject(App);
102
+ await alepha.start();
103
+ await app.tick.trigger();
104
+ const rows = await app.executions.findMany({
105
+ where: { jobName: { eq: "App.tick" } },
106
+ });
107
+ expect(rows).toHaveLength(1);
108
+ expect(rows[0].status).toBe("ok");
2476
109
  });
110
+ });
2477
111
 
2478
- // ----- JobService reporting -----
2479
-
2480
- describe("JobService reporting", () => {
2481
- it("should find executions with status filter", async () => {
2482
- class App {
2483
- repo = $repository(jobExecutionEntity);
2484
- jobService = $inject(JobService);
2485
- myJob = $job({
2486
- schema: t.object({ value: t.text() }),
2487
- handler: async () => {},
2488
- });
2489
- }
2490
-
2491
- const alepha = Alepha.create().with(AlephaOrmPostgres);
2492
- const app = alepha.inject(App);
2493
- await alepha.start();
2494
-
2495
- await app.myJob.push({ value: "test" });
2496
-
2497
- await vi.waitFor(async () => {
2498
- const completed = await app.repo.findMany({
2499
- where: { jobName: "App.myJob", status: "completed" },
2500
- });
2501
- expect(completed).toHaveLength(1);
2502
- });
2503
-
2504
- // Push a delayed one (stays scheduled)
2505
- await app.myJob.push({ value: "later" }, { delay: [1, "hour"] });
2506
-
2507
- const completedPage = await app.jobService.findExecutions({
2508
- status: "completed",
2509
- });
2510
- expect(completedPage.content.length).toBe(1);
2511
- expect(completedPage.content[0].status).toBe("completed");
2512
-
2513
- const scheduledPage = await app.jobService.findExecutions({
2514
- status: "scheduled",
2515
- });
2516
- expect(scheduledPage.content.length).toBe(1);
2517
- });
2518
-
2519
- it("should find executions with priority filter", async () => {
2520
- class App {
2521
- repo = $repository(jobExecutionEntity);
2522
- jobService = $inject(JobService);
2523
- myJob = $job({
2524
- schema: t.object({ value: t.text() }),
2525
- handler: async () => {},
2526
- });
2527
- }
2528
-
2529
- const alepha = Alepha.create().with(AlephaOrmPostgres);
2530
- const app = alepha.inject(App);
2531
- await alepha.start();
2532
-
2533
- await app.myJob.push(
2534
- { value: "hi" },
2535
- { priority: "high", delay: [1, "hour"] },
2536
- );
2537
- await app.myJob.push(
2538
- { value: "lo" },
2539
- { priority: "low", delay: [1, "hour"] },
2540
- );
2541
-
2542
- const highPage = await app.jobService.findExecutions({
2543
- priority: "high",
2544
- });
2545
- expect(highPage.content).toHaveLength(1);
2546
- expect(highPage.content[0].priority).toBe(1);
2547
- });
2548
-
2549
- it("should return execution detail with logs", async () => {
2550
- class App {
2551
- log = $logger();
2552
- repo = $repository(jobExecutionEntity);
2553
- logRepo = $repository(jobExecutionLogEntity);
2554
- jobService = $inject(JobService);
2555
- myJob = $job({
2556
- schema: t.object({ value: t.text() }),
2557
- handler: async () => {
2558
- this.log.info("hello from handler");
2559
- },
2560
- });
2561
- }
2562
-
2563
- const alepha = Alepha.create().with(AlephaOrmPostgres);
2564
- const app = alepha.inject(App);
2565
- await alepha.start();
2566
-
2567
- await app.myJob.push({ value: "test" });
2568
-
2569
- await vi.waitFor(async () => {
2570
- const completed = await app.repo.findMany({
2571
- where: { jobName: "App.myJob", status: "completed" },
2572
- });
2573
- expect(completed).toHaveLength(1);
112
+ // ---------------------------------------------------------------------------
113
+
114
+ describe("$job — queue mode (outbox)", () => {
115
+ it("push creates a pending row then deletes on success by default", async ({
116
+ expect,
117
+ }) => {
118
+ const alepha = makeApp();
119
+ let received: { n: number } | undefined;
120
+ class App {
121
+ executions = $repository(jobExecutionEntity);
122
+ work = $job({
123
+ schema: t.object({ n: t.integer() }),
124
+ handler: async ({ payload }) => {
125
+ received = payload;
126
+ },
2574
127
  });
2575
-
2576
- const completed = await app.repo.findMany({
2577
- where: { jobName: "App.myJob", status: "completed" },
128
+ }
129
+ const app = alepha.inject(App);
130
+ await alepha.start();
131
+ await app.work.push({ n: 42 });
132
+
133
+ // Memory queue is synchronous-ish; processing completes promptly.
134
+ await new Promise((r) => setTimeout(r, 50));
135
+
136
+ expect(received).toEqual({ n: 42 });
137
+ const rows = await app.executions.findMany({
138
+ where: { jobName: { eq: "App.work" } },
139
+ });
140
+ expect(rows).toHaveLength(0);
141
+ });
142
+
143
+ it("push keeps the row as 'ok' when record: 'all'", async ({ expect }) => {
144
+ const alepha = makeApp();
145
+ class App {
146
+ executions = $repository(jobExecutionEntity);
147
+ work = $job({
148
+ schema: t.object({ n: t.integer() }),
149
+ record: "all",
150
+ handler: async () => {},
151
+ });
152
+ }
153
+ const app = alepha.inject(App);
154
+ await alepha.start();
155
+ await app.work.push({ n: 1 });
156
+ await new Promise((r) => setTimeout(r, 50));
157
+ const rows = await app.executions.findMany({
158
+ where: { jobName: { eq: "App.work" } },
159
+ });
160
+ expect(rows).toHaveLength(1);
161
+ expect(rows[0].status).toBe("ok");
162
+ });
163
+
164
+ it("key-based dedup: second push with same key returns the same id while in flight", async ({
165
+ expect,
166
+ }) => {
167
+ const alepha = makeApp();
168
+ class App {
169
+ work = $job({
170
+ schema: t.object({ v: t.integer() }),
171
+ handler: async () => {},
172
+ });
173
+ }
174
+ const app = alepha.inject(App);
175
+ await alepha.start();
176
+ // Delay the first push so it stays in 'scheduled' state.
177
+ // While the row exists with a key, a second push should return the same id.
178
+ const id1 = await app.work.push(
179
+ { v: 1 },
180
+ { key: "dedup-1", delay: [1, "hour"] },
181
+ );
182
+ const id2 = await app.work.push(
183
+ { v: 2 },
184
+ { key: "dedup-1", delay: [1, "hour"] },
185
+ );
186
+ expect(id2).toBe(id1);
187
+ });
188
+
189
+ it("delay: push with delay creates a scheduled row, not dispatched", async ({
190
+ expect,
191
+ }) => {
192
+ const alepha = makeApp();
193
+ let calls = 0;
194
+ class App {
195
+ executions = $repository(jobExecutionEntity);
196
+ work = $job({
197
+ schema: t.object({ v: t.integer() }),
198
+ handler: async () => {
199
+ calls++;
200
+ },
2578
201
  });
2579
- const detail = await app.jobService.getExecution(completed[0].id);
2580
-
2581
- expect(detail.status).toBe("completed");
2582
- expect(detail.can).toBeDefined();
2583
- expect(detail.logs).toBeDefined();
2584
- expect(detail.logs!.some((l) => l.message === "hello from handler")).toBe(
2585
- true,
2586
- );
2587
- });
2588
-
2589
- it("should return cron jobs with last execution info", async () => {
2590
- class App {
2591
- repo = $repository(jobExecutionEntity);
2592
- jobService = $inject(JobService);
2593
- cronJob = $job({
2594
- cron: "0 0 * * *",
2595
- handler: async () => {},
2596
- });
2597
- }
2598
-
2599
- const alepha = Alepha.create().with(AlephaOrmPostgres);
2600
- const app = alepha.inject(App);
2601
- await alepha.start();
2602
-
2603
- await app.cronJob.trigger();
2604
-
2605
- await vi.waitFor(async () => {
2606
- const completed = await app.repo.findMany({
2607
- where: { jobName: "App.cronJob", status: "completed" },
2608
- });
2609
- expect(completed).toHaveLength(1);
202
+ }
203
+ const app = alepha.inject(App);
204
+ await alepha.start();
205
+ await app.work.push({ v: 1 }, { delay: [1, "hour"] });
206
+ await new Promise((r) => setTimeout(r, 50));
207
+ expect(calls).toBe(0);
208
+ const rows = await app.executions.findMany({
209
+ where: { jobName: { eq: "App.work" } },
210
+ });
211
+ expect(rows).toHaveLength(1);
212
+ expect(rows[0].status).toBe("scheduled");
213
+ expect(rows[0].scheduledAt).toBeTruthy();
214
+ });
215
+
216
+ it("pushMany: bulk inserts and processes all", async ({ expect }) => {
217
+ const alepha = makeApp();
218
+ const seen: number[] = [];
219
+ class App {
220
+ work = $job({
221
+ schema: t.object({ n: t.integer() }),
222
+ handler: async ({ payload }) => {
223
+ seen.push(payload.n);
224
+ },
2610
225
  });
2611
-
2612
- const cronJobs = await app.jobService.getCronJobs();
2613
- expect(cronJobs).toHaveLength(1);
2614
- expect(cronJobs[0].name).toBe("App.cronJob");
2615
- expect(cronJobs[0].cron).toBe("0 0 * * *");
2616
- expect(cronJobs[0].lastExecution).toBeDefined();
2617
- expect(cronJobs[0].lastExecution!.status).toBe("completed");
2618
- });
2619
-
2620
- it("should return queue depth per job", async () => {
2621
- class App {
2622
- repo = $repository(jobExecutionEntity);
2623
- jobService = $inject(JobService);
2624
- myJob = $job({
2625
- schema: t.object({ value: t.text() }),
2626
- handler: async () => {},
2627
- });
2628
- }
2629
-
2630
- const alepha = Alepha.create().with(AlephaOrmPostgres);
2631
- const app = alepha.inject(App);
2632
- await alepha.start();
2633
-
2634
- // Create a scheduled execution (stays in queue)
2635
- await app.myJob.push({ value: "test" }, { delay: [1, "hour"] });
2636
-
2637
- const depths = await app.jobService.getQueueDepth();
2638
- const depth = depths.find((d) => d.jobName === "App.myJob");
2639
- expect(depth).toBeDefined();
2640
- expect(depth!.scheduled).toBe(1);
2641
- expect(depth!.pending).toBe(0);
2642
- });
2643
-
2644
- it("should return top failures", async () => {
2645
- class App {
2646
- repo = $repository(jobExecutionEntity);
2647
- jobService = $inject(JobService);
2648
- myJob = $job({
2649
- schema: t.object({ value: t.text() }),
2650
- handler: async () => {
2651
- throw new Error("always fails");
2652
- },
2653
- });
2654
- }
2655
-
2656
- const alepha = Alepha.create().with(AlephaOrmPostgres);
2657
- const app = alepha.inject(App);
2658
- await alepha.start();
2659
-
2660
- await app.myJob.push({ value: "test" });
2661
-
2662
- await vi.waitFor(async () => {
2663
- const dead = await app.repo.findMany({
2664
- where: { jobName: "App.myJob", status: "dead" },
2665
- });
2666
- expect(dead).toHaveLength(1);
226
+ }
227
+ const app = alepha.inject(App);
228
+ await alepha.start();
229
+ const ids = await app.work.pushMany([
230
+ { payload: { n: 1 } },
231
+ { payload: { n: 2 } },
232
+ { payload: { n: 3 } },
233
+ ]);
234
+ expect(ids).toHaveLength(3);
235
+ await new Promise((r) => setTimeout(r, 100));
236
+ expect(seen.sort()).toEqual([1, 2, 3]);
237
+ });
238
+
239
+ it("retry: failed queue job is re-scheduled with backoff", async ({
240
+ expect,
241
+ }) => {
242
+ const alepha = makeApp();
243
+ let attempts = 0;
244
+ class App {
245
+ executions = $repository(jobExecutionEntity);
246
+ work = $job({
247
+ schema: t.object({ v: t.integer() }),
248
+ retry: { retries: 2, backoff: [10, "seconds"] },
249
+ handler: async () => {
250
+ attempts++;
251
+ throw new Error("fail");
252
+ },
2667
253
  });
2668
-
2669
- const failures = await app.jobService.getTopFailures();
2670
- expect(failures.length).toBeGreaterThanOrEqual(1);
2671
- const entry = failures.find((f) => f.jobName === "App.myJob");
2672
- expect(entry).toBeDefined();
2673
- expect(entry!.failures).toBe(1);
2674
- expect(entry!.lastError).toBe("always fails");
2675
- });
2676
-
2677
- it("should return activity over time", async () => {
2678
- class App {
2679
- repo = $repository(jobExecutionEntity);
2680
- jobService = $inject(JobService);
2681
- myJob = $job({
2682
- schema: t.object({ value: t.text() }),
2683
- handler: async () => {},
2684
- });
2685
- }
2686
-
2687
- const alepha = Alepha.create().with(AlephaOrmPostgres);
2688
- const app = alepha.inject(App);
2689
- await alepha.start();
2690
-
2691
- await app.myJob.push({ value: "test" });
2692
-
2693
- await vi.waitFor(async () => {
2694
- const completed = await app.repo.findMany({
2695
- where: { jobName: "App.myJob", status: "completed" },
2696
- });
2697
- expect(completed).toHaveLength(1);
254
+ }
255
+ const app = alepha.inject(App);
256
+ await alepha.start();
257
+ await app.work.push({ v: 1 });
258
+ await new Promise((r) => setTimeout(r, 50));
259
+ const rows = await app.executions.findMany({
260
+ where: { jobName: { eq: "App.work" } },
261
+ });
262
+ expect(rows).toHaveLength(1);
263
+ expect(rows[0].status).toBe("scheduled");
264
+ expect(rows[0].attempt).toBe(1);
265
+ expect(attempts).toBe(1);
266
+ });
267
+
268
+ it("retry: terminal error after all retries exhausted", async ({
269
+ expect,
270
+ }) => {
271
+ const alepha = makeApp();
272
+ class App {
273
+ executions = $repository(jobExecutionEntity);
274
+ work = $job({
275
+ schema: t.object({ v: t.integer() }),
276
+ // no retry config → 1 attempt
277
+ handler: async () => {
278
+ throw new Error("dead");
279
+ },
2698
280
  });
2699
-
2700
- const activity = await app.jobService.getActivity(7);
2701
- expect(activity).toHaveLength(7);
2702
-
2703
- // Today should have at least 1 completed
2704
- const today = activity[activity.length - 1];
2705
- expect(today.completed).toBeGreaterThanOrEqual(1);
2706
- });
2707
-
2708
- it("should return job registry with type classification", async () => {
2709
- class App {
2710
- jobService = $inject(JobService);
2711
- pushOnly = $job({
2712
- schema: t.object({ v: t.text() }),
2713
- handler: async () => {},
2714
- });
2715
- cronOnly = $job({
2716
- cron: "0 0 * * *",
2717
- handler: async () => {},
2718
- });
2719
- both = $job({
2720
- schema: t.object({ v: t.text() }),
2721
- cron: "0 0 * * *",
2722
- handler: async () => {},
2723
- });
2724
- }
2725
-
2726
- const alepha = Alepha.create().with(AlephaOrmPostgres);
2727
- const app = alepha.inject(App);
2728
- await alepha.start();
2729
-
2730
- const registry = app.jobService.getRegistry();
2731
- expect(registry).toHaveLength(3);
2732
-
2733
- const push = registry.find((r) => r.name === "App.pushOnly");
2734
- const cron = registry.find((r) => r.name === "App.cronOnly");
2735
- const dual = registry.find((r) => r.name === "App.both");
2736
-
2737
- expect(push!.type).toBe("push");
2738
- expect(cron!.type).toBe("cron");
2739
- expect(dual!.type).toBe("both");
281
+ }
282
+ const app = alepha.inject(App);
283
+ await alepha.start();
284
+ await app.work.push({ v: 1 });
285
+ await new Promise((r) => setTimeout(r, 100));
286
+ const rows = await app.executions.findMany({
287
+ where: { jobName: { eq: "App.work" } },
2740
288
  });
289
+ expect(rows).toHaveLength(1);
290
+ expect(rows[0].status).toBe("error");
291
+ expect(rows[0].error).toBe("dead");
2741
292
  });
293
+ });
2742
294
 
2743
- // ----- Log purge -----
2744
-
2745
- describe("log purge", () => {
2746
- it("should delete old completed executions and their logs", async () => {
2747
- class App {
2748
- log = $logger();
2749
- repo = $repository(jobExecutionEntity);
2750
- logRepo = $repository(jobExecutionLogEntity);
2751
- myJob = $job({
2752
- schema: t.object({ value: t.text() }),
2753
- handler: async () => {
2754
- this.log.info("some log");
2755
- },
2756
- });
2757
- }
2758
-
2759
- const alepha = Alepha.create().with(AlephaOrmPostgres);
2760
- const app = alepha.inject(App);
2761
- await alepha.start();
2762
-
2763
- await app.myJob.push({ value: "test" });
2764
-
2765
- await vi.waitFor(async () => {
2766
- const completed = await app.repo.findMany({
2767
- where: { jobName: "App.myJob", status: "completed" },
2768
- });
2769
- expect(completed).toHaveLength(1);
2770
- });
2771
-
2772
- // Backdate completedAt to 60 days ago (retention is 30 days)
2773
- const completed = await app.repo.findMany({
2774
- where: { jobName: "App.myJob", status: "completed" },
2775
- });
2776
- const sixtyDaysAgo = new Date(Date.now() - 60 * 86_400_000).toISOString();
2777
- await app.repo.updateById(completed[0].id, {
2778
- completedAt: sixtyDaysAgo,
2779
- });
2780
-
2781
- // Verify log exists before purge
2782
- const logBefore = await app.logRepo.findById(completed[0].id);
2783
- expect(logBefore).toBeDefined();
2784
-
2785
- // Run purge
2786
- const provider = alepha.inject(JobProvider) as any;
2787
- await provider.logPurge();
2788
-
2789
- // Both execution and log should be deleted
2790
- const execAfter = await app.repo.findById(completed[0].id);
2791
- expect(execAfter).toBeUndefined();
2792
-
2793
- const logAfter = await app.logRepo.findById(completed[0].id);
2794
- expect(logAfter).toBeUndefined();
2795
- });
2796
-
2797
- it("should preserve recent executions", async () => {
2798
- class App {
2799
- repo = $repository(jobExecutionEntity);
2800
- myJob = $job({
2801
- schema: t.object({ value: t.text() }),
2802
- handler: async () => {},
2803
- });
2804
- }
2805
-
2806
- const alepha = Alepha.create().with(AlephaOrmPostgres);
2807
- const app = alepha.inject(App);
2808
- await alepha.start();
2809
-
2810
- await app.myJob.push({ value: "test" });
2811
-
2812
- await vi.waitFor(async () => {
2813
- const completed = await app.repo.findMany({
2814
- where: { jobName: "App.myJob", status: "completed" },
2815
- });
2816
- expect(completed).toHaveLength(1);
2817
- });
2818
-
2819
- // Run purge — recent execution should survive
2820
- const provider = alepha.inject(JobProvider) as any;
2821
- await provider.logPurge();
2822
-
2823
- const all = await app.repo.findMany({
2824
- where: { jobName: "App.myJob" },
2825
- });
2826
- expect(all).toHaveLength(1);
2827
- });
295
+ // ---------------------------------------------------------------------------
296
+
297
+ describe("$job — cancel", () => {
298
+ it("cancel sets status to 'cancelled' and clears key", async ({ expect }) => {
299
+ const alepha = makeApp();
300
+ class App {
301
+ executions = $repository(jobExecutionEntity);
302
+ work = $job({
303
+ schema: t.object({ v: t.integer() }),
304
+ handler: async () => {},
305
+ });
306
+ }
307
+ const app = alepha.inject(App);
308
+ await alepha.start();
309
+ const id = await app.work.push({ v: 1 }, { delay: [1, "hour"] });
310
+ await app.work.cancel(id);
311
+ const row = await app.executions.findById(id);
312
+ expect(row?.status).toBe("cancelled");
313
+ expect(row?.key).toBeFalsy();
2828
314
  });
315
+ });
2829
316
 
2830
- // ----- Locked sweeps -----
2831
-
2832
- describe("locked sweeps", () => {
2833
- it("should skip sweep when lock is held by another worker", async () => {
2834
- class App {
2835
- repo = $repository(jobExecutionEntity);
2836
- myJob = $job({
2837
- schema: t.object({ value: t.text() }),
2838
- handler: async () => {},
2839
- });
2840
- }
2841
-
2842
- const alepha = Alepha.create().with(AlephaOrmPostgres);
2843
- const app = alepha.inject(App);
2844
- await alepha.start();
2845
-
2846
- // Insert a stale pending record
2847
- const staleTime = new Date(Date.now() - 600_000).toISOString();
2848
- await app.repo.create({
2849
- jobName: "App.myJob",
2850
- payload: { value: "stale" },
2851
- status: "pending",
2852
- priority: 2,
2853
- maxAttempts: 1,
2854
- createdAt: staleTime,
2855
- updatedAt: staleTime,
2856
- });
2857
-
2858
- // Simulate another worker holding the lock
2859
- const provider = alepha.inject(JobProvider) as any;
2860
- await provider.lockProvider.set(
2861
- "_alepha:jobs:recovery-lock",
2862
- "other-worker,2026-01-01T00:00:00.000Z",
2863
- false,
2864
- 300_000,
2865
- );
2866
-
2867
- // Recovery sweep should skip (lock held)
2868
- await provider.recoverySweep();
2869
-
2870
- // Job should still be pending (not dispatched)
2871
- const pending = await app.repo.findMany({
2872
- where: { jobName: "App.myJob", status: "pending" },
2873
- });
2874
- expect(pending).toHaveLength(1);
2875
- });
2876
-
2877
- it("should release lock after sweep completes", async () => {
2878
- class App {
2879
- repo = $repository(jobExecutionEntity);
2880
- myJob = $job({
2881
- schema: t.object({ value: t.text() }),
2882
- handler: async () => {},
2883
- });
2884
- }
2885
-
2886
- const alepha = Alepha.create().with(AlephaOrmPostgres);
2887
- const app = alepha.inject(App);
2888
- await alepha.start();
2889
-
2890
- const provider = alepha.inject(JobProvider) as any;
2891
-
2892
- // Run sweep (should acquire and release lock)
2893
- await provider.recoverySweep();
2894
-
2895
- // Second sweep should also succeed (lock was released)
2896
- await provider.recoverySweep();
2897
- });
317
+ // ---------------------------------------------------------------------------
318
+
319
+ describe("$job — admin service", () => {
320
+ it("listJobs returns all registered jobs with recent counts", async ({
321
+ expect,
322
+ }) => {
323
+ const alepha = makeApp();
324
+ class App {
325
+ cronA = $job({
326
+ cron: "0 0 * * *",
327
+ description: "Daily A",
328
+ handler: async () => {},
329
+ });
330
+ queueB = $job({
331
+ schema: t.object({ v: t.integer() }),
332
+ handler: async () => {},
333
+ });
334
+ }
335
+ alepha.inject(App);
336
+ await alepha.start();
337
+
338
+ const { JobService } = await import("../services/JobService.ts");
339
+ const svc = alepha.inject(JobService);
340
+ const list = await svc.listJobs();
341
+
342
+ const byName = new Map(list.map((l) => [l.name, l]));
343
+ expect(byName.get("App.cronA")?.type).toBe("cron");
344
+ expect(byName.get("App.cronA")?.cron).toBe("0 0 * * *");
345
+ expect(byName.get("App.queueB")?.type).toBe("queue");
346
+ expect(byName.get("App.cronA")?.recent.ok).toBe(0);
2898
347
  });
2899
348
  });