alepha 0.20.4 → 0.20.6

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 (192) hide show
  1. package/dist/api/audits/index.d.ts +391 -359
  2. package/dist/api/audits/index.d.ts.map +1 -1
  3. package/dist/api/audits/index.js +23 -1
  4. package/dist/api/audits/index.js.map +1 -1
  5. package/dist/api/files/index.d.ts +18 -0
  6. package/dist/api/files/index.d.ts.map +1 -1
  7. package/dist/api/files/index.js +51 -0
  8. package/dist/api/files/index.js.map +1 -1
  9. package/dist/api/jobs/index.browser.js +33 -14
  10. package/dist/api/jobs/index.browser.js.map +1 -1
  11. package/dist/api/jobs/index.d.ts +452 -155
  12. package/dist/api/jobs/index.d.ts.map +1 -1
  13. package/dist/api/jobs/index.js +474 -159
  14. package/dist/api/jobs/index.js.map +1 -1
  15. package/dist/api/keys/index.d.ts +32 -4
  16. package/dist/api/keys/index.d.ts.map +1 -1
  17. package/dist/api/keys/index.js +53 -0
  18. package/dist/api/keys/index.js.map +1 -1
  19. package/dist/api/notifications/index.d.ts +29 -1
  20. package/dist/api/notifications/index.d.ts.map +1 -1
  21. package/dist/api/notifications/index.js +55 -13
  22. package/dist/api/notifications/index.js.map +1 -1
  23. package/dist/api/organizations/index.js.map +1 -1
  24. package/dist/api/parameters/index.d.ts +15 -0
  25. package/dist/api/parameters/index.d.ts.map +1 -1
  26. package/dist/api/parameters/index.js +37 -0
  27. package/dist/api/parameters/index.js.map +1 -1
  28. package/dist/api/payments/index.js.map +1 -1
  29. package/dist/api/users/index.d.ts +150 -9
  30. package/dist/api/users/index.d.ts.map +1 -1
  31. package/dist/api/users/index.js +237 -28
  32. package/dist/api/users/index.js.map +1 -1
  33. package/dist/api/verifications/index.d.ts +3 -3
  34. package/dist/api/verifications/index.js.map +1 -1
  35. package/dist/batch/index.js.map +1 -1
  36. package/dist/bin/index.js +0 -0
  37. package/dist/bucket/index.d.ts +18 -0
  38. package/dist/bucket/index.d.ts.map +1 -1
  39. package/dist/bucket/index.js +47 -0
  40. package/dist/bucket/index.js.map +1 -1
  41. package/dist/bucket/index.workerd.js +24 -0
  42. package/dist/bucket/index.workerd.js.map +1 -1
  43. package/dist/cache/core/index.d.ts +20 -3
  44. package/dist/cache/core/index.d.ts.map +1 -1
  45. package/dist/cache/core/index.js.map +1 -1
  46. package/dist/cache/core/index.workerd.js.map +1 -1
  47. package/dist/cache/database/index.d.ts +155 -0
  48. package/dist/cache/database/index.d.ts.map +1 -0
  49. package/dist/cache/database/index.js +266 -0
  50. package/dist/cache/database/index.js.map +1 -0
  51. package/dist/cache/redis/index.js.map +1 -1
  52. package/dist/captcha/index.js.map +1 -1
  53. package/dist/cli/config/index.js.map +1 -1
  54. package/dist/cli/core/index.d.ts +35 -5
  55. package/dist/cli/core/index.d.ts.map +1 -1
  56. package/dist/cli/core/index.js +85 -6
  57. package/dist/cli/core/index.js.map +1 -1
  58. package/dist/cli/devtools/index.js.map +1 -1
  59. package/dist/cli/platform/index.js +1 -1
  60. package/dist/cli/platform/index.js.map +1 -1
  61. package/dist/cli/vendor/index.js.map +1 -1
  62. package/dist/command/index.js.map +1 -1
  63. package/dist/core/index.browser.js.map +1 -1
  64. package/dist/core/index.js.map +1 -1
  65. package/dist/core/index.native.js.map +1 -1
  66. package/dist/core/index.workerd.js.map +1 -1
  67. package/dist/crypto/index.browser.js.map +1 -1
  68. package/dist/crypto/index.js.map +1 -1
  69. package/dist/datetime/index.js.map +1 -1
  70. package/dist/email/brevo/index.js.map +1 -1
  71. package/dist/email/core/index.js.map +1 -1
  72. package/dist/email/core/index.workerd.js.map +1 -1
  73. package/dist/email/smtp/index.js.map +1 -1
  74. package/dist/fake/index.js.map +1 -1
  75. package/dist/lock/core/index.js.map +1 -1
  76. package/dist/lock/redis/index.js.map +1 -1
  77. package/dist/logger/index.js.map +1 -1
  78. package/dist/mcp/index.js.map +1 -1
  79. package/dist/orm/core/index.browser.js.map +1 -1
  80. package/dist/orm/core/index.bun.js.map +1 -1
  81. package/dist/orm/core/index.js.map +1 -1
  82. package/dist/orm/postgres/index.bun.js.map +1 -1
  83. package/dist/orm/postgres/index.js.map +1 -1
  84. package/dist/queue/core/index.js.map +1 -1
  85. package/dist/queue/core/index.workerd.js.map +1 -1
  86. package/dist/queue/redis/index.js.map +1 -1
  87. package/dist/react/auth/index.browser.js.map +1 -1
  88. package/dist/react/auth/index.js.map +1 -1
  89. package/dist/react/core/index.js.map +1 -1
  90. package/dist/react/form/index.js +2 -0
  91. package/dist/react/form/index.js.map +1 -1
  92. package/dist/react/head/index.browser.js.map +1 -1
  93. package/dist/react/head/index.js.map +1 -1
  94. package/dist/react/i18n/index.js.map +1 -1
  95. package/dist/react/intro/index.js.map +1 -1
  96. package/dist/react/router/index.browser.js.map +1 -1
  97. package/dist/react/router/index.js.map +1 -1
  98. package/dist/react/testing/index.js.map +1 -1
  99. package/dist/react/ui/index.js.map +1 -1
  100. package/dist/react/websocket/index.js.map +1 -1
  101. package/dist/redis/index.bun.js.map +1 -1
  102. package/dist/redis/index.js.map +1 -1
  103. package/dist/retry/index.js.map +1 -1
  104. package/dist/router/index.js.map +1 -1
  105. package/dist/scheduler/index.d.ts +22 -0
  106. package/dist/scheduler/index.d.ts.map +1 -1
  107. package/dist/scheduler/index.js +12 -0
  108. package/dist/scheduler/index.js.map +1 -1
  109. package/dist/scheduler/index.workerd.js +12 -0
  110. package/dist/scheduler/index.workerd.js.map +1 -1
  111. package/dist/security/index.browser.js.map +1 -1
  112. package/dist/security/index.js.map +1 -1
  113. package/dist/server/auth/index.js.map +1 -1
  114. package/dist/server/cookies/index.browser.js.map +1 -1
  115. package/dist/server/cookies/index.js.map +1 -1
  116. package/dist/server/core/index.browser.js.map +1 -1
  117. package/dist/server/core/index.js.map +1 -1
  118. package/dist/server/cors/index.js.map +1 -1
  119. package/dist/server/etag/index.js.map +1 -1
  120. package/dist/server/health/index.js.map +1 -1
  121. package/dist/server/links/index.browser.js.map +1 -1
  122. package/dist/server/links/index.js.map +1 -1
  123. package/dist/server/metrics/index.js.map +1 -1
  124. package/dist/server/proxy/index.js.map +1 -1
  125. package/dist/server/rate-limit/index.js.map +1 -1
  126. package/dist/server/static/index.js.map +1 -1
  127. package/dist/server/swagger/index.js.map +1 -1
  128. package/dist/sms/index.js.map +1 -1
  129. package/dist/system/index.browser.js.map +1 -1
  130. package/dist/system/index.js.map +1 -1
  131. package/dist/system/index.workerd.js.map +1 -1
  132. package/dist/topic/core/index.js.map +1 -1
  133. package/dist/topic/redis/index.js.map +1 -1
  134. package/dist/websocket/index.browser.js +4 -0
  135. package/dist/websocket/index.browser.js.map +1 -1
  136. package/dist/websocket/index.js +10 -0
  137. package/dist/websocket/index.js.map +1 -1
  138. package/package.json +282 -272
  139. package/src/api/audits/controllers/AdminAuditController.ts +29 -0
  140. package/src/api/files/controllers/FileController.ts +24 -0
  141. package/src/api/files/services/FileService.ts +41 -0
  142. package/src/api/jobs/__tests__/$job.spec.ts +427 -2
  143. package/src/api/jobs/entities/jobExecutionEntity.ts +3 -3
  144. package/src/api/jobs/index.ts +47 -10
  145. package/src/api/jobs/primitives/$job.ts +22 -9
  146. package/src/api/jobs/providers/DirectJobDispatcher.ts +71 -0
  147. package/src/api/jobs/providers/JobDispatcher.ts +49 -0
  148. package/src/api/jobs/providers/JobProvider.ts +365 -142
  149. package/src/api/jobs/providers/JobQueueProvider.ts +43 -18
  150. package/src/api/jobs/schemas/jobConfigAtom.ts +4 -3
  151. package/src/api/jobs/schemas/jobExecutionResourceSchema.ts +11 -0
  152. package/src/api/jobs/schemas/jobRegistrationSchema.ts +4 -2
  153. package/src/api/jobs/services/JobService.ts +21 -11
  154. package/src/api/keys/controllers/AdminApiKeyController.ts +23 -0
  155. package/src/api/keys/services/ApiKeyService.ts +42 -0
  156. package/src/api/notifications/__tests__/AlephaApiNotifications.spec.ts +63 -0
  157. package/src/api/notifications/controllers/AdminNotificationController.ts +48 -1
  158. package/src/api/notifications/index.ts +13 -3
  159. package/src/api/notifications/jobs/NotificationJobs.ts +0 -6
  160. package/src/api/parameters/controllers/AdminParameterController.ts +26 -0
  161. package/src/api/parameters/services/ParameterProvider.ts +18 -0
  162. package/src/api/users/__tests__/Registration-emailMode.spec.ts +203 -0
  163. package/src/api/users/__tests__/UsernameSlugger.spec.ts +138 -0
  164. package/src/api/users/atoms/realmAuthSettingsAtom.ts +41 -3
  165. package/src/api/users/controllers/AdminSessionController.ts +29 -0
  166. package/src/api/users/controllers/AdminUserController.ts +32 -0
  167. package/src/api/users/index.ts +3 -0
  168. package/src/api/users/services/CredentialService.ts +5 -0
  169. package/src/api/users/services/RegistrationService.ts +49 -1
  170. package/src/api/users/services/SessionCrudService.ts +16 -0
  171. package/src/api/users/services/SessionService.ts +17 -59
  172. package/src/api/users/services/UsernameSlugger.ts +195 -0
  173. package/src/bucket/primitives/$bucket.ts +21 -0
  174. package/src/bucket/providers/CloudflareR2Provider.ts +15 -0
  175. package/src/bucket/providers/FileStorageProvider.ts +9 -0
  176. package/src/bucket/providers/LocalFileStorageProvider.ts +14 -0
  177. package/src/bucket/providers/MemoryFileStorageProvider.ts +9 -0
  178. package/src/bucket/providers/NodeS3BucketProvider.ts +35 -0
  179. package/src/cache/core/primitives/$cache.ts +20 -3
  180. package/src/cache/database/__tests__/DatabaseCacheProvider.behavior.spec.ts +203 -0
  181. package/src/cache/database/__tests__/DatabaseCacheProvider.spec.ts +110 -0
  182. package/src/cache/database/entities/cacheEntries.ts +55 -0
  183. package/src/cache/database/index.ts +36 -0
  184. package/src/cache/database/providers/DatabaseCacheProvider.ts +348 -0
  185. package/src/cli/core/services/ProjectScaffolder.ts +0 -2
  186. package/src/cli/core/tasks/BuildCloudflareTask.ts +17 -3
  187. package/src/cli/core/tasks/BuildSitemapTask.ts +7 -0
  188. package/src/cli/core/tasks/BuildVercelTask.ts +82 -3
  189. package/src/cli/platform/__tests__/detectResources.spec.ts +96 -0
  190. package/src/cli/platform/commands/platform.ts +7 -1
  191. package/src/scheduler/index.ts +14 -0
  192. package/src/scheduler/providers/CronProvider.ts +13 -0
@@ -1,6 +1,8 @@
1
1
  import { $inject, t } from "alepha";
2
+ import { $repository } from "alepha/orm";
2
3
  import { $secure } from "alepha/security";
3
4
  import { $action } from "alepha/server";
5
+ import { audits } from "../entities/audits.ts";
4
6
  import { auditQuerySchema } from "../schemas/auditQuerySchema.ts";
5
7
  import { auditResourceSchema } from "../schemas/auditResourceSchema.ts";
6
8
  import { createAuditSchema } from "../schemas/createAuditSchema.ts";
@@ -19,6 +21,7 @@ export class AdminAuditController {
19
21
  protected readonly url = "/audits";
20
22
  protected readonly group = "admin:audits";
21
23
  protected readonly auditService = $inject(AuditService);
24
+ protected readonly repo = $repository(audits);
22
25
 
23
26
  /**
24
27
  * Find audit entries with filtering and pagination.
@@ -52,6 +55,32 @@ export class AdminAuditController {
52
55
  handler: ({ params }) => this.auditService.getById(params.id),
53
56
  });
54
57
 
58
+ /**
59
+ * Delete many audit entries by id in one repository call. Use with care —
60
+ * audit logs are usually retained for compliance reasons.
61
+ */
62
+ public readonly deleteAudits = $action({
63
+ method: "POST",
64
+ path: `${this.url}/delete`,
65
+ group: this.group,
66
+ use: [$secure({ permissions: ["admin:audit:delete"] })],
67
+ description: "Delete many audit entries",
68
+ schema: {
69
+ body: t.object({
70
+ ids: t.array(t.text(), { minItems: 1, maxItems: 1000 }),
71
+ }),
72
+ response: t.object({
73
+ deleted: t.array(t.text()),
74
+ }),
75
+ },
76
+ handler: async ({ body }) => {
77
+ const deleted = await this.repo.deleteMany({
78
+ id: { inArray: body.ids },
79
+ });
80
+ return { deleted: deleted.map(String) };
81
+ },
82
+ });
83
+
55
84
  /**
56
85
  * Create a new audit entry.
57
86
  * System-only — this permission should never be assigned to human roles.
@@ -50,6 +50,30 @@ export class FileController {
50
50
  handler: ({ params }) => this.fileService.deleteFile(params.id),
51
51
  });
52
52
 
53
+ /**
54
+ * POST /files/delete - Delete many files in one request, batching the
55
+ * underlying bucket calls per bucket (R2/S3 batch where supported).
56
+ */
57
+ public readonly deleteFiles = $action({
58
+ method: "POST",
59
+ path: `${this.url}/delete`,
60
+ group: `admin:${this.group}`,
61
+ use: [$secure({ permissions: ["admin:file:delete"] })],
62
+ description: "Delete many files",
63
+ schema: {
64
+ body: t.object({
65
+ ids: t.array(t.uuid(), { minItems: 1, maxItems: 1000 }),
66
+ }),
67
+ response: t.object({
68
+ deleted: t.array(t.string()),
69
+ }),
70
+ },
71
+ handler: async ({ body }) => {
72
+ const deleted = await this.fileService.deleteFiles(body.ids);
73
+ return { deleted };
74
+ },
75
+ });
76
+
53
77
  /**
54
78
  * POST /files - Uploads a new file to storage.
55
79
  * Creates a database record with metadata and calculates checksum.
@@ -324,6 +324,47 @@ export class FileService {
324
324
  return { ok: true, id: String(file.id) };
325
325
  }
326
326
 
327
+ /**
328
+ * Delete many files in one round-trip per bucket. The database rows are
329
+ * removed in a single `deleteMany`, and each affected bucket gets a single
330
+ * `bucket.deleteMany` call (R2/S3 batch where supported).
331
+ */
332
+ public async deleteFiles(ids: string[]): Promise<string[]> {
333
+ if (ids.length === 0) return [];
334
+
335
+ const files = await this.fileRepository.findMany({
336
+ where: { id: { inArray: ids } },
337
+ columns: ["id", "bucket", "blobId"],
338
+ });
339
+ if (files.length === 0) return [];
340
+
341
+ const dbDeleted = await this.fileRepository.deleteMany({
342
+ id: { inArray: files.map((f) => f.id) },
343
+ });
344
+
345
+ const blobsByBucket = new Map<string, string[]>();
346
+ for (const f of files) {
347
+ const list = blobsByBucket.get(f.bucket) ?? [];
348
+ list.push(f.blobId);
349
+ blobsByBucket.set(f.bucket, list);
350
+ }
351
+
352
+ for (const [bucketName, blobIds] of blobsByBucket) {
353
+ try {
354
+ await this.bucket(bucketName).deleteMany(blobIds, true);
355
+ } catch (e) {
356
+ // DB rows already gone — log and continue. Orphaned blobs are
357
+ // recoverable; orphaned DB rows would be worse.
358
+ this.log.warn(
359
+ `Failed to bulk-delete ${blobIds.length} files from bucket ${bucketName}`,
360
+ e,
361
+ );
362
+ }
363
+ }
364
+
365
+ return dbDeleted.map(String);
366
+ }
367
+
327
368
  /**
328
369
  * Retrieves a file entity by its ID.
329
370
  * If already an entity object, returns it as-is (convenience method).
@@ -1,4 +1,5 @@
1
1
  import { Alepha, AlephaError, t } from "alepha";
2
+ import { LockProvider, MemoryLockProvider } from "alepha/lock";
2
3
  import { $repository } from "alepha/orm";
3
4
  import { AlephaOrmPostgres } from "alepha/orm/postgres";
4
5
  import { describe, it } from "vitest";
@@ -6,6 +7,7 @@ import {
6
7
  $job,
7
8
  AlephaApiJobs,
8
9
  AlephaApiJobsQueue,
10
+ JobProvider,
9
11
  jobExecutionEntity,
10
12
  } from "../index.ts";
11
13
 
@@ -15,6 +17,10 @@ const makeApp = () =>
15
17
  .with(AlephaApiJobs)
16
18
  .with(AlephaApiJobsQueue);
17
19
 
20
+ /** App without `AlephaApiJobsQueue` — exercises *direct* mode. */
21
+ const makeAppDirect = () =>
22
+ Alepha.create().with(AlephaOrmPostgres).with(AlephaApiJobs);
23
+
18
24
  describe("$job — registration validation", () => {
19
25
  it("rejects jobs declaring both cron and schema", async ({ expect }) => {
20
26
  const alepha = makeApp();
@@ -240,7 +246,7 @@ describe("$job — queue mode (outbox)", () => {
240
246
  expect(seen.sort()).toEqual([1, 2, 3]);
241
247
  });
242
248
 
243
- it("retry: failed queue job is re-scheduled with backoff", async ({
249
+ it("retry: failed queue job is rescheduled for the next sweep tick", async ({
244
250
  expect,
245
251
  }) => {
246
252
  const alepha = makeApp();
@@ -249,7 +255,7 @@ describe("$job — queue mode (outbox)", () => {
249
255
  executions = $repository(jobExecutionEntity);
250
256
  work = $job({
251
257
  schema: t.object({ v: t.integer() }),
252
- retry: { retries: 2, backoff: [10, "seconds"] },
258
+ retry: { retries: 2 },
253
259
  handler: async () => {
254
260
  attempts++;
255
261
  throw new Error("fail");
@@ -267,6 +273,11 @@ describe("$job — queue mode (outbox)", () => {
267
273
  expect(rows[0].status).toBe("scheduled");
268
274
  expect(rows[0].attempt).toBe(1);
269
275
  expect(attempts).toBe(1);
276
+ // scheduledAt is "now" (no backoff), proving we no longer wait for an
277
+ // exponential delay — the row is immediately eligible at the next sweep.
278
+ expect(rows[0].scheduledAt).toBeTruthy();
279
+ const sched = new Date(rows[0].scheduledAt!).getTime();
280
+ expect(Math.abs(sched - Date.now())).toBeLessThan(2_000);
270
281
  });
271
282
 
272
283
  it("retry: terminal error after all retries exhausted", async ({
@@ -349,4 +360,418 @@ describe("$job — admin service", () => {
349
360
  expect(byName.get("App.queueB")?.type).toBe("queue");
350
361
  expect(byName.get("App.cronA")?.recent.ok).toBe(0);
351
362
  });
363
+
364
+ it("listJobs reports 'direct' when AlephaApiJobsQueue is not loaded", async ({
365
+ expect,
366
+ }) => {
367
+ const alepha = makeAppDirect();
368
+ class App {
369
+ worker = $job({
370
+ schema: t.object({ v: t.integer() }),
371
+ handler: async () => {},
372
+ });
373
+ }
374
+ alepha.inject(App);
375
+ await alepha.start();
376
+
377
+ const { JobService } = await import("../services/JobService.ts");
378
+ const svc = alepha.inject(JobService);
379
+ const list = await svc.listJobs();
380
+
381
+ expect(list.find((j) => j.name === "App.worker")?.type).toBe("direct");
382
+ });
383
+ });
384
+
385
+ // ---------------------------------------------------------------------------
386
+
387
+ describe("$job — direct mode (no AlephaApiJobsQueue)", () => {
388
+ it("push without a queue dispatcher processes the row in-process", async ({
389
+ expect,
390
+ }) => {
391
+ const alepha = makeAppDirect();
392
+ let received: { n: number } | undefined;
393
+ class App {
394
+ executions = $repository(jobExecutionEntity);
395
+ work = $job({
396
+ schema: t.object({ n: t.integer() }),
397
+ handler: async ({ payload }) => {
398
+ received = payload;
399
+ },
400
+ });
401
+ }
402
+ const app = alepha.inject(App);
403
+ await alepha.start();
404
+
405
+ expect(alepha.inject(JobProvider).effectiveMode("App.work")).toBe("direct");
406
+
407
+ await app.work.push({ n: 7 });
408
+
409
+ // Direct mode is fire-and-track; give the microtask queue a moment.
410
+ const deadline = Date.now() + 1500;
411
+ while (received === undefined && Date.now() < deadline) {
412
+ await new Promise((r) => setTimeout(r, 25));
413
+ }
414
+
415
+ expect(received).toEqual({ n: 7 });
416
+ // Default record: 'error' → success deletes the row.
417
+ const rows = await app.executions.findMany({
418
+ where: { jobName: { eq: "App.work" } },
419
+ });
420
+ expect(rows).toHaveLength(0);
421
+ });
422
+
423
+ it("direct mode failure schedules the row for the next sweep", async ({
424
+ expect,
425
+ }) => {
426
+ const alepha = makeAppDirect();
427
+ class App {
428
+ executions = $repository(jobExecutionEntity);
429
+ work = $job({
430
+ schema: t.object({ n: t.integer() }),
431
+ retry: { retries: 2 },
432
+ handler: async () => {
433
+ throw new Error("nope");
434
+ },
435
+ });
436
+ }
437
+ const app = alepha.inject(App);
438
+ await alepha.start();
439
+
440
+ await app.work.push({ n: 1 });
441
+
442
+ // Wait for the in-process attempt to fail.
443
+ const deadline = Date.now() + 2000;
444
+ let row: any;
445
+ while (Date.now() < deadline) {
446
+ const rows = await app.executions.findMany({
447
+ where: { jobName: { eq: "App.work" } },
448
+ });
449
+ if (rows[0]?.status === "scheduled") {
450
+ row = rows[0];
451
+ break;
452
+ }
453
+ await new Promise((r) => setTimeout(r, 25));
454
+ }
455
+
456
+ expect(row).toBeTruthy();
457
+ expect(row.status).toBe("scheduled");
458
+ expect(row.attempt).toBe(1);
459
+ expect(row.error).toBe("nope");
460
+ // scheduledAt should be "now" (no backoff).
461
+ const sched = new Date(row.scheduledAt).getTime();
462
+ expect(Math.abs(sched - Date.now())).toBeLessThan(2_000);
463
+ });
464
+ });
465
+
466
+ // ---------------------------------------------------------------------------
467
+
468
+ /**
469
+ * Shared in-process key/value to simulate a cross-instance lock store
470
+ * (mirrors the pattern used in scheduler tests).
471
+ */
472
+ const sharedLockStore: Record<string, string> = {};
473
+ class SharedMemoryLockProvider extends MemoryLockProvider {
474
+ override store = sharedLockStore;
475
+ }
476
+
477
+ describe("$job — cron lock (multi-instance)", () => {
478
+ it("only one instance fires the cron handler when sharing a LockProvider", async ({
479
+ expect,
480
+ }) => {
481
+ // Reset store between tests.
482
+ for (const k of Object.keys(sharedLockStore)) delete sharedLockStore[k];
483
+
484
+ let fired = 0;
485
+ class App {
486
+ tick = $job({
487
+ cron: "0 0 * * *",
488
+ handler: async () => {
489
+ fired++;
490
+ },
491
+ });
492
+ }
493
+
494
+ const make = () =>
495
+ Alepha.create()
496
+ // Substitute the LockProvider BEFORE AlephaApiJobs imports AlephaLock,
497
+ // otherwise the module's default `optional` MemoryLockProvider wins.
498
+ .with({ provide: LockProvider, use: SharedMemoryLockProvider })
499
+ .with(AlephaOrmPostgres)
500
+ .with(AlephaApiJobs);
501
+
502
+ const a = make();
503
+ const b = make();
504
+ a.inject(App);
505
+ b.inject(App);
506
+ await a.start();
507
+ await b.start();
508
+
509
+ // Trigger the tick from both instances: only one should win the lock.
510
+ await Promise.all([
511
+ a.inject(App).tick.trigger(),
512
+ b.inject(App).tick.trigger(),
513
+ ]);
514
+
515
+ expect(fired).toBe(1);
516
+ });
517
+
518
+ it("lock: false lets every instance fire (opt-out behavior)", async ({
519
+ expect,
520
+ }) => {
521
+ for (const k of Object.keys(sharedLockStore)) delete sharedLockStore[k];
522
+
523
+ let fired = 0;
524
+ class App {
525
+ tick = $job({
526
+ cron: "0 0 * * *",
527
+ lock: false,
528
+ handler: async () => {
529
+ fired++;
530
+ },
531
+ });
532
+ }
533
+
534
+ const make = () =>
535
+ Alepha.create()
536
+ // Substitute the LockProvider BEFORE AlephaApiJobs imports AlephaLock,
537
+ // otherwise the module's default `optional` MemoryLockProvider wins.
538
+ .with({ provide: LockProvider, use: SharedMemoryLockProvider })
539
+ .with(AlephaOrmPostgres)
540
+ .with(AlephaApiJobs);
541
+
542
+ const a = make();
543
+ const b = make();
544
+ a.inject(App);
545
+ b.inject(App);
546
+ await a.start();
547
+ await b.start();
548
+
549
+ await Promise.all([
550
+ a.inject(App).tick.trigger(),
551
+ b.inject(App).tick.trigger(),
552
+ ]);
553
+
554
+ expect(fired).toBe(2);
555
+ });
556
+ });
557
+
558
+ // ---------------------------------------------------------------------------
559
+
560
+ describe("$job — retry semantics", () => {
561
+ it("retries: 2 runs the handler 3 times before the row is terminal", async ({
562
+ expect,
563
+ }) => {
564
+ const alepha = makeApp();
565
+ let attempts = 0;
566
+ class App {
567
+ executions = $repository(jobExecutionEntity);
568
+ work = $job({
569
+ schema: t.object({ v: t.integer() }),
570
+ retry: { retries: 2 },
571
+ handler: async () => {
572
+ attempts++;
573
+ throw new Error("boom");
574
+ },
575
+ });
576
+ }
577
+ const app = alepha.inject(App);
578
+ await alepha.start();
579
+
580
+ await app.work.push({ v: 1 });
581
+
582
+ // First attempt runs immediately. Subsequent retries are sweep-driven.
583
+ await new Promise((r) => setTimeout(r, 100));
584
+ expect(attempts).toBe(1);
585
+
586
+ const provider = alepha.inject(JobProvider);
587
+ // Trigger sweeps manually — each one should claim and run the next attempt.
588
+ await (provider as any).sweep();
589
+ await new Promise((r) => setTimeout(r, 50));
590
+ expect(attempts).toBe(2);
591
+
592
+ await (provider as any).sweep();
593
+ await new Promise((r) => setTimeout(r, 50));
594
+ expect(attempts).toBe(3);
595
+
596
+ // After 3 attempts the row is terminal — no more retries.
597
+ const finalRow = (
598
+ await app.executions.findMany({ where: { jobName: { eq: "App.work" } } })
599
+ )[0];
600
+ expect(finalRow.status).toBe("error");
601
+ expect(finalRow.attempt).toBe(3);
602
+ });
603
+ });
604
+
605
+ // ---------------------------------------------------------------------------
606
+
607
+ describe("$job — cancel race", () => {
608
+ it("cancel during a running handler keeps the row 'cancelled' (not 'error')", async ({
609
+ expect,
610
+ }) => {
611
+ const alepha = makeApp();
612
+ class App {
613
+ executions = $repository(jobExecutionEntity);
614
+ slow = $job({
615
+ schema: t.object({ v: t.integer() }),
616
+ retry: { retries: 0 },
617
+ handler: async ({ signal }) => {
618
+ // Wait for cancellation, then throw — without the guard this throw
619
+ // would write `status: error` after `status: cancelled`.
620
+ await new Promise<void>((_, reject) => {
621
+ signal.addEventListener("abort", () => {
622
+ reject(new Error("aborted"));
623
+ });
624
+ });
625
+ },
626
+ });
627
+ }
628
+ const app = alepha.inject(App);
629
+ await alepha.start();
630
+
631
+ const id = await app.slow.push({ v: 1 });
632
+
633
+ // Wait for the handler to be `running`.
634
+ const deadline = Date.now() + 1500;
635
+ while (Date.now() < deadline) {
636
+ const row = await app.executions.findById(id);
637
+ if (row?.status === "running") break;
638
+ await new Promise((r) => setTimeout(r, 20));
639
+ }
640
+
641
+ await app.slow.cancel(id);
642
+
643
+ // Give the handler's abort path a moment to execute and (without the
644
+ // guard) try to overwrite the row.
645
+ await new Promise((r) => setTimeout(r, 100));
646
+
647
+ const final = await app.executions.findById(id);
648
+ expect(final?.status).toBe("cancelled");
649
+ expect(final?.error).toBeFalsy();
650
+ });
651
+ });
652
+
653
+ // ---------------------------------------------------------------------------
654
+
655
+ describe("$job — cron + retry (outbox path)", () => {
656
+ it("a failing cron job with retry is rescheduled by the sweep, not next tick", async ({
657
+ expect,
658
+ }) => {
659
+ const alepha = makeAppDirect();
660
+ let attempts = 0;
661
+ class App {
662
+ executions = $repository(jobExecutionEntity);
663
+ tick = $job({
664
+ cron: "0 0 * * *",
665
+ retry: { retries: 1 },
666
+ handler: async () => {
667
+ attempts++;
668
+ throw new Error("transient");
669
+ },
670
+ });
671
+ }
672
+ const app = alepha.inject(App);
673
+ await alepha.start();
674
+
675
+ // Manually fire the cron tick (admin trigger path goes through the
676
+ // same lock-aware runner as the scheduled tick).
677
+ await app.tick.trigger();
678
+
679
+ // Direct-mode dispatch is fire-and-track. Wait for the first attempt
680
+ // to land in the DB.
681
+ const deadline = Date.now() + 1500;
682
+ while (attempts < 1 && Date.now() < deadline) {
683
+ await new Promise((r) => setTimeout(r, 25));
684
+ }
685
+ expect(attempts).toBe(1);
686
+
687
+ // The row exists in the outbox after the cron tick (unlike inline crons,
688
+ // which only persist on error).
689
+ const provider = alepha.inject(JobProvider);
690
+ const rows1 = await app.executions.findMany({
691
+ where: { jobName: { eq: "App.tick" } },
692
+ });
693
+ expect(rows1).toHaveLength(1);
694
+ expect(rows1[0].status).toBe("scheduled");
695
+
696
+ // Sweep picks it up and runs attempt 2 → terminal.
697
+ await (provider as any).sweep();
698
+ await new Promise((r) => setTimeout(r, 100));
699
+ expect(attempts).toBe(2);
700
+ const rows2 = await app.executions.findMany({
701
+ where: { jobName: { eq: "App.tick" } },
702
+ });
703
+ expect(rows2[0].status).toBe("error");
704
+ });
705
+ });
706
+
707
+ // ---------------------------------------------------------------------------
708
+
709
+ describe("$job — dispatchMany (queue mode)", () => {
710
+ it("pushMany hands the dispatcher a single batch", async ({ expect }) => {
711
+ const alepha = makeApp();
712
+ class App {
713
+ bulk = $job({
714
+ schema: t.object({ n: t.integer() }),
715
+ handler: async () => {},
716
+ });
717
+ }
718
+ const app = alepha.inject(App);
719
+ await alepha.start();
720
+
721
+ const provider = alepha.inject(JobProvider);
722
+ const dispatcher = (provider as any).dispatcher as {
723
+ dispatchMany: (
724
+ items: Array<{ jobName: string; executionId: string }>,
725
+ ) => Promise<void>;
726
+ };
727
+
728
+ let batched: Array<{ jobName: string; executionId: string }> = [];
729
+ const original = dispatcher.dispatchMany.bind(dispatcher);
730
+ dispatcher.dispatchMany = async (items) => {
731
+ batched = items;
732
+ await original(items);
733
+ };
734
+
735
+ await app.bulk.pushMany([
736
+ { payload: { n: 1 } },
737
+ { payload: { n: 2 } },
738
+ { payload: { n: 3 } },
739
+ ]);
740
+
741
+ expect(batched).toHaveLength(3);
742
+ for (const it of batched) {
743
+ expect(it.jobName).toBe("App.bulk");
744
+ expect(it.executionId).toBeTruthy();
745
+ }
746
+ });
747
+ });
748
+
749
+ // ---------------------------------------------------------------------------
750
+
751
+ describe("$job — admin resource shape", () => {
752
+ it("execution resource exposes priority as the enum string", async ({
753
+ expect,
754
+ }) => {
755
+ const alepha = makeApp();
756
+ class App {
757
+ work = $job({
758
+ schema: t.object({ v: t.integer() }),
759
+ priority: "high",
760
+ record: "all",
761
+ handler: async () => {},
762
+ });
763
+ }
764
+ const app = alepha.inject(App);
765
+ await alepha.start();
766
+
767
+ const id = await app.work.push({ v: 1 });
768
+ // Wait for the handler to finish so the row is `ok` (record: all keeps it).
769
+ await new Promise((r) => setTimeout(r, 100));
770
+
771
+ const { JobService } = await import("../services/JobService.ts");
772
+ const svc = alepha.inject(JobService);
773
+ const resource = await svc.getExecution(id);
774
+ expect(resource.priority).toBe("high");
775
+ expect(typeof resource.priority).toBe("string");
776
+ });
352
777
  });
@@ -10,11 +10,11 @@ import { $entity, db } from "alepha/orm";
10
10
  * the last N rows per job (configurable via `jobConfig.keepLastSuccess`).
11
11
  *
12
12
  * Status transitions:
13
- * - queue push → pending
13
+ * - queue push → pending (or `scheduled` if `delay`/`scheduledAt` was given)
14
14
  * - worker claim → running
15
- * - success → ok
15
+ * - success → ok (or row deleted, depending on `record` and `keepLastSuccess`)
16
16
  * - terminal failure → error
17
- * - retry → scheduled (with scheduledAt = now + backoff)
17
+ * - retryable failure → scheduled (with scheduledAt = now; sweep picks it up)
18
18
  * - delay → scheduled (with scheduledAt = now + delay)
19
19
  * - sweep picks due ones → pending
20
20
  * - cancel → cancelled