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