@rebasepro/server 0.21.2-canary.g1ea48be → 0.23.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 (115) hide show
  1. package/dist/{GCSStorageController-CLIJXwGS.js → GCSStorageController-CjrA4PMo.js} +37 -23
  2. package/dist/GCSStorageController-CjrA4PMo.js.map +1 -0
  3. package/dist/{S3StorageController-Dcuf8lMA.js → S3StorageController-B6pKDNVj.js} +2 -2
  4. package/dist/{S3StorageController-Dcuf8lMA.js.map → S3StorageController-B6pKDNVj.js.map} +1 -1
  5. package/dist/api/ast-schema-editor.d.ts +35 -0
  6. package/dist/api/live-schema-routes.d.ts +14 -0
  7. package/dist/api/rest/api-generator.d.ts +117 -35
  8. package/dist/api/rest/auth-collection-writes.d.ts +85 -0
  9. package/dist/api/rest/field-access-query.d.ts +6 -2
  10. package/dist/api/rest/idempotency.d.ts +7 -1
  11. package/dist/api/rest/nested-write-access.d.ts +46 -0
  12. package/dist/api/rest/write-validation.d.ts +32 -0
  13. package/dist/{ast-schema-editor-CslO8Oje.js → ast-schema-editor-Mvr50v_S.js} +104 -4
  14. package/dist/ast-schema-editor-Mvr50v_S.js.map +1 -0
  15. package/dist/auth/address-ownership.d.ts +53 -0
  16. package/dist/auth/admin-user-ops.d.ts +35 -2
  17. package/dist/auth/auth-hooks.d.ts +4 -0
  18. package/dist/auth/captcha.d.ts +5 -0
  19. package/dist/auth/interfaces.d.ts +36 -6
  20. package/dist/auth/jwt.d.ts +17 -0
  21. package/dist/auth/oauth-signin-policy.d.ts +25 -8
  22. package/dist/auth/rate-limiter.d.ts +31 -1
  23. package/dist/auth/session-routes.d.ts +7 -0
  24. package/dist/auth/token-revocation.d.ts +4 -1
  25. package/dist/{auth-DZXQRmYD.js → auth-B-GIMpDG.js} +643 -230
  26. package/dist/auth-B-GIMpDG.js.map +1 -0
  27. package/dist/backend-DTAOsLQc.js +30 -0
  28. package/dist/backend-DTAOsLQc.js.map +1 -0
  29. package/dist/backup/backup-common.d.ts +19 -0
  30. package/dist/{backup-DzI9jLwc.js → backup-D7YR94N3.js} +69 -8
  31. package/dist/backup-D7YR94N3.js.map +1 -0
  32. package/dist/boot/driver.d.ts +10 -0
  33. package/dist/boot/env.d.ts +2 -2
  34. package/dist/boot/fetch-bundle.d.ts +18 -1
  35. package/dist/boot/rls-audit-option.d.ts +26 -0
  36. package/dist/boot/sources.d.ts +1 -0
  37. package/dist/{contract-routes-eLxV0le1.js → contract-routes-CbFjuBwa.js} +2 -2
  38. package/dist/{contract-routes-eLxV0le1.js.map → contract-routes-CbFjuBwa.js.map} +1 -1
  39. package/dist/cron/cron-routes.d.ts +7 -2
  40. package/dist/cron/cron-scheduler.d.ts +128 -8
  41. package/dist/cron/cron-store.d.ts +72 -8
  42. package/dist/cron/index.d.ts +1 -1
  43. package/dist/{cron-loader-CQjvjpEw.js → cron-loader-DfTj2Hbi.js} +2 -2
  44. package/dist/{cron-loader-CQjvjpEw.js.map → cron-loader-DfTj2Hbi.js.map} +1 -1
  45. package/dist/{cron-routes-cv0hSd-O.js → cron-routes-eE8nif_b.js} +33 -12
  46. package/dist/cron-routes-eE8nif_b.js.map +1 -0
  47. package/dist/{cron-scheduler-COPQxlEq.js → cron-scheduler-B0pLfAix.js} +394 -68
  48. package/dist/cron-scheduler-B0pLfAix.js.map +1 -0
  49. package/dist/{cron-store-Bdm7JqFB.js → cron-store-TcoGz-xS.js} +133 -12
  50. package/dist/cron-store-TcoGz-xS.js.map +1 -0
  51. package/dist/{ddl-bootstrap-CfNvxMuK.js → ddl-bootstrap-C6mo0Kmz.js} +2 -25
  52. package/dist/ddl-bootstrap-C6mo0Kmz.js.map +1 -0
  53. package/dist/email/link-base.d.ts +5 -4
  54. package/dist/email/smtp-email-service.d.ts +13 -1
  55. package/dist/email/templates.d.ts +9 -0
  56. package/dist/email/types.d.ts +3 -2
  57. package/dist/env.d.ts +24 -5
  58. package/dist/{history-recorder-CYfkqP2X.js → history-recorder-B4MpJfJK.js} +8 -6
  59. package/dist/history-recorder-B4MpJfJK.js.map +1 -0
  60. package/dist/{history-store-B1CBwLfi.js → history-store-BhxWOuz9.js} +2 -2
  61. package/dist/{history-store-B1CBwLfi.js.map → history-store-BhxWOuz9.js.map} +1 -1
  62. package/dist/index.d.ts +6 -2
  63. package/dist/index.es.js +2769 -878
  64. package/dist/index.es.js.map +1 -1
  65. package/dist/init/docs.d.ts +5 -2
  66. package/dist/init/global-callbacks.d.ts +21 -0
  67. package/dist/init/shutdown.d.ts +8 -3
  68. package/dist/jobs/index.d.ts +2 -2
  69. package/dist/jobs/job-queue.d.ts +23 -2
  70. package/dist/jobs/job-store.d.ts +37 -5
  71. package/dist/jobs/types.d.ts +8 -6
  72. package/dist/{jobs-2fO2BI8P.js → jobs-CazMYhyy.js} +305 -166
  73. package/dist/jobs-CazMYhyy.js.map +1 -0
  74. package/dist/{jwt-C4OW-DNq.js → jwt-DnQHNFCl.js} +77 -25
  75. package/dist/{jwt-C4OW-DNq.js.map → jwt-DnQHNFCl.js.map} +1 -1
  76. package/dist/{keys-Qfc4XieN.js → keys-CogCQpxG.js} +18 -3
  77. package/dist/{keys-Qfc4XieN.js.map → keys-CogCQpxG.js.map} +1 -1
  78. package/dist/{logs-routes-3EEzPjhl.js → logs-routes-Bj4TYYUl.js} +7 -4
  79. package/dist/{logs-routes-3EEzPjhl.js.map → logs-routes-Bj4TYYUl.js.map} +1 -1
  80. package/dist/mcp/mcp-routes.d.ts +38 -2
  81. package/dist/mcp/mcp-tools.d.ts +7 -1
  82. package/dist/mcp/oauth-routes.d.ts +27 -0
  83. package/dist/mcp/oauth-store.d.ts +29 -13
  84. package/dist/metrics/history-recorder.d.ts +1 -1
  85. package/dist/{openapi-generator-D8ZIN_ss.js → openapi-generator-O_O24MAT.js} +34 -12
  86. package/dist/openapi-generator-O_O24MAT.js.map +1 -0
  87. package/dist/{query-parser-CoIOmflr.js → query-parser-DGRVFNM3.js} +39 -30
  88. package/dist/query-parser-DGRVFNM3.js.map +1 -0
  89. package/dist/rls-audit/index.d.ts +4 -0
  90. package/dist/{schema-editor-routes-DdLihzp0.js → schema-editor-routes-C5-lh_jO.js} +2 -2
  91. package/dist/{schema-editor-routes-DdLihzp0.js.map → schema-editor-routes-C5-lh_jO.js.map} +1 -1
  92. package/dist/{src-BYax9_rm.js → src-pmvW7BFx.js} +189 -14
  93. package/dist/src-pmvW7BFx.js.map +1 -0
  94. package/dist/{src-Br6ARbs6.js → src-vkcwKXbT.js} +2 -27
  95. package/dist/{src-Br6ARbs6.js.map → src-vkcwKXbT.js.map} +1 -1
  96. package/dist/storage/GCSStorageController.d.ts +11 -1
  97. package/dist/storage/keys.d.ts +12 -0
  98. package/dist/storage/rendition-cache.d.ts +11 -1
  99. package/dist/storage/request-keys.d.ts +67 -0
  100. package/dist/storage/types.d.ts +17 -1
  101. package/dist/types-BfKcm9do.js.map +1 -1
  102. package/package.json +9 -9
  103. package/dist/GCSStorageController-CLIJXwGS.js.map +0 -1
  104. package/dist/ast-schema-editor-CslO8Oje.js.map +0 -1
  105. package/dist/auth-DZXQRmYD.js.map +0 -1
  106. package/dist/backup-DzI9jLwc.js.map +0 -1
  107. package/dist/cron-routes-cv0hSd-O.js.map +0 -1
  108. package/dist/cron-scheduler-COPQxlEq.js.map +0 -1
  109. package/dist/cron-store-Bdm7JqFB.js.map +0 -1
  110. package/dist/ddl-bootstrap-CfNvxMuK.js.map +0 -1
  111. package/dist/history-recorder-CYfkqP2X.js.map +0 -1
  112. package/dist/jobs-2fO2BI8P.js.map +0 -1
  113. package/dist/openapi-generator-D8ZIN_ss.js.map +0 -1
  114. package/dist/query-parser-CoIOmflr.js.map +0 -1
  115. package/dist/src-BYax9_rm.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";
6
+ import "./src-vkcwKXbT.js";
7
7
  import { a as redactSensitiveText, r as logger } from "./logger-DO2PZc4i.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
@@ -445,13 +521,24 @@ var CronScheduler = class {
445
521
  });
446
522
  continue;
447
523
  }
524
+ const timeoutProblem = invalidTimeoutReason(loaded.definition.timeoutSeconds);
525
+ if (timeoutProblem) {
526
+ logger.error(`[cron] Rejecting job "${loaded.id}": ${timeoutProblem}.`);
527
+ this.rejected.set(loaded.id, {
528
+ id: loaded.id,
529
+ name: loaded.definition.name ?? loaded.id,
530
+ schedule: loaded.definition.schedule,
531
+ reason: timeoutProblem
532
+ });
533
+ continue;
534
+ }
448
535
  this.rejected.delete(loaded.id);
449
536
  if (this.jobs.get(loaded.id)) {
450
537
  logger.warn(`[cron] Duplicate cron job id: "${loaded.id}". Overwriting.`);
451
538
  this.stopJob(loaded.id);
452
539
  }
453
540
  const enabled = loaded.definition.enabled !== false;
454
- this.jobs.set(loaded.id, {
541
+ const job = {
455
542
  id: loaded.id,
456
543
  definition: loaded.definition,
457
544
  enabled,
@@ -460,8 +547,9 @@ var CronScheduler = class {
460
547
  totalFailures: 0,
461
548
  logs: [],
462
549
  executing: false
463
- });
464
- if (this.started && enabled) this.scheduleNext(loaded.id);
550
+ };
551
+ this.jobs.set(loaded.id, job);
552
+ if (this.started && this.shouldArm(job)) this.scheduleNext(loaded.id);
465
553
  }
466
554
  }
467
555
  /**
@@ -482,21 +570,42 @@ var CronScheduler = class {
482
570
  }).catch((err) => {
483
571
  logger.warn("[cron] Failed to seed job stats from database", { error: err });
484
572
  });
485
- for (const [id, job] of this.jobs) if (job.enabled) this.scheduleNext(id);
573
+ for (const [id, job] of this.jobs) if (this.shouldArm(job)) this.scheduleNext(id);
486
574
  if (!this.store) logger.warn("[cron] No cron store attached — runs are uncoordinated; with multiple app instances every instance will execute every job");
487
575
  this.warnIfScaleToZero();
488
576
  this.catchUpMissedSlots();
489
577
  logger.info(`⏰ Cron scheduler started with ${this.jobs.size} job(s)`);
490
578
  }
491
579
  /**
492
- * Stop the scheduler and clear all timers.
580
+ * Stop the scheduler: clear every timer, then wait for the runs executing
581
+ * right now — for at most `timeoutMs` when given.
582
+ *
583
+ * A run still going when the budget runs out has its `ctx.signal` aborted
584
+ * and ends there, recorded as a failure that says why. Its slot stays
585
+ * claimed, so no other instance re-runs it; a handler that must not be cut
586
+ * short should watch its signal and leave its work resumable.
493
587
  *
494
- * Currently-executing handlers run to completion (they are async),
495
- * but no further scheduling occurs after stop.
588
+ * Nothing is scheduled after `stop()`, whether or not the returned promise
589
+ * is awaited: the timers are cleared before it first yields.
496
590
  */
497
- stop() {
591
+ async stop(timeoutMs) {
498
592
  this.started = false;
499
593
  for (const [id] of this.jobs) this.stopJob(id);
594
+ if (this.inFlight.size === 0) return;
595
+ const settled = Promise.allSettled([...this.inFlight]).then(() => true);
596
+ if (timeoutMs === void 0) {
597
+ await settled;
598
+ return;
599
+ }
600
+ let budget;
601
+ const finished = await Promise.race([settled, new Promise((resolve) => {
602
+ budget = setTimeout(() => resolve(false), timeoutMs);
603
+ if (budget && typeof budget === "object" && "unref" in budget) budget.unref();
604
+ })]);
605
+ clearTimeout(budget);
606
+ if (finished || this.runControllers.size === 0) return;
607
+ logger.warn(`[cron] ${this.runControllers.size} run(s) still executing after ${timeoutMs}ms — aborting them`);
608
+ for (const controller of this.runControllers) controller.abort(/* @__PURE__ */ new Error("The server is shutting down; the run was stopped before it finished"));
500
609
  }
501
610
  /**
502
611
  * List all registered jobs with their current status.
@@ -522,6 +631,31 @@ var CronScheduler = class {
522
631
  return job ? this.toStatus(job) : void 0;
523
632
  }
524
633
  /**
634
+ * {@link listJobs}, as the fleet sees it rather than this process alone.
635
+ *
636
+ * Reads the store: every job's enabled state — a pause another replica
637
+ * made — and whether another process holds its run lease. On a process
638
+ * whose scheduler is not started (the `api` role) it also reads each job's
639
+ * run count and last run from `cron_logs`, since that process runs nothing
640
+ * and its own counters would say 0 runs for a job the worker has run a
641
+ * thousand times.
642
+ *
643
+ * A store that cannot answer leaves this process's own view, with a
644
+ * warning: a listing is not worth failing.
645
+ */
646
+ async fetchJobs() {
647
+ const jobs = [...this.jobs.values()];
648
+ const views = await this.readFleet(jobs);
649
+ return jobs.map((job) => this.toStatus(job, views.get(job.id)));
650
+ }
651
+ /** {@link getJob}, as {@link fetchJobs} reads it. */
652
+ async fetchJob(id) {
653
+ const job = this.jobs.get(id);
654
+ if (!job) return void 0;
655
+ const views = await this.readFleet([job]);
656
+ return this.toStatus(job, views.get(id));
657
+ }
658
+ /**
525
659
  * Get log entries for a job.
526
660
  */
527
661
  getJobLogs(id, limit) {
@@ -542,27 +676,49 @@ var CronScheduler = class {
542
676
  return this.getJobLogs(id, limit);
543
677
  }
544
678
  /**
545
- * Enable or disable a job at runtime.
679
+ * Enable or disable a job in this process only. `null` returns it to what
680
+ * its code declares.
681
+ *
682
+ * {@link persistJobEnabled} is what the admin API calls: this alone is
683
+ * forgotten on restart and reaches no other replica.
546
684
  */
547
685
  setJobEnabled(id, enabled) {
548
686
  const job = this.jobs.get(id);
549
687
  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
- }
688
+ const effective = enabled ?? this.codeEnabled(job);
689
+ this.applyEnabled(job, effective);
690
+ if (effective && this.started) this.scheduleNext(id);
691
+ else if (!this.shouldArm(job)) this.stopJob(id);
558
692
  return this.toStatus(job);
559
693
  }
560
694
  /**
695
+ * Enable or disable a job for every process that runs it, and keep it that
696
+ * way across restarts and redeploys: `true` or `false` overrides the code's
697
+ * `enabled`, `null` hands the decision back to it.
698
+ *
699
+ * The override is written to the store first, and only then applied here,
700
+ * so a write that fails throws and changes nothing — a pause that only the
701
+ * replica serving the request would honour is the failure this exists to
702
+ * end. Every scheduler reads the override when a slot comes due, before it
703
+ * claims the slot.
704
+ *
705
+ * Without a store that keeps the state (a driver with no SQL, persistence
706
+ * switched off, a custom store) this is {@link setJobEnabled}: local, and
707
+ * gone on restart.
708
+ */
709
+ async persistJobEnabled(id, enabled, updatedBy) {
710
+ if (!this.jobs.has(id)) return void 0;
711
+ if (this.store?.saveEnabledOverride) await this.store.saveEnabledOverride(id, enabled, updatedBy);
712
+ return this.setJobEnabled(id, enabled);
713
+ }
714
+ /**
561
715
  * Manually trigger a job execution immediately.
562
716
  *
563
717
  * 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.
718
+ * If the job is currently executing — here, or on another process that
719
+ * holds its run lease — returns the log entry with a `skipped: true`
720
+ * result rather than running concurrently; {@link isAlreadyExecutingSkip}
721
+ * recognises it.
566
722
  */
567
723
  async triggerJob(id) {
568
724
  const job = this.jobs.get(id);
@@ -587,8 +743,9 @@ var CronScheduler = class {
587
743
  * `success: true` is deliberate: nothing failed. The `result.skipped` flag
588
744
  * and the reason are what distinguishes it, and the Studio panel reads them.
589
745
  */
590
- recordSkip(job, reason, manual) {
746
+ recordSkip(job, reason, manual, runningOn) {
591
747
  const now = (/* @__PURE__ */ new Date()).toISOString();
748
+ const why = reason === "already_executing" ? "the previous run has not finished" : reason;
592
749
  const logEntry = {
593
750
  jobId: job.id,
594
751
  startedAt: now,
@@ -599,16 +756,143 @@ var CronScheduler = class {
599
756
  skipped: true,
600
757
  reason
601
758
  },
602
- logs: [`Skipped: ${reason === "already_executing" ? "the previous run has not finished" : reason}`],
759
+ logs: [`Skipped: ${why}${runningOn ? ` — it is running on ${runningOn}` : ""}`],
603
760
  manual
604
761
  };
605
762
  job.logs.push(logEntry);
606
763
  if (job.logs.length > MAX_LOGS_PER_JOB) job.logs.shift();
607
- this.store?.insertLog(logEntry).catch((persistErr) => {
764
+ if (this.store) this.track(this.store.insertLog(logEntry).catch((persistErr) => {
608
765
  logger.error(`[cron] Failed to persist skip for "${job.id}"`, { error: persistErr });
609
- });
766
+ }));
610
767
  return logEntry;
611
768
  }
769
+ /** What the job's own definition says, before any override. */
770
+ codeEnabled(job) {
771
+ return job.definition.enabled !== false;
772
+ }
773
+ /**
774
+ * Whether the enabled state lives in the store, where every process reads
775
+ * it, rather than in this process alone.
776
+ */
777
+ sharesEnabledState() {
778
+ return Boolean(this.store?.fetchJobStates && this.store.saveEnabledOverride);
779
+ }
780
+ /**
781
+ * Whether a job gets a timer here. A job paused in this process's memory
782
+ * needs none — nothing but this process can resume it. A job whose state
783
+ * the store keeps does, paused or not: another replica may resume it, and
784
+ * reading the state when its slot comes due is how this one finds out.
785
+ */
786
+ shouldArm(job) {
787
+ return job.enabled || this.sharesEnabledState();
788
+ }
789
+ /** Set a job's enabled flag, and its state with it unless a run is in progress. */
790
+ applyEnabled(job, enabled) {
791
+ job.enabled = enabled;
792
+ if (job.executing) return;
793
+ if (!enabled) job.state = "disabled";
794
+ else if (job.state === "disabled") job.state = "idle";
795
+ }
796
+ /**
797
+ * Whether a job may run now: the store's override when it keeps one, else
798
+ * the code's `enabled`.
799
+ *
800
+ * One query per fire, before the slot is claimed — a paused job does not
801
+ * spend its slot. A store that cannot answer falls back to the code's
802
+ * `enabled`, with a warning: a scheduled run fails open, as it does when
803
+ * the claims table cannot answer, because a broken state table must not
804
+ * silently stop every job.
805
+ */
806
+ async isEnabledNow(job) {
807
+ if (!this.store?.fetchJobStates || !this.sharesEnabledState()) return job.enabled;
808
+ try {
809
+ const enabled = (await this.store.fetchJobStates([job.id])).get(job.id)?.enabled ?? this.codeEnabled(job);
810
+ this.applyEnabled(job, enabled);
811
+ return enabled;
812
+ } catch (err) {
813
+ const enabled = this.codeEnabled(job);
814
+ 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 });
815
+ return enabled;
816
+ }
817
+ }
818
+ /**
819
+ * How long a run's lease lasts: the run's timeout and a grace, so that a
820
+ * crashed holder frees the job once its run would have been aborted
821
+ * anyway. No timeout gets {@link MAX_UNBOUNDED_LEASE_SECONDS}.
822
+ */
823
+ leaseSeconds(job) {
824
+ const timeout = job.definition.timeoutSeconds ?? DEFAULT_TIMEOUT_SECONDS;
825
+ return Number.isFinite(timeout) ? timeout + LEASE_GRACE_SECONDS : MAX_UNBOUNDED_LEASE_SECONDS;
826
+ }
827
+ /**
828
+ * Take the job's run lease, the cross-process half of "already
829
+ * executing". A store without leases, or one that cannot answer, lets the
830
+ * run go ahead unleased — with a warning in the second case — the same way
831
+ * a claim that cannot answer does.
832
+ */
833
+ async acquireLease(job) {
834
+ if (!this.store?.tryAcquireRunLease) return { acquired: true };
835
+ const token = `${this.instanceLabel}#${randomUUID().slice(0, 8)}`;
836
+ try {
837
+ const lease = await this.store.tryAcquireRunLease(job.id, token, this.leaseSeconds(job));
838
+ return lease.acquired ? {
839
+ acquired: true,
840
+ token
841
+ } : lease;
842
+ } catch (err) {
843
+ 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 });
844
+ return { acquired: true };
845
+ }
846
+ }
847
+ /** Release a lease this run took. A release that fails leaves it to lapse. */
848
+ async releaseLease(job, token) {
849
+ try {
850
+ await this.store?.releaseRunLease?.(job.id, token);
851
+ } catch (err) {
852
+ logger.warn(`[cron] Could not release the run lease for "${job.id}" — it lapses on its own within ${this.leaseSeconds(job)}s`, { error: err });
853
+ }
854
+ }
855
+ /**
856
+ * What the store says about `jobs` beyond this process — see
857
+ * {@link fetchJobs}. Applies the enabled state it reads, and returns the
858
+ * rest as a view for {@link toStatus}. Never throws.
859
+ */
860
+ async readFleet(jobs) {
861
+ const views = /* @__PURE__ */ new Map();
862
+ const viewOf = (id) => {
863
+ let view = views.get(id);
864
+ if (!view) {
865
+ view = {};
866
+ views.set(id, view);
867
+ }
868
+ return view;
869
+ };
870
+ const store = this.store;
871
+ const ids = jobs.map((job) => job.id);
872
+ if (store?.fetchJobStates && this.sharesEnabledState() && ids.length > 0) try {
873
+ const states = await store.fetchJobStates(ids);
874
+ for (const job of jobs) {
875
+ const state = states.get(job.id);
876
+ this.applyEnabled(job, state?.enabled ?? this.codeEnabled(job));
877
+ if (state?.runningBy && !job.executing) viewOf(job.id).runningElsewhere = true;
878
+ }
879
+ } catch (err) {
880
+ logger.warn("[cron] Could not read the jobs' shared state — listing what this process knows", { error: err });
881
+ }
882
+ if (!this.started) {
883
+ const now = /* @__PURE__ */ new Date();
884
+ for (const job of jobs) try {
885
+ viewOf(job.id).nextRunAt = parseCronExpression(job.definition.schedule, now, job.definition.timezone);
886
+ } catch {}
887
+ if (store?.fetchRunSummaries && ids.length > 0) try {
888
+ const summaries = await store.fetchRunSummaries(ids);
889
+ for (const [id, summary] of summaries) viewOf(id).summary = summary;
890
+ } catch (err) {
891
+ logger.warn("[cron] Could not read the jobs' run history — listing what this process knows", { error: err });
892
+ }
893
+ }
894
+ return views;
895
+ }
612
896
  /**
613
897
  * Warn once at start when the process looks like it is running on a
614
898
  * platform that freezes or evicts instances between requests, where the
@@ -638,7 +922,7 @@ var CronScheduler = class {
638
922
  */
639
923
  scheduleNext(id) {
640
924
  const job = this.jobs.get(id);
641
- if (!job || !job.enabled || !this.started) return;
925
+ if (!job || !this.started || !this.shouldArm(job)) return;
642
926
  this.stopJob(id);
643
927
  try {
644
928
  const now = /* @__PURE__ */ new Date();
@@ -648,21 +932,26 @@ var CronScheduler = class {
648
932
  const delay = Math.max(rawDelay, MIN_SCHEDULE_INTERVAL_MS);
649
933
  if (delay > MAX_TIMER_DELAY_MS) {
650
934
  const hop = setTimeout(() => {
651
- if (this.started && job.enabled) this.scheduleNext(id);
935
+ if (this.started && this.shouldArm(job)) this.scheduleNext(id);
652
936
  }, MAX_TIMER_DELAY_MS);
653
937
  if (hop && typeof hop === "object" && "unref" in hop) hop.unref();
654
938
  job.timerId = hop;
655
939
  return;
656
940
  }
657
941
  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);
942
+ if (!this.started) return;
943
+ if (Date.now() < nextRun.getTime()) {
662
944
  this.scheduleNext(id);
663
945
  return;
664
946
  }
665
- if (Date.now() < nextRun.getTime()) {
947
+ if (!await this.isEnabledNow(job)) {
948
+ if (this.started && this.shouldArm(job)) this.scheduleNext(id);
949
+ return;
950
+ }
951
+ if (!this.started) return;
952
+ if (job.executing) {
953
+ logger.warn(`[cron] Skipping scheduled run of "${id}" — still executing from previous run`);
954
+ this.recordSkip(job, "already_executing", false);
666
955
  this.scheduleNext(id);
667
956
  return;
668
957
  }
@@ -675,12 +964,12 @@ var CronScheduler = class {
675
964
  }
676
965
  if (!claimed) {
677
966
  logger.info(`[cron] Slot ${nextRun.toISOString()} for "${id}" claimed by another instance — skipping`);
678
- if (this.started && job.enabled) this.scheduleNext(id);
967
+ if (this.started && this.shouldArm(job)) this.scheduleNext(id);
679
968
  return;
680
969
  }
681
970
  }
682
971
  await this.executeJob(job, false);
683
- if (this.started && job.enabled) this.scheduleNext(id);
972
+ if (this.started && this.shouldArm(job)) this.scheduleNext(id);
684
973
  }, delay);
685
974
  if (timer && typeof timer === "object" && "unref" in timer) timer.unref();
686
975
  job.timerId = timer;
@@ -707,15 +996,17 @@ var CronScheduler = class {
707
996
  * otherwise ticking correctly.
708
997
  */
709
998
  async catchUpMissedSlots() {
710
- const candidates = [...this.jobs.values()].filter((job) => job.enabled && (job.definition.catchUpWindowSeconds ?? 0) > 0);
999
+ const candidates = [...this.jobs.values()].filter((job) => this.shouldArm(job) && (job.definition.catchUpWindowSeconds ?? 0) > 0);
711
1000
  if (candidates.length === 0) return;
712
1001
  if (!this.store?.tryClaimRun) {
713
1002
  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
1003
  return;
715
1004
  }
716
- const now = /* @__PURE__ */ new Date();
717
1005
  for (const job of candidates) try {
718
- if (!this.started || !job.enabled || job.executing) continue;
1006
+ if (!this.started || job.executing) continue;
1007
+ if (!await this.isEnabledNow(job)) continue;
1008
+ if (!this.started || job.executing) continue;
1009
+ const now = /* @__PURE__ */ new Date();
719
1010
  const windowSeconds = job.definition.catchUpWindowSeconds;
720
1011
  const from = /* @__PURE__ */ new Date(now.getTime() - windowSeconds * 1e3);
721
1012
  const slot = findMostRecentSlot(job.definition.schedule, from, now, job.definition.timezone);
@@ -756,11 +1047,34 @@ var CronScheduler = class {
756
1047
  * - Persists to store (non-blocking) if available
757
1048
  * - Always restores state even on catastrophic errors
758
1049
  */
759
- async executeJob(job, manual, seedLog) {
1050
+ executeJob(job, manual, seedLog) {
1051
+ const abort = new AbortController();
1052
+ this.runControllers.add(abort);
1053
+ const run = this.runJob(job, manual, abort, seedLog);
1054
+ this.track(run);
1055
+ return run.finally(() => {
1056
+ this.runControllers.delete(abort);
1057
+ });
1058
+ }
1059
+ /** Hold `stop()` until `work` settles. */
1060
+ track(work) {
1061
+ this.inFlight.add(work);
1062
+ const release = () => {
1063
+ this.inFlight.delete(work);
1064
+ };
1065
+ work.then(release, release);
1066
+ }
1067
+ async runJob(job, manual, abort, seedLog) {
1068
+ if (job.executing) return this.recordSkip(job, "already_executing", manual);
1069
+ job.executing = true;
1070
+ const lease = await this.acquireLease(job);
1071
+ if (!lease.acquired) {
1072
+ job.executing = false;
1073
+ logger.warn(`[cron] Skipping ${manual ? "manual trigger" : "scheduled run"} of "${job.id}" — it is running on ${lease.holder ?? "another process"}`);
1074
+ return this.recordSkip(job, "already_executing", manual, lease.holder ?? "another process");
1075
+ }
760
1076
  const startedAt = /* @__PURE__ */ new Date();
761
1077
  const capturedLogs = seedLog ? [seedLog] : [];
762
- job.executing = true;
763
- const abort = new AbortController();
764
1078
  const ctx = {
765
1079
  jobId: job.id,
766
1080
  scheduledAt: startedAt,
@@ -777,26 +1091,26 @@ var CronScheduler = class {
777
1091
  let success = true;
778
1092
  let error;
779
1093
  let result;
1094
+ const aborted = new Promise((_, reject) => {
1095
+ const end = () => reject(abort.signal.reason);
1096
+ if (abort.signal.aborted) end();
1097
+ else abort.signal.addEventListener("abort", end, { once: true });
1098
+ });
1099
+ aborted.catch(() => void 0);
780
1100
  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
- });
1101
+ const timeout = (job.definition.timeoutSeconds ?? DEFAULT_TIMEOUT_SECONDS) * 1e3;
1102
+ const cancelTimeout = Number.isFinite(timeout) ? setLongTimeout(() => abort.abort(/* @__PURE__ */ new Error(`Cron job "${job.id}" timed out after ${timeout}ms`)), timeout) : void 0;
790
1103
  try {
791
- result = await Promise.race([handlerPromise, timeoutPromise]);
1104
+ result = await Promise.race([Promise.resolve(job.definition.handler(ctx)), aborted]);
792
1105
  } finally {
793
- clearTimeout(timeoutHandle);
1106
+ cancelTimeout?.();
794
1107
  }
795
1108
  } catch (err) {
796
1109
  success = false;
797
1110
  error = redactSensitiveText(err instanceof Error ? err.message : String(err));
798
1111
  job.totalFailures++;
799
1112
  } finally {
1113
+ if (lease.token) await this.releaseLease(job, lease.token);
800
1114
  job.executing = false;
801
1115
  }
802
1116
  const finishedAt = /* @__PURE__ */ new Date();
@@ -817,31 +1131,43 @@ var CronScheduler = class {
817
1131
  };
818
1132
  job.logs.push(logEntry);
819
1133
  if (job.logs.length > MAX_LOGS_PER_JOB) job.logs.shift();
820
- if (this.store) this.store.insertLog(logEntry).catch((persistErr) => {
1134
+ if (this.store) this.track(this.store.insertLog(logEntry).catch((persistErr) => {
821
1135
  logger.error(`[cron] Failed to persist log for "${job.id}"`, { error: persistErr });
822
- });
1136
+ }));
823
1137
  if (success) logger.info(`✅ [cron] "${job.id}" completed in ${durationMs}ms`);
824
1138
  else logger.error(`❌ [cron] "${job.id}" failed in ${durationMs}ms: ${error}`);
825
1139
  return logEntry;
826
1140
  }
827
- toStatus(job) {
1141
+ /**
1142
+ * A job's status: this process's own record, overlaid with what `view`
1143
+ * read from the store. A paused job shows no next run, even where its
1144
+ * timer stays armed to read the state again at that slot.
1145
+ */
1146
+ toStatus(job, view = {}) {
1147
+ const summary = view.summary;
1148
+ const nextRunAt = job.enabled ? job.nextRunAt ?? view.nextRunAt : void 0;
1149
+ let state = job.state;
1150
+ if (!job.executing) {
1151
+ if (view.runningElsewhere) state = "running";
1152
+ else if (summary && job.enabled) state = summary.lastSuccess === false ? "error" : "idle";
1153
+ }
828
1154
  return {
829
1155
  id: job.id,
830
1156
  name: job.definition.name ?? job.id,
831
1157
  description: job.definition.description,
832
1158
  schedule: job.definition.schedule,
833
1159
  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
1160
+ state,
1161
+ lastRunAt: summary ? summary.lastRunAt : job.lastRunAt?.toISOString(),
1162
+ nextRunAt: nextRunAt?.toISOString(),
1163
+ lastDurationMs: summary ? summary.lastDurationMs : job.lastDurationMs,
1164
+ lastError: summary ? summary.lastSuccess === false ? summary.lastError : void 0 : job.lastError,
1165
+ totalRuns: summary ? summary.totalRuns : job.totalRuns,
1166
+ totalFailures: summary ? summary.totalFailures : job.totalFailures
841
1167
  };
842
1168
  }
843
1169
  };
844
1170
  //#endregion
845
- export { parseEnvBoolean as i, cron_scheduler_exports as n, validateCronExpression as r, CronScheduler as t };
1171
+ export { parseEnvBoolean as a, validateCronExpression as i, cron_scheduler_exports as n, isAlreadyExecutingSkip as r, CronScheduler as t };
846
1172
 
847
- //# sourceMappingURL=cron-scheduler-COPQxlEq.js.map
1173
+ //# sourceMappingURL=cron-scheduler-B0pLfAix.js.map