@rebasepro/server 0.22.0 → 0.24.0

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 (211) hide show
  1. package/README.md +1 -1
  2. package/bin/rebase-server.js +4 -2
  3. package/dist/{GCSStorageController-CLIJXwGS.js → GCSStorageController-BSiP1c-f.js} +57 -29
  4. package/dist/GCSStorageController-BSiP1c-f.js.map +1 -0
  5. package/dist/{S3StorageController-Dcuf8lMA.js → S3StorageController-CAwFRgjV.js} +19 -7
  6. package/dist/S3StorageController-CAwFRgjV.js.map +1 -0
  7. package/dist/api/ast-schema-editor.d.ts +127 -1
  8. package/dist/api/errors.d.ts +9 -0
  9. package/dist/api/live-schema-routes.d.ts +52 -8
  10. package/dist/api/logs-routes.d.ts +39 -1
  11. package/dist/api/openapi-generator.d.ts +17 -0
  12. package/dist/api/rest/api-generator.d.ts +158 -42
  13. package/dist/api/rest/auth-collection-writes.d.ts +85 -0
  14. package/dist/api/rest/field-access-query.d.ts +6 -2
  15. package/dist/api/rest/idempotency.d.ts +7 -1
  16. package/dist/api/rest/nested-write-access.d.ts +46 -0
  17. package/dist/api/rest/write-validation.d.ts +34 -2
  18. package/dist/api/types.d.ts +17 -1
  19. package/dist/{ast-schema-editor-CslO8Oje.js → ast-schema-editor-CWqS_sLJ.js} +411 -13
  20. package/dist/ast-schema-editor-CWqS_sLJ.js.map +1 -0
  21. package/dist/auth/access.d.ts +105 -0
  22. package/dist/auth/adapter-middleware.d.ts +2 -1
  23. package/dist/auth/address-ownership.d.ts +68 -0
  24. package/dist/auth/admin-roles-route.d.ts +4 -2
  25. package/dist/auth/admin-roles.d.ts +17 -20
  26. package/dist/auth/admin-user-ops.d.ts +35 -2
  27. package/dist/auth/admin-users-route.d.ts +1 -0
  28. package/dist/auth/api-keys/api-key-middleware.d.ts +56 -55
  29. package/dist/auth/api-keys/api-key-routes.d.ts +41 -11
  30. package/dist/auth/api-keys/api-key-store.d.ts +31 -8
  31. package/dist/auth/api-keys/api-key-types.d.ts +14 -16
  32. package/dist/auth/api-keys/http-operation.d.ts +19 -0
  33. package/dist/auth/api-keys/index.d.ts +11 -11
  34. package/dist/auth/api-keys/key-grant.d.ts +41 -0
  35. package/dist/auth/api-keys/legacy-permissions.d.ts +33 -0
  36. package/dist/auth/auth-hooks.d.ts +50 -7
  37. package/dist/auth/builtin-auth-adapter.d.ts +8 -0
  38. package/dist/auth/captcha.d.ts +5 -0
  39. package/dist/auth/cookie-utils.d.ts +7 -0
  40. package/dist/auth/deliverable-address.d.ts +6 -0
  41. package/dist/auth/email-change-routes.d.ts +41 -0
  42. package/dist/auth/expired-token-sweep.d.ts +67 -0
  43. package/dist/auth/impersonation.d.ts +110 -0
  44. package/dist/auth/index.d.ts +4 -2
  45. package/dist/auth/interfaces.d.ts +146 -65
  46. package/dist/auth/jwt.d.ts +66 -3
  47. package/dist/auth/magic-link-routes.d.ts +2 -6
  48. package/dist/auth/mfa-routes.d.ts +2 -9
  49. package/dist/auth/middleware.d.ts +17 -5
  50. package/dist/auth/oauth-signin-policy.d.ts +25 -8
  51. package/dist/auth/otp-routes.d.ts +2 -6
  52. package/dist/auth/passwordless-signup.d.ts +27 -0
  53. package/dist/auth/platform-token.d.ts +122 -0
  54. package/dist/auth/rate-limiter.d.ts +72 -1
  55. package/dist/auth/routes.d.ts +45 -0
  56. package/dist/auth/scope-routes.d.ts +22 -0
  57. package/dist/auth/session-routes.d.ts +18 -6
  58. package/dist/auth/token-revocation.d.ts +53 -1
  59. package/dist/auth/verify-credential.d.ts +28 -0
  60. package/dist/{auth-CCDpk2rn.js → auth-DMLngxn_.js} +2712 -711
  61. package/dist/auth-DMLngxn_.js.map +1 -0
  62. package/dist/backend-DTAOsLQc.js +30 -0
  63. package/dist/backend-DTAOsLQc.js.map +1 -0
  64. package/dist/backup/backup-common.d.ts +29 -0
  65. package/dist/backup/backup-routes.d.ts +24 -4
  66. package/dist/backup/backup-schedule.d.ts +33 -0
  67. package/dist/backup/backup-storage.d.ts +14 -0
  68. package/dist/backup/index.d.ts +2 -0
  69. package/dist/backup-CN0s50D2.js +444 -0
  70. package/dist/backup-CN0s50D2.js.map +1 -0
  71. package/dist/boot/bundle.d.ts +19 -0
  72. package/dist/boot/driver.d.ts +10 -0
  73. package/dist/boot/env.d.ts +51 -6
  74. package/dist/boot/fetch-bundle.d.ts +18 -1
  75. package/dist/boot/rls-audit-option.d.ts +26 -0
  76. package/dist/boot/security-headers.d.ts +26 -0
  77. package/dist/boot/sources.d.ts +1 -0
  78. package/dist/boot/static-routing.d.ts +56 -0
  79. package/dist/collection_patch-BRu-BvDv.js +472 -0
  80. package/dist/collection_patch-BRu-BvDv.js.map +1 -0
  81. package/dist/{contract-routes-eLxV0le1.js → contract-routes-fz8i4pxs.js} +17 -4
  82. package/dist/contract-routes-fz8i4pxs.js.map +1 -0
  83. package/dist/cron/cron-routes.d.ts +7 -2
  84. package/dist/cron/cron-scheduler.d.ts +146 -21
  85. package/dist/cron/cron-store.d.ts +76 -8
  86. package/dist/cron/index.d.ts +1 -1
  87. package/dist/{cron-loader-CQjvjpEw.js → cron-loader-CwaANlOG.js} +4 -4
  88. package/dist/cron-loader-CwaANlOG.js.map +1 -0
  89. package/dist/cron-routes-Bc-SB0Se.js +96 -0
  90. package/dist/cron-routes-Bc-SB0Se.js.map +1 -0
  91. package/dist/{cron-scheduler-COPQxlEq.js → cron-scheduler-CYQgco86.js} +427 -83
  92. package/dist/cron-scheduler-CYQgco86.js.map +1 -0
  93. package/dist/{cron-store-BYGZFNWk.js → cron-store-D2Q9-Aco.js} +139 -23
  94. package/dist/cron-store-D2Q9-Aco.js.map +1 -0
  95. package/dist/{ddl-bootstrap-CfNvxMuK.js → ddl-bootstrap-BaqMSa4Y.js} +3 -26
  96. package/dist/ddl-bootstrap-BaqMSa4Y.js.map +1 -0
  97. package/dist/email/index.d.ts +2 -2
  98. package/dist/email/link-base.d.ts +5 -4
  99. package/dist/email/smtp-email-service.d.ts +13 -1
  100. package/dist/email/templates.d.ts +31 -0
  101. package/dist/email/types.d.ts +29 -2
  102. package/dist/env.d.ts +25 -7
  103. package/dist/{errors-DWsX4yTd.js → errors-D6_y86c5.js} +102 -8
  104. package/dist/errors-D6_y86c5.js.map +1 -0
  105. package/dist/{function-loader-xnbDAPfa.js → function-loader-D7o5Epjj.js} +2 -2
  106. package/dist/{function-loader-xnbDAPfa.js.map → function-loader-D7o5Epjj.js.map} +1 -1
  107. package/dist/{function-routes-Chet4-lB.js → function-routes-CaNG4waN.js} +24 -12
  108. package/dist/function-routes-CaNG4waN.js.map +1 -0
  109. package/dist/functions/context.d.ts +17 -6
  110. package/dist/functions/guards.d.ts +22 -5
  111. package/dist/functions/index.d.ts +2 -2
  112. package/dist/functions/index.js +90 -36
  113. package/dist/functions/index.js.map +1 -1
  114. package/dist/{history-recorder-BQmB0P_j.js → history-recorder-Nr8zLvoU.js} +9 -7
  115. package/dist/history-recorder-Nr8zLvoU.js.map +1 -0
  116. package/dist/{history-store-CetkrBBD.js → history-store-rcAm_xFR.js} +2 -2
  117. package/dist/{history-store-CetkrBBD.js.map → history-store-rcAm_xFR.js.map} +1 -1
  118. package/dist/index.d.ts +14 -4
  119. package/dist/index.es.js +5733 -1551
  120. package/dist/index.es.js.map +1 -1
  121. package/dist/init/docs.d.ts +5 -2
  122. package/dist/init/health.d.ts +17 -2
  123. package/dist/init/shutdown.d.ts +18 -3
  124. package/dist/init.d.ts +54 -0
  125. package/dist/jobs/index.d.ts +2 -2
  126. package/dist/jobs/job-queue.d.ts +23 -2
  127. package/dist/jobs/job-store.d.ts +37 -5
  128. package/dist/jobs/types.d.ts +8 -6
  129. package/dist/{jobs-Bjr8DZAi.js → jobs-DqYNfquG.js} +306 -167
  130. package/dist/jobs-DqYNfquG.js.map +1 -0
  131. package/dist/{jwt-C4OW-DNq.js → jwt-R6bSPMjk.js} +114 -38
  132. package/dist/{jwt-C4OW-DNq.js.map → jwt-R6bSPMjk.js.map} +1 -1
  133. package/dist/{keys-Qfc4XieN.js → keys-GAVZqbqx.js} +18 -17
  134. package/dist/{keys-Qfc4XieN.js.map → keys-GAVZqbqx.js.map} +1 -1
  135. package/dist/{logger-DO2PZc4i.js → logger-D-S-hO5e.js} +26 -3
  136. package/dist/logger-D-S-hO5e.js.map +1 -0
  137. package/dist/{logs-routes-3EEzPjhl.js → logs-routes-DAdv37GI.js} +54 -11
  138. package/dist/logs-routes-DAdv37GI.js.map +1 -0
  139. package/dist/mcp/consent-page.d.ts +1 -1
  140. package/dist/mcp/mcp-routes.d.ts +45 -2
  141. package/dist/mcp/mcp-tools.d.ts +22 -10
  142. package/dist/mcp/oauth-metadata.d.ts +21 -16
  143. package/dist/mcp/oauth-routes.d.ts +34 -1
  144. package/dist/mcp/oauth-store.d.ts +29 -13
  145. package/dist/metrics/history-recorder.d.ts +1 -1
  146. package/dist/{openapi-generator-D8uFz-LW.js → openapi-generator-DAq_XVDu.js} +135 -22
  147. package/dist/openapi-generator-DAq_XVDu.js.map +1 -0
  148. package/dist/{proxy-Czngl3p9.js → proxy-qRlqeUmO.js} +2 -2
  149. package/dist/{proxy-Czngl3p9.js.map → proxy-qRlqeUmO.js.map} +1 -1
  150. package/dist/{query-parser-BleZmY18.js → query-parser-BgiKJKvc.js} +41 -82
  151. package/dist/query-parser-BgiKJKvc.js.map +1 -0
  152. package/dist/{request-timeout-C_4C2BeR.js → request-timeout-DgH7j8qO.js} +3 -3
  153. package/dist/{request-timeout-C_4C2BeR.js.map → request-timeout-DgH7j8qO.js.map} +1 -1
  154. package/dist/rls-audit/index.d.ts +4 -0
  155. package/dist/schema-edit/apply-schema-change.d.ts +63 -3
  156. package/dist/schema-edit/project-root.d.ts +3 -2
  157. package/dist/schema-edit/remote-source.d.ts +9 -4
  158. package/dist/{schema-editor-routes-DdLihzp0.js → schema-editor-routes-oIyuWl3L.js} +12 -7
  159. package/dist/schema-editor-routes-oIyuWl3L.js.map +1 -0
  160. package/dist/serve-spa.d.ts +58 -0
  161. package/dist/services/routed-realtime-service.d.ts +11 -0
  162. package/dist/soft-delete-params-BWPilMPF.js +59 -0
  163. package/dist/soft-delete-params-BWPilMPF.js.map +1 -0
  164. package/dist/{src-Br6ARbs6.js → src-CatHFUym.js} +439 -45
  165. package/dist/src-CatHFUym.js.map +1 -0
  166. package/dist/{src-1vL-I1Po.js → src-I3aG1PcY.js} +371 -81
  167. package/dist/src-I3aG1PcY.js.map +1 -0
  168. package/dist/storage/GCSStorageController.d.ts +13 -1
  169. package/dist/storage/LocalStorageController.d.ts +2 -0
  170. package/dist/storage/S3StorageController.d.ts +2 -0
  171. package/dist/storage/index.d.ts +2 -2
  172. package/dist/storage/keys.d.ts +12 -0
  173. package/dist/storage/property-limits.d.ts +41 -6
  174. package/dist/storage/rendition-cache.d.ts +11 -1
  175. package/dist/storage/request-keys.d.ts +82 -0
  176. package/dist/storage/requested-object.d.ts +74 -0
  177. package/dist/storage/routes.d.ts +36 -18
  178. package/dist/storage/tus-handler.d.ts +30 -5
  179. package/dist/storage/types.d.ts +36 -1
  180. package/dist/types-BfKcm9do.js.map +1 -1
  181. package/dist/utils/logger.d.ts +12 -0
  182. package/package.json +9 -9
  183. package/dist/GCSStorageController-CLIJXwGS.js.map +0 -1
  184. package/dist/S3StorageController-Dcuf8lMA.js.map +0 -1
  185. package/dist/admin-roles-vYdp_Pil.js +0 -36
  186. package/dist/admin-roles-vYdp_Pil.js.map +0 -1
  187. package/dist/admin_block-DxKLmdiv.js +0 -206
  188. package/dist/admin_block-DxKLmdiv.js.map +0 -1
  189. package/dist/ast-schema-editor-CslO8Oje.js.map +0 -1
  190. package/dist/auth/api-keys/api-key-permission-guard.d.ts +0 -65
  191. package/dist/auth-CCDpk2rn.js.map +0 -1
  192. package/dist/backup-DzI9jLwc.js +0 -192
  193. package/dist/backup-DzI9jLwc.js.map +0 -1
  194. package/dist/contract-routes-eLxV0le1.js.map +0 -1
  195. package/dist/cron-loader-CQjvjpEw.js.map +0 -1
  196. package/dist/cron-routes-B7CRGfiq.js +0 -72
  197. package/dist/cron-routes-B7CRGfiq.js.map +0 -1
  198. package/dist/cron-scheduler-COPQxlEq.js.map +0 -1
  199. package/dist/cron-store-BYGZFNWk.js.map +0 -1
  200. package/dist/ddl-bootstrap-CfNvxMuK.js.map +0 -1
  201. package/dist/errors-DWsX4yTd.js.map +0 -1
  202. package/dist/function-routes-Chet4-lB.js.map +0 -1
  203. package/dist/history-recorder-BQmB0P_j.js.map +0 -1
  204. package/dist/jobs-Bjr8DZAi.js.map +0 -1
  205. package/dist/logger-DO2PZc4i.js.map +0 -1
  206. package/dist/logs-routes-3EEzPjhl.js.map +0 -1
  207. package/dist/openapi-generator-D8uFz-LW.js.map +0 -1
  208. package/dist/query-parser-BleZmY18.js.map +0 -1
  209. package/dist/schema-editor-routes-DdLihzp0.js.map +0 -1
  210. package/dist/src-1vL-I1Po.js.map +0 -1
  211. package/dist/src-Br6ARbs6.js.map +0 -1
@@ -3,8 +3,10 @@ import __rebaseProcess from "process";
3
3
  globalThis.process ??= __rebaseProcess;
4
4
  __rebaseCreateRequire(import.meta.url);
5
5
  import { n as __exportAll } from "./rolldown-runtime-dW7B1o5h.js";
6
- import "./src-Br6ARbs6.js";
7
- import { a as redactSensitiveText, r as logger } from "./logger-DO2PZc4i.js";
6
+ import "./src-CatHFUym.js";
7
+ import { a as redactSensitiveText, r as logger, s as stringifyForLog } from "./logger-D-S-hO5e.js";
8
+ import { randomUUID } from "node:crypto";
9
+ import { hostname } from "node:os";
8
10
  //#region ../types/src/types/env_boolean.ts
9
11
  var YES = /* @__PURE__ */ new Set([
10
12
  "true",
@@ -148,6 +150,7 @@ function buildScaleToZeroWarning(jobs, env = process.env) {
148
150
  var cron_scheduler_exports = /* @__PURE__ */ __exportAll({
149
151
  CronScheduler: () => CronScheduler,
150
152
  findMostRecentSlot: () => findMostRecentSlot,
153
+ isAlreadyExecutingSkip: () => isAlreadyExecutingSkip,
151
154
  isValidTimeZone: () => isValidTimeZone,
152
155
  parseCronExpression: () => parseCronExpression,
153
156
  validateCronExpression: () => validateCronExpression
@@ -326,7 +329,6 @@ function matchesCronFields(candidate, fields, zone) {
326
329
  };
327
330
  return fields.months.includes(wall.month) && fields.doms.includes(wall.dom) && fields.dows.includes(wall.dow) && fields.hours.includes(wall.hour) && fields.minutes.includes(wall.minute);
328
331
  }
329
- /** ~1 year in minutes — the walk bound for both search directions. */
330
332
  /**
331
333
  * How far forward to look for the next matching slot.
332
334
  *
@@ -335,6 +337,7 @@ function matchesCronFields(candidate, fields, zone) {
335
337
  * search never found it.
336
338
  */
337
339
  var MAX_SLOT_SEARCH_MINUTES = 2105280;
340
+ var MINUTE_MS = 6e4;
338
341
  /**
339
342
  * Calculate the next Date after `after` that matches the cron expression.
340
343
  * Throws on invalid expressions.
@@ -342,11 +345,11 @@ var MAX_SLOT_SEARCH_MINUTES = 2105280;
342
345
  function parseCronExpression(expression, after, timezone) {
343
346
  const fields = parseCronFields(expression);
344
347
  const candidate = new Date(after);
345
- candidate.setSeconds(0, 0);
346
- candidate.setMinutes(candidate.getMinutes() + 1);
348
+ candidate.setUTCSeconds(0, 0);
349
+ candidate.setTime(candidate.getTime() + MINUTE_MS);
347
350
  for (let i = 0; i < MAX_SLOT_SEARCH_MINUTES; i++) {
348
351
  if (matchesCronFields(candidate, fields, timezone)) return candidate;
349
- candidate.setMinutes(candidate.getMinutes() + 1);
352
+ candidate.setTime(candidate.getTime() + MINUTE_MS);
350
353
  }
351
354
  throw new Error(`Cron expression "${expression}" has no matching time within ${Math.round(MAX_SLOT_SEARCH_MINUTES / 525960)} years of ${after.toISOString()}. Check the day-of-month and month fields — a date such as 31 February never occurs.`);
352
355
  }
@@ -369,10 +372,10 @@ function parseCronExpression(expression, after, timezone) {
369
372
  function findMostRecentSlot(expression, from, to, timezone) {
370
373
  const fields = parseCronFields(expression);
371
374
  const candidate = new Date(to);
372
- candidate.setSeconds(0, 0);
375
+ candidate.setUTCSeconds(0, 0);
373
376
  for (let i = 0; i < MAX_SLOT_SEARCH_MINUTES && candidate.getTime() >= from.getTime(); i++) {
374
377
  if (matchesCronFields(candidate, fields, timezone)) return candidate;
375
- candidate.setMinutes(candidate.getMinutes() - 1);
378
+ candidate.setTime(candidate.getTime() - MINUTE_MS);
376
379
  }
377
380
  }
378
381
  var MAX_LOGS_PER_JOB = 50;
@@ -387,6 +390,66 @@ var MIN_SCHEDULE_INTERVAL_MS = 5e3;
387
390
  * further out than this must be reached in hops rather than one timer.
388
391
  */
389
392
  var MAX_TIMER_DELAY_MS = 2147483647;
393
+ /**
394
+ * `setTimeout` for a delay that may exceed {@link MAX_TIMER_DELAY_MS}: armed in
395
+ * hops no longer than the ceiling, re-measured against the deadline on each
396
+ * wake. Returns the function that cancels it.
397
+ */
398
+ function setLongTimeout(callback, delayMs) {
399
+ const deadline = Date.now() + delayMs;
400
+ let handle;
401
+ const arm = () => {
402
+ handle = setTimeout(() => {
403
+ if (Date.now() < deadline) arm();
404
+ else callback();
405
+ }, Math.min(Math.max(deadline - Date.now(), 0), MAX_TIMER_DELAY_MS));
406
+ };
407
+ arm();
408
+ return () => clearTimeout(handle);
409
+ }
410
+ /** A run's timeout when its definition names none. */
411
+ var DEFAULT_TIMEOUT_SECONDS = 300;
412
+ /**
413
+ * How long a run lease outlasts the run's own timeout.
414
+ *
415
+ * The lease is timed on the database's clock from the moment it is taken, the
416
+ * timeout on this process's from a moment later; without a margin a lease
417
+ * could lapse a beat before its run is aborted and released, and another
418
+ * process start the job alongside it.
419
+ */
420
+ var LEASE_GRACE_SECONDS = 30;
421
+ /**
422
+ * The lease a run with no timeout (`timeoutSeconds: Infinity`) holds.
423
+ *
424
+ * A lease is released when its run ends, so this only matters in two cases.
425
+ * The holder crashed: the job is blocked — every trigger answered "already
426
+ * executing", every slot skipped — until the lease lapses, so it must lapse
427
+ * within an hour rather than never. The run is still going after an hour:
428
+ * from then on another process may start the job beside it. A job that
429
+ * legitimately runs for hours should say so with a finite `timeoutSeconds`,
430
+ * which its lease then follows.
431
+ */
432
+ var MAX_UNBOUNDED_LEASE_SECONDS = 3600;
433
+ /**
434
+ * Whether a log entry records a run that did not happen because one was
435
+ * already executing — on this process, or on another that holds the job's run
436
+ * lease. The shape is the documented marker of a skip, the one the Studio
437
+ * panel reads.
438
+ */
439
+ function isAlreadyExecutingSkip(entry) {
440
+ const result = entry.result;
441
+ return typeof result === "object" && result !== null && "skipped" in result && result.skipped === true && "reason" in result && result.reason === "already_executing";
442
+ }
443
+ /**
444
+ * Why `timeoutSeconds` cannot be used, or `undefined` when it can. Infinity is
445
+ * a timeout that never fires; zero, a negative number or NaN would fail every
446
+ * run the moment it started.
447
+ */
448
+ function invalidTimeoutReason(timeoutSeconds) {
449
+ if (timeoutSeconds === void 0 || timeoutSeconds === Infinity) return void 0;
450
+ if (typeof timeoutSeconds === "number" && Number.isFinite(timeoutSeconds) && timeoutSeconds > 0) return void 0;
451
+ return `timeoutSeconds must be a positive number of seconds, or Infinity for none — got ${String(timeoutSeconds)}`;
452
+ }
390
453
  var CronScheduler = class {
391
454
  jobs = /* @__PURE__ */ new Map();
392
455
  rejected = /* @__PURE__ */ new Map();
@@ -394,6 +457,19 @@ var CronScheduler = class {
394
457
  store;
395
458
  client;
396
459
  /**
460
+ * Work `stop()` has to wait for: every run executing right now, and every
461
+ * run's log write still on its way to the store.
462
+ */
463
+ inFlight = /* @__PURE__ */ new Set();
464
+ /** The controller that ends each executing run — what `stop()` aborts. */
465
+ runControllers = /* @__PURE__ */ new Set();
466
+ /**
467
+ * This process, as a run lease names it: what "already executing on …"
468
+ * tells an operator. Each run adds its own suffix, so a release can only
469
+ * ever clear the lease that run took.
470
+ */
471
+ instanceLabel = `${hostname()}:${process.pid}`;
472
+ /**
397
473
  * Set the server singleton to make it available to cron job handlers.
398
474
  *
399
475
  * `RebaseServerClient`, not `RebaseClient`: the object `init.ts` passes is
@@ -426,23 +502,19 @@ var CronScheduler = class {
426
502
  const validation = validateCronExpression(loaded.definition.schedule);
427
503
  if (!validation.valid) {
428
504
  logger.error(`[cron] Rejecting job "${loaded.id}": invalid schedule "${loaded.definition.schedule}" — ${validation.reason}`);
429
- this.rejected.set(loaded.id, {
430
- id: loaded.id,
431
- name: loaded.definition.name ?? loaded.id,
432
- schedule: loaded.definition.schedule,
433
- reason: validation.reason
434
- });
505
+ this.refuse(loaded, validation.reason);
435
506
  continue;
436
507
  }
437
508
  if (loaded.definition.timezone !== void 0 && !isValidTimeZone(loaded.definition.timezone)) {
438
509
  const reason = `unknown timezone "${loaded.definition.timezone}" — use an IANA name such as "Europe/Madrid" or "UTC"`;
439
510
  logger.error(`[cron] Rejecting job "${loaded.id}": ${reason}.`);
440
- this.rejected.set(loaded.id, {
441
- id: loaded.id,
442
- name: loaded.definition.name ?? loaded.id,
443
- schedule: loaded.definition.schedule,
444
- reason
445
- });
511
+ this.refuse(loaded, reason);
512
+ continue;
513
+ }
514
+ const timeoutProblem = invalidTimeoutReason(loaded.definition.timeoutSeconds);
515
+ if (timeoutProblem) {
516
+ logger.error(`[cron] Rejecting job "${loaded.id}": ${timeoutProblem}.`);
517
+ this.refuse(loaded, timeoutProblem);
446
518
  continue;
447
519
  }
448
520
  this.rejected.delete(loaded.id);
@@ -451,7 +523,7 @@ var CronScheduler = class {
451
523
  this.stopJob(loaded.id);
452
524
  }
453
525
  const enabled = loaded.definition.enabled !== false;
454
- this.jobs.set(loaded.id, {
526
+ const job = {
455
527
  id: loaded.id,
456
528
  definition: loaded.definition,
457
529
  enabled,
@@ -460,8 +532,9 @@ var CronScheduler = class {
460
532
  totalFailures: 0,
461
533
  logs: [],
462
534
  executing: false
463
- });
464
- if (this.started && enabled) this.scheduleNext(loaded.id);
535
+ };
536
+ this.jobs.set(loaded.id, job);
537
+ if (this.started && this.shouldArm(job)) this.scheduleNext(loaded.id);
465
538
  }
466
539
  }
467
540
  /**
@@ -482,21 +555,42 @@ var CronScheduler = class {
482
555
  }).catch((err) => {
483
556
  logger.warn("[cron] Failed to seed job stats from database", { error: err });
484
557
  });
485
- for (const [id, job] of this.jobs) if (job.enabled) this.scheduleNext(id);
558
+ for (const [id, job] of this.jobs) if (this.shouldArm(job)) this.scheduleNext(id);
486
559
  if (!this.store) logger.warn("[cron] No cron store attached — runs are uncoordinated; with multiple app instances every instance will execute every job");
487
560
  this.warnIfScaleToZero();
488
561
  this.catchUpMissedSlots();
489
562
  logger.info(`⏰ Cron scheduler started with ${this.jobs.size} job(s)`);
490
563
  }
491
564
  /**
492
- * Stop the scheduler and clear all timers.
565
+ * Stop the scheduler: clear every timer, then wait for the runs executing
566
+ * right now — for at most `timeoutMs` when given.
567
+ *
568
+ * A run still going when the budget runs out has its `ctx.signal` aborted
569
+ * and ends there, recorded as a failure that says why. Its slot stays
570
+ * claimed, so no other instance re-runs it; a handler that must not be cut
571
+ * short should watch its signal and leave its work resumable.
493
572
  *
494
- * Currently-executing handlers run to completion (they are async),
495
- * but no further scheduling occurs after stop.
573
+ * Nothing is scheduled after `stop()`, whether or not the returned promise
574
+ * is awaited: the timers are cleared before it first yields.
496
575
  */
497
- stop() {
576
+ async stop(timeoutMs) {
498
577
  this.started = false;
499
578
  for (const [id] of this.jobs) this.stopJob(id);
579
+ if (this.inFlight.size === 0) return;
580
+ const settled = Promise.allSettled([...this.inFlight]).then(() => true);
581
+ if (timeoutMs === void 0) {
582
+ await settled;
583
+ return;
584
+ }
585
+ let budget;
586
+ const finished = await Promise.race([settled, new Promise((resolve) => {
587
+ budget = setTimeout(() => resolve(false), timeoutMs);
588
+ if (budget && typeof budget === "object" && "unref" in budget) budget.unref();
589
+ })]);
590
+ clearTimeout(budget);
591
+ if (finished || this.runControllers.size === 0) return;
592
+ logger.warn(`[cron] ${this.runControllers.size} run(s) still executing after ${timeoutMs}ms — aborting them`);
593
+ for (const controller of this.runControllers) controller.abort(/* @__PURE__ */ new Error("The server is shutting down; the run was stopped before it finished"));
500
594
  }
501
595
  /**
502
596
  * List all registered jobs with their current status.
@@ -504,6 +598,18 @@ var CronScheduler = class {
504
598
  listJobs() {
505
599
  return [...this.jobs.values()].map((job) => this.toStatus(job));
506
600
  }
601
+ /** Keep a refused job, so the admin surface can say why it never runs. */
602
+ refuse(loaded, reason) {
603
+ this.rejected.set(loaded.id, {
604
+ job: {
605
+ id: loaded.id,
606
+ name: loaded.definition.name ?? loaded.id,
607
+ schedule: loaded.definition.schedule,
608
+ reason
609
+ },
610
+ definition: loaded.definition
611
+ });
612
+ }
507
613
  /**
508
614
  * Jobs that loaded but whose schedule the scheduler refused.
509
615
  *
@@ -512,7 +618,22 @@ var CronScheduler = class {
512
618
  * cron will never fire" look identical from the admin panel.
513
619
  */
514
620
  listRejectedJobs() {
515
- return [...this.rejected.values()];
621
+ return [...this.rejected.values()].map((refused) => refused.job);
622
+ }
623
+ /**
624
+ * {@link listRejectedJobs}, those whose definition matches — how a surface
625
+ * finds a refused job by what it is, as {@link jobIdsWhere} does for the
626
+ * registered ones.
627
+ */
628
+ rejectedJobsWhere(predicate) {
629
+ return [...this.rejected.values()].filter((refused) => predicate(refused.definition)).map((refused) => refused.job);
630
+ }
631
+ /**
632
+ * The ids of the registered jobs whose definition matches — how a surface
633
+ * finds a job by what it is rather than by the file it happens to live in.
634
+ */
635
+ jobIdsWhere(predicate) {
636
+ return [...this.jobs.values()].filter((job) => predicate(job.definition)).map((job) => job.id);
516
637
  }
517
638
  /**
518
639
  * Get a single job status by ID.
@@ -522,6 +643,31 @@ var CronScheduler = class {
522
643
  return job ? this.toStatus(job) : void 0;
523
644
  }
524
645
  /**
646
+ * {@link listJobs}, as the fleet sees it rather than this process alone.
647
+ *
648
+ * Reads the store: every job's enabled state — a pause another replica
649
+ * made — whether another process holds its run lease, and each job's run
650
+ * count and last run from `cron_logs`. The counters in this process's
651
+ * memory count only the runs it made: the `api` role runs nothing and
652
+ * would say 0 runs for a job the worker has run a thousand times, and one
653
+ * of two scheduling replicas never sees the slots the other one claimed.
654
+ *
655
+ * A store that cannot answer leaves this process's own view, with a
656
+ * warning: a listing is not worth failing.
657
+ */
658
+ async fetchJobs() {
659
+ const jobs = [...this.jobs.values()];
660
+ const views = await this.readFleet(jobs);
661
+ return jobs.map((job) => this.toStatus(job, views.get(job.id)));
662
+ }
663
+ /** {@link getJob}, as {@link fetchJobs} reads it. */
664
+ async fetchJob(id) {
665
+ const job = this.jobs.get(id);
666
+ if (!job) return void 0;
667
+ const views = await this.readFleet([job]);
668
+ return this.toStatus(job, views.get(id));
669
+ }
670
+ /**
525
671
  * Get log entries for a job.
526
672
  */
527
673
  getJobLogs(id, limit) {
@@ -532,7 +678,12 @@ var CronScheduler = class {
532
678
  }
533
679
  /**
534
680
  * Get log entries for a job from the database (if store is available).
535
- * Falls back to in-memory logs if no store is configured.
681
+ * Falls back to in-memory logs if no store is configured, or if the store
682
+ * has none — a run whose row is still being written.
683
+ *
684
+ * Throws when the store cannot read the history. This process's memory is
685
+ * not a stand-in for it: it is empty on the `api` role and after a
686
+ * restart, and holds one replica's runs at most.
536
687
  */
537
688
  async getJobLogsFromDb(id, limit) {
538
689
  if (this.store) {
@@ -542,27 +693,49 @@ var CronScheduler = class {
542
693
  return this.getJobLogs(id, limit);
543
694
  }
544
695
  /**
545
- * Enable or disable a job at runtime.
696
+ * Enable or disable a job in this process only. `null` returns it to what
697
+ * its code declares.
698
+ *
699
+ * {@link persistJobEnabled} is what the admin API calls: this alone is
700
+ * forgotten on restart and reaches no other replica.
546
701
  */
547
702
  setJobEnabled(id, enabled) {
548
703
  const job = this.jobs.get(id);
549
704
  if (!job) return void 0;
550
- job.enabled = enabled;
551
- if (enabled && this.started) {
552
- job.state = "idle";
553
- this.scheduleNext(id);
554
- } else if (!enabled) {
555
- this.stopJob(id);
556
- job.state = "disabled";
557
- }
705
+ const effective = enabled ?? this.codeEnabled(job);
706
+ this.applyEnabled(job, effective);
707
+ if (effective && this.started) this.scheduleNext(id);
708
+ else if (!this.shouldArm(job)) this.stopJob(id);
558
709
  return this.toStatus(job);
559
710
  }
560
711
  /**
712
+ * Enable or disable a job for every process that runs it, and keep it that
713
+ * way across restarts and redeploys: `true` or `false` overrides the code's
714
+ * `enabled`, `null` hands the decision back to it.
715
+ *
716
+ * The override is written to the store first, and only then applied here,
717
+ * so a write that fails throws and changes nothing — a pause that only the
718
+ * replica serving the request would honour is the failure this exists to
719
+ * end. Every scheduler reads the override when a slot comes due, before it
720
+ * claims the slot.
721
+ *
722
+ * Without a store that keeps the state (a driver with no SQL, persistence
723
+ * switched off, a custom store) this is {@link setJobEnabled}: local, and
724
+ * gone on restart.
725
+ */
726
+ async persistJobEnabled(id, enabled, updatedBy) {
727
+ if (!this.jobs.has(id)) return void 0;
728
+ if (this.store?.saveEnabledOverride) await this.store.saveEnabledOverride(id, enabled, updatedBy);
729
+ return this.setJobEnabled(id, enabled);
730
+ }
731
+ /**
561
732
  * Manually trigger a job execution immediately.
562
733
  *
563
734
  * Returns `undefined` if the job doesn't exist.
564
- * If the job is currently executing, returns the log entry with
565
- * a `skipped: true` result rather than running concurrently.
735
+ * If the job is currently executing — here, or on another process that
736
+ * holds its run lease — returns the log entry with a `skipped: true`
737
+ * result rather than running concurrently; {@link isAlreadyExecutingSkip}
738
+ * recognises it.
566
739
  */
567
740
  async triggerJob(id) {
568
741
  const job = this.jobs.get(id);
@@ -587,8 +760,9 @@ var CronScheduler = class {
587
760
  * `success: true` is deliberate: nothing failed. The `result.skipped` flag
588
761
  * and the reason are what distinguishes it, and the Studio panel reads them.
589
762
  */
590
- recordSkip(job, reason, manual) {
763
+ recordSkip(job, reason, manual, runningOn) {
591
764
  const now = (/* @__PURE__ */ new Date()).toISOString();
765
+ const why = reason === "already_executing" ? "the previous run has not finished" : reason;
592
766
  const logEntry = {
593
767
  jobId: job.id,
594
768
  startedAt: now,
@@ -599,16 +773,143 @@ var CronScheduler = class {
599
773
  skipped: true,
600
774
  reason
601
775
  },
602
- logs: [`Skipped: ${reason === "already_executing" ? "the previous run has not finished" : reason}`],
776
+ logs: [`Skipped: ${why}${runningOn ? ` — it is running on ${runningOn}` : ""}`],
603
777
  manual
604
778
  };
605
779
  job.logs.push(logEntry);
606
780
  if (job.logs.length > MAX_LOGS_PER_JOB) job.logs.shift();
607
- this.store?.insertLog(logEntry).catch((persistErr) => {
781
+ if (this.store) this.track(this.store.insertLog(logEntry).catch((persistErr) => {
608
782
  logger.error(`[cron] Failed to persist skip for "${job.id}"`, { error: persistErr });
609
- });
783
+ }));
610
784
  return logEntry;
611
785
  }
786
+ /** What the job's own definition says, before any override. */
787
+ codeEnabled(job) {
788
+ return job.definition.enabled !== false;
789
+ }
790
+ /**
791
+ * Whether the enabled state lives in the store, where every process reads
792
+ * it, rather than in this process alone.
793
+ */
794
+ sharesEnabledState() {
795
+ return Boolean(this.store?.fetchJobStates && this.store.saveEnabledOverride);
796
+ }
797
+ /**
798
+ * Whether a job gets a timer here. A job paused in this process's memory
799
+ * needs none — nothing but this process can resume it. A job whose state
800
+ * the store keeps does, paused or not: another replica may resume it, and
801
+ * reading the state when its slot comes due is how this one finds out.
802
+ */
803
+ shouldArm(job) {
804
+ return job.enabled || this.sharesEnabledState();
805
+ }
806
+ /** Set a job's enabled flag, and its state with it unless a run is in progress. */
807
+ applyEnabled(job, enabled) {
808
+ job.enabled = enabled;
809
+ if (job.executing) return;
810
+ if (!enabled) job.state = "disabled";
811
+ else if (job.state === "disabled") job.state = "idle";
812
+ }
813
+ /**
814
+ * Whether a job may run now: the store's override when it keeps one, else
815
+ * the code's `enabled`.
816
+ *
817
+ * One query per fire, before the slot is claimed — a paused job does not
818
+ * spend its slot. A store that cannot answer falls back to the code's
819
+ * `enabled`, with a warning: a scheduled run fails open, as it does when
820
+ * the claims table cannot answer, because a broken state table must not
821
+ * silently stop every job.
822
+ */
823
+ async isEnabledNow(job) {
824
+ if (!this.store?.fetchJobStates || !this.sharesEnabledState()) return job.enabled;
825
+ try {
826
+ const enabled = (await this.store.fetchJobStates([job.id])).get(job.id)?.enabled ?? this.codeEnabled(job);
827
+ this.applyEnabled(job, enabled);
828
+ return enabled;
829
+ } catch (err) {
830
+ const enabled = this.codeEnabled(job);
831
+ logger.warn(`[cron] Could not read the enabled state of "${job.id}" — falling back to the code's enabled (${enabled}); a pause made from the admin API is not being honoured`, { error: err });
832
+ return enabled;
833
+ }
834
+ }
835
+ /**
836
+ * How long a run's lease lasts: the run's timeout and a grace, so that a
837
+ * crashed holder frees the job once its run would have been aborted
838
+ * anyway. No timeout gets {@link MAX_UNBOUNDED_LEASE_SECONDS}.
839
+ */
840
+ leaseSeconds(job) {
841
+ const timeout = job.definition.timeoutSeconds ?? DEFAULT_TIMEOUT_SECONDS;
842
+ return Number.isFinite(timeout) ? timeout + LEASE_GRACE_SECONDS : MAX_UNBOUNDED_LEASE_SECONDS;
843
+ }
844
+ /**
845
+ * Take the job's run lease, the cross-process half of "already
846
+ * executing". A store without leases, or one that cannot answer, lets the
847
+ * run go ahead unleased — with a warning in the second case — the same way
848
+ * a claim that cannot answer does.
849
+ */
850
+ async acquireLease(job) {
851
+ if (!this.store?.tryAcquireRunLease) return { acquired: true };
852
+ const token = `${this.instanceLabel}#${randomUUID().slice(0, 8)}`;
853
+ try {
854
+ const lease = await this.store.tryAcquireRunLease(job.id, token, this.leaseSeconds(job));
855
+ return lease.acquired ? {
856
+ acquired: true,
857
+ token
858
+ } : lease;
859
+ } catch (err) {
860
+ logger.warn(`[cron] Could not take the run lease for "${job.id}" — running without it, so another process may run it at the same time`, { error: err });
861
+ return { acquired: true };
862
+ }
863
+ }
864
+ /** Release a lease this run took. A release that fails leaves it to lapse. */
865
+ async releaseLease(job, token) {
866
+ try {
867
+ await this.store?.releaseRunLease?.(job.id, token);
868
+ } catch (err) {
869
+ logger.warn(`[cron] Could not release the run lease for "${job.id}" — it lapses on its own within ${this.leaseSeconds(job)}s`, { error: err });
870
+ }
871
+ }
872
+ /**
873
+ * What the store says about `jobs` beyond this process — see
874
+ * {@link fetchJobs}. Applies the enabled state it reads, and returns the
875
+ * rest as a view for {@link toStatus}. Never throws.
876
+ */
877
+ async readFleet(jobs) {
878
+ const views = /* @__PURE__ */ new Map();
879
+ const viewOf = (id) => {
880
+ let view = views.get(id);
881
+ if (!view) {
882
+ view = {};
883
+ views.set(id, view);
884
+ }
885
+ return view;
886
+ };
887
+ const store = this.store;
888
+ const ids = jobs.map((job) => job.id);
889
+ if (store?.fetchJobStates && this.sharesEnabledState() && ids.length > 0) try {
890
+ const states = await store.fetchJobStates(ids);
891
+ for (const job of jobs) {
892
+ const state = states.get(job.id);
893
+ this.applyEnabled(job, state?.enabled ?? this.codeEnabled(job));
894
+ if (state?.runningBy && !job.executing) viewOf(job.id).runningElsewhere = true;
895
+ }
896
+ } catch (err) {
897
+ logger.warn("[cron] Could not read the jobs' shared state — listing what this process knows", { error: err });
898
+ }
899
+ if (!this.started) {
900
+ const now = /* @__PURE__ */ new Date();
901
+ for (const job of jobs) try {
902
+ viewOf(job.id).nextRunAt = parseCronExpression(job.definition.schedule, now, job.definition.timezone);
903
+ } catch {}
904
+ }
905
+ if (store?.fetchRunSummaries && ids.length > 0) try {
906
+ const summaries = await store.fetchRunSummaries(ids);
907
+ for (const [id, summary] of summaries) viewOf(id).summary = summary;
908
+ } catch (err) {
909
+ logger.warn("[cron] Could not read the jobs' run history — listing what this process knows", { error: err });
910
+ }
911
+ return views;
912
+ }
612
913
  /**
613
914
  * Warn once at start when the process looks like it is running on a
614
915
  * platform that freezes or evicts instances between requests, where the
@@ -638,7 +939,7 @@ var CronScheduler = class {
638
939
  */
639
940
  scheduleNext(id) {
640
941
  const job = this.jobs.get(id);
641
- if (!job || !job.enabled || !this.started) return;
942
+ if (!job || !this.started || !this.shouldArm(job)) return;
642
943
  this.stopJob(id);
643
944
  try {
644
945
  const now = /* @__PURE__ */ new Date();
@@ -648,21 +949,26 @@ var CronScheduler = class {
648
949
  const delay = Math.max(rawDelay, MIN_SCHEDULE_INTERVAL_MS);
649
950
  if (delay > MAX_TIMER_DELAY_MS) {
650
951
  const hop = setTimeout(() => {
651
- if (this.started && job.enabled) this.scheduleNext(id);
952
+ if (this.started && this.shouldArm(job)) this.scheduleNext(id);
652
953
  }, MAX_TIMER_DELAY_MS);
653
954
  if (hop && typeof hop === "object" && "unref" in hop) hop.unref();
654
955
  job.timerId = hop;
655
956
  return;
656
957
  }
657
958
  const timer = setTimeout(async () => {
658
- if (!job.enabled || !this.started) return;
659
- if (job.executing) {
660
- logger.warn(`[cron] Skipping scheduled run of "${id}" — still executing from previous run`);
661
- this.recordSkip(job, "already_executing", false);
959
+ if (!this.started) return;
960
+ if (Date.now() < nextRun.getTime()) {
662
961
  this.scheduleNext(id);
663
962
  return;
664
963
  }
665
- if (Date.now() < nextRun.getTime()) {
964
+ if (!await this.isEnabledNow(job)) {
965
+ if (this.started && this.shouldArm(job)) this.scheduleNext(id);
966
+ return;
967
+ }
968
+ if (!this.started) return;
969
+ if (job.executing) {
970
+ logger.warn(`[cron] Skipping scheduled run of "${id}" — still executing from previous run`);
971
+ this.recordSkip(job, "already_executing", false);
666
972
  this.scheduleNext(id);
667
973
  return;
668
974
  }
@@ -675,12 +981,12 @@ var CronScheduler = class {
675
981
  }
676
982
  if (!claimed) {
677
983
  logger.info(`[cron] Slot ${nextRun.toISOString()} for "${id}" claimed by another instance — skipping`);
678
- if (this.started && job.enabled) this.scheduleNext(id);
984
+ if (this.started && this.shouldArm(job)) this.scheduleNext(id);
679
985
  return;
680
986
  }
681
987
  }
682
988
  await this.executeJob(job, false);
683
- if (this.started && job.enabled) this.scheduleNext(id);
989
+ if (this.started && this.shouldArm(job)) this.scheduleNext(id);
684
990
  }, delay);
685
991
  if (timer && typeof timer === "object" && "unref" in timer) timer.unref();
686
992
  job.timerId = timer;
@@ -707,15 +1013,17 @@ var CronScheduler = class {
707
1013
  * otherwise ticking correctly.
708
1014
  */
709
1015
  async catchUpMissedSlots() {
710
- const candidates = [...this.jobs.values()].filter((job) => job.enabled && (job.definition.catchUpWindowSeconds ?? 0) > 0);
1016
+ const candidates = [...this.jobs.values()].filter((job) => this.shouldArm(job) && (job.definition.catchUpWindowSeconds ?? 0) > 0);
711
1017
  if (candidates.length === 0) return;
712
1018
  if (!this.store?.tryClaimRun) {
713
1019
  logger.warn(`[cron] Catch-up is configured on ${candidates.length} job(s) but no claims-capable store is attached — skipping. Without claims a restart cannot tell an unrun slot from one the previous instance already ran.`);
714
1020
  return;
715
1021
  }
716
- const now = /* @__PURE__ */ new Date();
717
1022
  for (const job of candidates) try {
718
- if (!this.started || !job.enabled || job.executing) continue;
1023
+ if (!this.started || job.executing) continue;
1024
+ if (!await this.isEnabledNow(job)) continue;
1025
+ if (!this.started || job.executing) continue;
1026
+ const now = /* @__PURE__ */ new Date();
719
1027
  const windowSeconds = job.definition.catchUpWindowSeconds;
720
1028
  const from = /* @__PURE__ */ new Date(now.getTime() - windowSeconds * 1e3);
721
1029
  const slot = findMostRecentSlot(job.definition.schedule, from, now, job.definition.timezone);
@@ -756,11 +1064,34 @@ var CronScheduler = class {
756
1064
  * - Persists to store (non-blocking) if available
757
1065
  * - Always restores state even on catastrophic errors
758
1066
  */
759
- async executeJob(job, manual, seedLog) {
1067
+ executeJob(job, manual, seedLog) {
1068
+ const abort = new AbortController();
1069
+ this.runControllers.add(abort);
1070
+ const run = this.runJob(job, manual, abort, seedLog);
1071
+ this.track(run);
1072
+ return run.finally(() => {
1073
+ this.runControllers.delete(abort);
1074
+ });
1075
+ }
1076
+ /** Hold `stop()` until `work` settles. */
1077
+ track(work) {
1078
+ this.inFlight.add(work);
1079
+ const release = () => {
1080
+ this.inFlight.delete(work);
1081
+ };
1082
+ work.then(release, release);
1083
+ }
1084
+ async runJob(job, manual, abort, seedLog) {
1085
+ if (job.executing) return this.recordSkip(job, "already_executing", manual);
1086
+ job.executing = true;
1087
+ const lease = await this.acquireLease(job);
1088
+ if (!lease.acquired) {
1089
+ job.executing = false;
1090
+ logger.warn(`[cron] Skipping ${manual ? "manual trigger" : "scheduled run"} of "${job.id}" — it is running on ${lease.holder ?? "another process"}`);
1091
+ return this.recordSkip(job, "already_executing", manual, lease.holder ?? "another process");
1092
+ }
760
1093
  const startedAt = /* @__PURE__ */ new Date();
761
1094
  const capturedLogs = seedLog ? [seedLog] : [];
762
- job.executing = true;
763
- const abort = new AbortController();
764
1095
  const ctx = {
765
1096
  jobId: job.id,
766
1097
  scheduledAt: startedAt,
@@ -768,6 +1099,7 @@ var CronScheduler = class {
768
1099
  log: (...args) => {
769
1100
  const line = args.map((a) => typeof a === "string" ? a : JSON.stringify(a)).join(" ");
770
1101
  capturedLogs.push(line);
1102
+ logger.info(args.map((a) => typeof a === "string" ? a : stringifyForLog(a)).join(" "), { cron: job.id });
771
1103
  },
772
1104
  rebase: this.client
773
1105
  };
@@ -777,26 +1109,26 @@ var CronScheduler = class {
777
1109
  let success = true;
778
1110
  let error;
779
1111
  let result;
1112
+ const aborted = new Promise((_, reject) => {
1113
+ const end = () => reject(abort.signal.reason);
1114
+ if (abort.signal.aborted) end();
1115
+ else abort.signal.addEventListener("abort", end, { once: true });
1116
+ });
1117
+ aborted.catch(() => void 0);
780
1118
  try {
781
- const timeout = (job.definition.timeoutSeconds ?? 300) * 1e3;
782
- const handlerPromise = Promise.resolve(job.definition.handler(ctx));
783
- let timeoutHandle;
784
- const timeoutPromise = new Promise((_, reject) => {
785
- timeoutHandle = setTimeout(() => {
786
- abort.abort(/* @__PURE__ */ new Error(`Cron job "${job.id}" timed out after ${timeout}ms`));
787
- reject(/* @__PURE__ */ new Error(`Cron job "${job.id}" timed out after ${timeout}ms`));
788
- }, timeout);
789
- });
1119
+ const timeout = (job.definition.timeoutSeconds ?? DEFAULT_TIMEOUT_SECONDS) * 1e3;
1120
+ const cancelTimeout = Number.isFinite(timeout) ? setLongTimeout(() => abort.abort(/* @__PURE__ */ new Error(`Cron job "${job.id}" timed out after ${timeout}ms`)), timeout) : void 0;
790
1121
  try {
791
- result = await Promise.race([handlerPromise, timeoutPromise]);
1122
+ result = await Promise.race([Promise.resolve(job.definition.handler(ctx)), aborted]);
792
1123
  } finally {
793
- clearTimeout(timeoutHandle);
1124
+ cancelTimeout?.();
794
1125
  }
795
1126
  } catch (err) {
796
1127
  success = false;
797
1128
  error = redactSensitiveText(err instanceof Error ? err.message : String(err));
798
1129
  job.totalFailures++;
799
1130
  } finally {
1131
+ if (lease.token) await this.releaseLease(job, lease.token);
800
1132
  job.executing = false;
801
1133
  }
802
1134
  const finishedAt = /* @__PURE__ */ new Date();
@@ -817,31 +1149,43 @@ var CronScheduler = class {
817
1149
  };
818
1150
  job.logs.push(logEntry);
819
1151
  if (job.logs.length > MAX_LOGS_PER_JOB) job.logs.shift();
820
- if (this.store) this.store.insertLog(logEntry).catch((persistErr) => {
1152
+ if (this.store) this.track(this.store.insertLog(logEntry).catch((persistErr) => {
821
1153
  logger.error(`[cron] Failed to persist log for "${job.id}"`, { error: persistErr });
822
- });
1154
+ }));
823
1155
  if (success) logger.info(`✅ [cron] "${job.id}" completed in ${durationMs}ms`);
824
1156
  else logger.error(`❌ [cron] "${job.id}" failed in ${durationMs}ms: ${error}`);
825
1157
  return logEntry;
826
1158
  }
827
- toStatus(job) {
1159
+ /**
1160
+ * A job's status: this process's own record, overlaid with what `view`
1161
+ * read from the store. A paused job shows no next run, even where its
1162
+ * timer stays armed to read the state again at that slot.
1163
+ */
1164
+ toStatus(job, view = {}) {
1165
+ const summary = view.summary;
1166
+ const nextRunAt = job.enabled ? job.nextRunAt ?? view.nextRunAt : void 0;
1167
+ let state = job.state;
1168
+ if (!job.executing) {
1169
+ if (view.runningElsewhere) state = "running";
1170
+ else if (summary && job.enabled) state = summary.lastSuccess === false ? "error" : "idle";
1171
+ }
828
1172
  return {
829
1173
  id: job.id,
830
1174
  name: job.definition.name ?? job.id,
831
1175
  description: job.definition.description,
832
1176
  schedule: job.definition.schedule,
833
1177
  enabled: job.enabled,
834
- state: job.state,
835
- lastRunAt: job.lastRunAt?.toISOString(),
836
- nextRunAt: job.nextRunAt?.toISOString(),
837
- lastDurationMs: job.lastDurationMs,
838
- lastError: job.lastError,
839
- totalRuns: job.totalRuns,
840
- totalFailures: job.totalFailures
1178
+ state,
1179
+ lastRunAt: summary ? summary.lastRunAt : job.lastRunAt?.toISOString(),
1180
+ nextRunAt: nextRunAt?.toISOString(),
1181
+ lastDurationMs: summary ? summary.lastDurationMs : job.lastDurationMs,
1182
+ lastError: summary ? summary.lastSuccess === false ? summary.lastError : void 0 : job.lastError,
1183
+ totalRuns: summary ? summary.totalRuns : job.totalRuns,
1184
+ totalFailures: summary ? summary.totalFailures : job.totalFailures
841
1185
  };
842
1186
  }
843
1187
  };
844
1188
  //#endregion
845
- export { parseEnvBoolean as i, cron_scheduler_exports as n, validateCronExpression as r, CronScheduler as t };
1189
+ export { parseEnvBoolean as a, validateCronExpression as i, cron_scheduler_exports as n, isAlreadyExecutingSkip as r, CronScheduler as t };
846
1190
 
847
- //# sourceMappingURL=cron-scheduler-COPQxlEq.js.map
1191
+ //# sourceMappingURL=cron-scheduler-CYQgco86.js.map