@rebasepro/server 0.12.0 → 0.12.1-canary.gdfba2a1

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 (55) hide show
  1. package/dist/GCSStorageController-BEmDYFKc.js +216 -0
  2. package/dist/GCSStorageController-BEmDYFKc.js.map +1 -0
  3. package/dist/S3StorageController-CK7r5yZb.js +243 -0
  4. package/dist/S3StorageController-CK7r5yZb.js.map +1 -0
  5. package/dist/admin_block-BGQFSAuV.js +123 -0
  6. package/dist/admin_block-BGQFSAuV.js.map +1 -0
  7. package/dist/auth-Bb7XFCKN.js +8898 -0
  8. package/dist/auth-Bb7XFCKN.js.map +1 -0
  9. package/dist/backend-CIxN4FVm.js +15 -0
  10. package/dist/backend-CIxN4FVm.js.map +1 -0
  11. package/dist/backup-C3Bah7XZ.js +163 -0
  12. package/dist/backup-C3Bah7XZ.js.map +1 -0
  13. package/dist/boot/boot.d.ts +20 -0
  14. package/dist/boot/fetch-bundle.d.ts +43 -0
  15. package/dist/contract-routes-Dj8i5AiM.js +264 -0
  16. package/dist/contract-routes-Dj8i5AiM.js.map +1 -0
  17. package/dist/cron/cron-scheduler.d.ts +34 -0
  18. package/dist/cron-loader-B1S2MCSl.js +63 -0
  19. package/dist/cron-loader-B1S2MCSl.js.map +1 -0
  20. package/dist/cron-routes-Do325hDt.js +62 -0
  21. package/dist/cron-routes-Do325hDt.js.map +1 -0
  22. package/dist/cron-scheduler-B3RFt0HS.js +647 -0
  23. package/dist/cron-scheduler-B3RFt0HS.js.map +1 -0
  24. package/dist/cron-store-BywZsyfZ.js +164 -0
  25. package/dist/cron-store-BywZsyfZ.js.map +1 -0
  26. package/dist/dynamic-import-Dvh-K5fl.js.map +1 -1
  27. package/dist/errors-BYAQztMf.js +222 -0
  28. package/dist/errors-BYAQztMf.js.map +1 -0
  29. package/dist/function-loader-B_1fYfUY.js +86 -0
  30. package/dist/function-loader-B_1fYfUY.js.map +1 -0
  31. package/dist/function-routes-C0cLIy3N.js +28 -0
  32. package/dist/function-routes-C0cLIy3N.js.map +1 -0
  33. package/dist/index.es.js +7480 -17861
  34. package/dist/index.es.js.map +1 -1
  35. package/dist/{jwt-D-eI6TTu.js → jwt-DD6EtpGj.js} +32 -13
  36. package/dist/{jwt-D-eI6TTu.js.map → jwt-DD6EtpGj.js.map} +1 -1
  37. package/dist/logger-BYU66ENZ.js.map +1 -1
  38. package/dist/logs-routes-BYA72C_C.js +100 -0
  39. package/dist/logs-routes-BYA72C_C.js.map +1 -0
  40. package/dist/{openapi-generator-Bjzmb5cn.js → openapi-generator-DFDS7kVk.js} +9 -3
  41. package/dist/openapi-generator-DFDS7kVk.js.map +1 -0
  42. package/dist/{schema-editor-routes-DDxfOIid.js → schema-editor-routes-CZVW2iBr.js} +4 -3
  43. package/dist/{schema-editor-routes-DDxfOIid.js.map → schema-editor-routes-CZVW2iBr.js.map} +1 -1
  44. package/dist/{src-CoOAMnBh.js → src-DymRyxdb.js} +196 -65
  45. package/dist/src-DymRyxdb.js.map +1 -0
  46. package/dist/src-_qQ3RNCK.js +279 -0
  47. package/dist/src-_qQ3RNCK.js.map +1 -0
  48. package/dist/types-DSnOC4mF.js +38 -0
  49. package/dist/types-DSnOC4mF.js.map +1 -0
  50. package/package.json +10 -10
  51. package/dist/openapi-generator-Bjzmb5cn.js.map +0 -1
  52. package/dist/src-CoOAMnBh.js.map +0 -1
  53. package/dist/src-Ivjud8jD.js +0 -957
  54. package/dist/src-Ivjud8jD.js.map +0 -1
  55. /package/dist/{chunk-DSJWtz9O.js → rolldown-runtime-DSJWtz9O.js} +0 -0
@@ -0,0 +1,647 @@
1
+ import { createRequire as __createRequire } from "module";
2
+ import process from "process";
3
+ __createRequire(import.meta.url);
4
+ import { n as __exportAll } from "./rolldown-runtime-DSJWtz9O.js";
5
+ import { t as logger } from "./logger-BYU66ENZ.js";
6
+ //#region src/cron/scale-to-zero.ts
7
+ /**
8
+ * Scale-to-zero detection for the cron scheduler.
9
+ *
10
+ * The scheduler drives jobs with in-process `setTimeout`. That works on any
11
+ * always-running instance, but on a platform that freezes or evicts the
12
+ * container between requests (Cloud Run with `--min-instances=0`, AWS Lambda,
13
+ * Vercel functions) the timers simply never fire — the process boots, logs the
14
+ * jobs as registered, and silently runs nothing.
15
+ *
16
+ * None of these platforms expose their scaling floor to the container, so this
17
+ * detection is a heuristic: it identifies the *platform*, not the setting. It
18
+ * is a warning only — it must never influence boot.
19
+ *
20
+ * Environment variables used here were verified against vendor documentation:
21
+ * - `K_SERVICE` / `K_REVISION` / `K_CONFIGURATION` — Cloud Run services
22
+ * (Cloud Run container contract; no variable exposes min-instances).
23
+ * - `CLOUD_RUN_JOB` — Cloud Run jobs (same contract).
24
+ * - `AWS_LAMBDA_FUNCTION_NAME` — reserved AWS Lambda runtime variable.
25
+ * - `VERCEL=1` — Vercel system environment variable, available at runtime.
26
+ * - `KUBERNETES_SERVICE_HOST` — injected into every pod by the kubelet. Used
27
+ * as an *exclusion*: a Deployment pod runs continuously, and Knative on
28
+ * Kubernetes also sets `K_SERVICE`, so a pod is never warned about.
29
+ */
30
+ /** Environment variable that permanently silences the scale-to-zero warning. */
31
+ var CRON_ALWAYS_ON_ENV = "REBASE_CRON_ALWAYS_ON";
32
+ /** Accepts the usual truthy spellings; anything else (including "") is false. */
33
+ function isTruthy(value) {
34
+ if (!value) return false;
35
+ const normalised = value.trim().toLowerCase();
36
+ return normalised === "1" || normalised === "true" || normalised === "yes" || normalised === "on";
37
+ }
38
+ /**
39
+ * Identify a runtime whose instances can be frozen or torn down between
40
+ * requests. Returns `undefined` when the platform is unknown or known to run
41
+ * continuously.
42
+ */
43
+ function detectFreezableRuntime(env = process.env) {
44
+ if (env.KUBERNETES_SERVICE_HOST) return void 0;
45
+ if (env.K_SERVICE) {
46
+ const signals = ["K_SERVICE"];
47
+ if (env.K_REVISION) signals.push("K_REVISION");
48
+ if (env.K_CONFIGURATION) signals.push("K_CONFIGURATION");
49
+ return {
50
+ platform: "Cloud Run",
51
+ signals
52
+ };
53
+ }
54
+ if (env.CLOUD_RUN_JOB) return {
55
+ platform: "Cloud Run Jobs",
56
+ signals: ["CLOUD_RUN_JOB"]
57
+ };
58
+ if (env.AWS_LAMBDA_FUNCTION_NAME) return {
59
+ platform: "AWS Lambda",
60
+ signals: ["AWS_LAMBDA_FUNCTION_NAME"]
61
+ };
62
+ if (env.VERCEL === "1") return {
63
+ platform: "Vercel",
64
+ signals: ["VERCEL"]
65
+ };
66
+ }
67
+ /** How many job ids to name before collapsing the rest into "+N more". */
68
+ var MAX_NAMED_JOBS = 10;
69
+ /**
70
+ * Build the boot-time warning, or `undefined` when it does not apply.
71
+ *
72
+ * Fires only when all of the following hold:
73
+ * 1. `NODE_ENV=production` — a laptop or CI run is not at risk.
74
+ * 2. At least one *enabled* job is registered — nothing to lose otherwise.
75
+ * 3. The environment looks like a freezable platform (see above).
76
+ * 4. `REBASE_CRON_ALWAYS_ON` is not set to a truthy value.
77
+ */
78
+ function buildScaleToZeroWarning(jobs, env = process.env) {
79
+ if (env.NODE_ENV !== "production") return void 0;
80
+ if (isTruthy(env["REBASE_CRON_ALWAYS_ON"])) return void 0;
81
+ const enabled = jobs.filter((job) => job.enabled).map((job) => job.id);
82
+ if (enabled.length === 0) return void 0;
83
+ const runtime = detectFreezableRuntime(env);
84
+ if (!runtime) return void 0;
85
+ const named = enabled.slice(0, MAX_NAMED_JOBS);
86
+ const list = enabled.length > named.length ? `${named.join(", ")} (+${enabled.length - named.length} more)` : named.join(", ");
87
+ return {
88
+ message: `[cron] ${runtime.platform} detected — in-process timers do not fire while an instance is frozen or scaled to zero, so ${enabled.length} enabled job(s) may never run: ${list}; drive them from an external scheduler instead (POST /api/cron/:id/trigger, e.g. Cloud Scheduler). ${runtime.platform} does not expose its scaling floor to the container, so an always-warm deployment cannot be confirmed from inside the process — set ${CRON_ALWAYS_ON_ENV}=1 to silence this if at least one instance is pinned warm.`,
89
+ data: {
90
+ platform: runtime.platform,
91
+ signals: runtime.signals,
92
+ jobs: enabled
93
+ }
94
+ };
95
+ }
96
+ //#endregion
97
+ //#region src/cron/cron-scheduler.ts
98
+ var cron_scheduler_exports = /* @__PURE__ */ __exportAll({
99
+ CronScheduler: () => CronScheduler,
100
+ findMostRecentSlot: () => findMostRecentSlot,
101
+ validateCronExpression: () => validateCronExpression
102
+ });
103
+ /**
104
+ * Expand a single cron field into an ordered array of allowed values.
105
+ * Supports: `*`, `N`, `N-M`, `N/S`, `N-M/S`, `*\/S`, and comma-separated combinations.
106
+ */
107
+ function expandCronField(field, min, max) {
108
+ const results = /* @__PURE__ */ new Set();
109
+ for (const segment of field.split(",")) {
110
+ const trimmed = segment.trim();
111
+ if (trimmed === "*") for (let i = min; i <= max; i++) results.add(i);
112
+ else if (trimmed.includes("/")) {
113
+ const [rangeStr, stepStr] = trimmed.split("/");
114
+ const step = parseInt(stepStr, 10);
115
+ if (isNaN(step) || step <= 0) throw new Error(`Invalid step value "${stepStr}" in cron field "${field}"`);
116
+ let start = min;
117
+ let end = max;
118
+ if (rangeStr !== "*") if (rangeStr.includes("-")) {
119
+ const [a, b] = rangeStr.split("-").map(Number);
120
+ start = a;
121
+ end = b;
122
+ } else start = parseInt(rangeStr, 10);
123
+ for (let i = start; i <= end; i += step) results.add(i);
124
+ } else if (trimmed.includes("-")) {
125
+ const [a, b] = trimmed.split("-").map(Number);
126
+ for (let i = a; i <= b; i++) results.add(i);
127
+ } else {
128
+ const val = parseInt(trimmed, 10);
129
+ if (isNaN(val)) throw new Error(`Invalid value "${trimmed}" in cron field "${field}"`);
130
+ results.add(val);
131
+ }
132
+ }
133
+ return [...results].sort((a, b) => a - b);
134
+ }
135
+ /**
136
+ * Validates a standard 5-field cron expression structurally and semantically.
137
+ * Returns `{ valid: true }` or `{ valid: false, reason: string }`.
138
+ */
139
+ function validateCronExpression(schedule) {
140
+ if (!schedule || typeof schedule !== "string") return {
141
+ valid: false,
142
+ reason: "Schedule must be a non-empty string"
143
+ };
144
+ const parts = schedule.trim().split(/\s+/);
145
+ if (parts.length !== 5) return {
146
+ valid: false,
147
+ reason: `Expected 5 fields, got ${parts.length}`
148
+ };
149
+ const fieldRanges = [
150
+ [
151
+ "minute",
152
+ 0,
153
+ 59
154
+ ],
155
+ [
156
+ "hour",
157
+ 0,
158
+ 23
159
+ ],
160
+ [
161
+ "day of month",
162
+ 1,
163
+ 31
164
+ ],
165
+ [
166
+ "month",
167
+ 1,
168
+ 12
169
+ ],
170
+ [
171
+ "day of week",
172
+ 0,
173
+ 6
174
+ ]
175
+ ];
176
+ for (let i = 0; i < 5; i++) {
177
+ const [name, min, max] = fieldRanges[i];
178
+ try {
179
+ const values = expandCronField(parts[i], min, max);
180
+ if (values.length === 0) return {
181
+ valid: false,
182
+ reason: `${name} field "${parts[i]}" produces no values`
183
+ };
184
+ for (const v of values) if (v < min || v > max) return {
185
+ valid: false,
186
+ reason: `${name} field value ${v} out of range [${min}–${max}]`
187
+ };
188
+ } catch (err) {
189
+ return {
190
+ valid: false,
191
+ reason: `${name} field: ${err instanceof Error ? err.message : String(err)}`
192
+ };
193
+ }
194
+ }
195
+ return { valid: true };
196
+ }
197
+ /** Expand all five fields of an expression. Throws on invalid expressions. */
198
+ function parseCronFields(expression) {
199
+ const parts = expression.trim().split(/\s+/);
200
+ if (parts.length < 5) throw new Error(`Invalid cron expression: "${expression}". Expected 5 fields.`);
201
+ const [minField, hourField, domField, monField, dowField] = parts;
202
+ return {
203
+ minutes: expandCronField(minField, 0, 59),
204
+ hours: expandCronField(hourField, 0, 23),
205
+ doms: expandCronField(domField, 1, 31),
206
+ months: expandCronField(monField, 1, 12),
207
+ dows: expandCronField(dowField, 0, 6)
208
+ };
209
+ }
210
+ /** Whether a minute-precision instant matches every field of the expression. */
211
+ function matchesCronFields(candidate, fields) {
212
+ return fields.months.includes(candidate.getMonth() + 1) && fields.doms.includes(candidate.getDate()) && fields.dows.includes(candidate.getDay()) && fields.hours.includes(candidate.getHours()) && fields.minutes.includes(candidate.getMinutes());
213
+ }
214
+ /** ~1 year in minutes — the walk bound for both search directions. */
215
+ var MAX_SLOT_SEARCH_MINUTES = 525960;
216
+ /**
217
+ * Calculate the next Date after `after` that matches the cron expression.
218
+ * Throws on invalid expressions.
219
+ */
220
+ function parseCronExpression(expression, after) {
221
+ const fields = parseCronFields(expression);
222
+ const candidate = new Date(after);
223
+ candidate.setSeconds(0, 0);
224
+ candidate.setMinutes(candidate.getMinutes() + 1);
225
+ for (let i = 0; i < MAX_SLOT_SEARCH_MINUTES; i++) {
226
+ if (matchesCronFields(candidate, fields)) return candidate;
227
+ candidate.setMinutes(candidate.getMinutes() + 1);
228
+ }
229
+ const fallback = new Date(after);
230
+ fallback.setMinutes(fallback.getMinutes() + 1);
231
+ return fallback;
232
+ }
233
+ /**
234
+ * The latest slot matching `expression` within the inclusive window
235
+ * `[from, to]`, or `undefined` when the expression has no slot in it.
236
+ *
237
+ * Walks backwards a minute at a time from `to`, so the first hit is already
238
+ * the answer — in the common case (a job that ran normally moments ago) that
239
+ * is a handful of iterations, not a scan of the whole window.
240
+ *
241
+ * `to`'s own minute is included: an instance booting at 06:00:30 has *not* run
242
+ * the 06:00 slot — `parseCronExpression` already skipped past it to tomorrow —
243
+ * so that slot is genuinely missed and must be a candidate.
244
+ *
245
+ * Seconds and milliseconds are zeroed to match how `parseCronExpression`
246
+ * builds a slot, so the same wall-clock slot serialises to a byte-identical
247
+ * ISO string down either path. The claim key depends on that.
248
+ */
249
+ function findMostRecentSlot(expression, from, to) {
250
+ const fields = parseCronFields(expression);
251
+ const candidate = new Date(to);
252
+ candidate.setSeconds(0, 0);
253
+ for (let i = 0; i < MAX_SLOT_SEARCH_MINUTES && candidate.getTime() >= from.getTime(); i++) {
254
+ if (matchesCronFields(candidate, fields)) return candidate;
255
+ candidate.setMinutes(candidate.getMinutes() - 1);
256
+ }
257
+ }
258
+ var MAX_LOGS_PER_JOB = 50;
259
+ /**
260
+ * Minimum milliseconds between scheduled executions of the same job.
261
+ * Prevents tight re-execution loops caused by jitter or clock drift.
262
+ */
263
+ var MIN_SCHEDULE_INTERVAL_MS = 5e3;
264
+ var CronScheduler = class {
265
+ jobs = /* @__PURE__ */ new Map();
266
+ started = false;
267
+ store;
268
+ client;
269
+ /**
270
+ * Set the RebaseClient instance to make it available to cron job handlers.
271
+ */
272
+ setClient(client) {
273
+ this.client = client;
274
+ }
275
+ /**
276
+ * Attach a persistence store for cron logs.
277
+ * When set, execution logs are written to the database after each run,
278
+ * and counters are seeded from the database on start.
279
+ */
280
+ setStore(store) {
281
+ this.store = store;
282
+ }
283
+ /**
284
+ * Register a batch of loaded cron jobs.
285
+ *
286
+ * If the scheduler is already started, newly registered jobs are
287
+ * automatically scheduled (so late-registered jobs don't sit idle).
288
+ *
289
+ * Validates the cron schedule on registration — invalid schedules
290
+ * are rejected with a warning and the job is NOT registered.
291
+ */
292
+ registerJobs(loadedJobs) {
293
+ for (const loaded of loadedJobs) {
294
+ const validation = validateCronExpression(loaded.definition.schedule);
295
+ if (!validation.valid) {
296
+ logger.error(`[cron] Rejecting job "${loaded.id}": invalid schedule "${loaded.definition.schedule}" — ${validation.reason}`);
297
+ continue;
298
+ }
299
+ if (this.jobs.get(loaded.id)) {
300
+ logger.warn(`[cron] Duplicate cron job id: "${loaded.id}". Overwriting.`);
301
+ this.stopJob(loaded.id);
302
+ }
303
+ const enabled = loaded.definition.enabled !== false;
304
+ this.jobs.set(loaded.id, {
305
+ id: loaded.id,
306
+ definition: loaded.definition,
307
+ enabled,
308
+ state: enabled ? "idle" : "disabled",
309
+ totalRuns: 0,
310
+ totalFailures: 0,
311
+ logs: [],
312
+ executing: false
313
+ });
314
+ if (this.started && enabled) this.scheduleNext(loaded.id);
315
+ }
316
+ }
317
+ /**
318
+ * Start the scheduler — begins ticking all enabled jobs.
319
+ */
320
+ start() {
321
+ if (this.started) return;
322
+ this.started = true;
323
+ if (this.store) this.store.fetchJobStats().then((stats) => {
324
+ for (const [jobId, data] of stats) {
325
+ const job = this.jobs.get(jobId);
326
+ if (job) {
327
+ job.totalRuns = data.totalRuns;
328
+ job.totalFailures = data.totalFailures;
329
+ if (data.lastRunAt) job.lastRunAt = new Date(data.lastRunAt);
330
+ }
331
+ }
332
+ }).catch((err) => {
333
+ logger.warn("[cron] Failed to seed job stats from database", { error: err });
334
+ });
335
+ for (const [id, job] of this.jobs) if (job.enabled) this.scheduleNext(id);
336
+ if (!this.store) logger.warn("[cron] No cron store attached — runs are uncoordinated; with multiple app instances every instance will execute every job");
337
+ this.warnIfScaleToZero();
338
+ this.catchUpMissedSlots();
339
+ logger.info(`⏰ Cron scheduler started with ${this.jobs.size} job(s)`);
340
+ }
341
+ /**
342
+ * Stop the scheduler and clear all timers.
343
+ *
344
+ * Currently-executing handlers run to completion (they are async),
345
+ * but no further scheduling occurs after stop.
346
+ */
347
+ stop() {
348
+ this.started = false;
349
+ for (const [id] of this.jobs) this.stopJob(id);
350
+ }
351
+ /**
352
+ * List all registered jobs with their current status.
353
+ */
354
+ listJobs() {
355
+ return [...this.jobs.values()].map((job) => this.toStatus(job));
356
+ }
357
+ /**
358
+ * Get a single job status by ID.
359
+ */
360
+ getJob(id) {
361
+ const job = this.jobs.get(id);
362
+ return job ? this.toStatus(job) : void 0;
363
+ }
364
+ /**
365
+ * Get log entries for a job.
366
+ */
367
+ getJobLogs(id, limit) {
368
+ const job = this.jobs.get(id);
369
+ if (!job) return [];
370
+ const logs = [...job.logs].reverse();
371
+ return limit ? logs.slice(0, limit) : logs;
372
+ }
373
+ /**
374
+ * Get log entries for a job from the database (if store is available).
375
+ * Falls back to in-memory logs if no store is configured.
376
+ */
377
+ async getJobLogsFromDb(id, limit) {
378
+ if (this.store) {
379
+ const dbLogs = await this.store.fetchLogs(id, limit);
380
+ if (dbLogs.length > 0) return dbLogs;
381
+ }
382
+ return this.getJobLogs(id, limit);
383
+ }
384
+ /**
385
+ * Enable or disable a job at runtime.
386
+ */
387
+ setJobEnabled(id, enabled) {
388
+ const job = this.jobs.get(id);
389
+ if (!job) return void 0;
390
+ job.enabled = enabled;
391
+ if (enabled && this.started) {
392
+ job.state = "idle";
393
+ this.scheduleNext(id);
394
+ } else if (!enabled) {
395
+ this.stopJob(id);
396
+ job.state = "disabled";
397
+ }
398
+ return this.toStatus(job);
399
+ }
400
+ /**
401
+ * Manually trigger a job execution immediately.
402
+ *
403
+ * Returns `undefined` if the job doesn't exist.
404
+ * If the job is currently executing, returns the log entry with
405
+ * a `skipped: true` result rather than running concurrently.
406
+ */
407
+ async triggerJob(id) {
408
+ const job = this.jobs.get(id);
409
+ if (!job) return void 0;
410
+ if (job.executing) {
411
+ logger.warn(`[cron] Skipping manual trigger of "${id}" — already executing`);
412
+ const logEntry = {
413
+ jobId: id,
414
+ startedAt: (/* @__PURE__ */ new Date()).toISOString(),
415
+ finishedAt: (/* @__PURE__ */ new Date()).toISOString(),
416
+ durationMs: 0,
417
+ success: true,
418
+ result: {
419
+ skipped: true,
420
+ reason: "already_executing"
421
+ },
422
+ logs: ["Skipped: job is already running"],
423
+ manual: true
424
+ };
425
+ job.logs.push(logEntry);
426
+ if (job.logs.length > MAX_LOGS_PER_JOB) job.logs.shift();
427
+ return logEntry;
428
+ }
429
+ return this.executeJob(job, true);
430
+ }
431
+ /**
432
+ * Warn once at start when the process looks like it is running on a
433
+ * platform that freezes or evicts instances between requests, where the
434
+ * in-process timers this scheduler relies on never fire.
435
+ *
436
+ * Advisory only: any failure here is swallowed so a detection bug can
437
+ * never take a production boot down.
438
+ */
439
+ warnIfScaleToZero() {
440
+ try {
441
+ const warning = buildScaleToZeroWarning([...this.jobs.values()].map((job) => ({
442
+ id: job.id,
443
+ enabled: job.enabled
444
+ })));
445
+ if (warning) logger.warn(warning.message, warning.data);
446
+ } catch {}
447
+ }
448
+ /**
449
+ * Schedule the next execution for a job.
450
+ *
451
+ * Safety guarantees:
452
+ * 1. Clears any existing timer first (prevents leaked/duplicate timers)
453
+ * 2. Enforces a minimum delay to prevent tight loops from jitter
454
+ * 3. Unref's the timer so it doesn't prevent process exit
455
+ * 4. Re-checks enabled & started state before executing
456
+ * 5. Concurrency guard prevents overlapping handler executions
457
+ */
458
+ scheduleNext(id) {
459
+ const job = this.jobs.get(id);
460
+ if (!job || !job.enabled || !this.started) return;
461
+ this.stopJob(id);
462
+ try {
463
+ const now = /* @__PURE__ */ new Date();
464
+ const nextRun = parseCronExpression(job.definition.schedule, now);
465
+ job.nextRunAt = nextRun;
466
+ const rawDelay = nextRun.getTime() - now.getTime();
467
+ const timer = setTimeout(async () => {
468
+ if (!job.enabled || !this.started) return;
469
+ if (job.executing) {
470
+ logger.warn(`[cron] Skipping scheduled run of "${id}" — still executing from previous run`);
471
+ this.scheduleNext(id);
472
+ return;
473
+ }
474
+ if (this.store?.tryClaimRun) {
475
+ let claimed = true;
476
+ try {
477
+ claimed = await this.store.tryClaimRun(id, nextRun.toISOString());
478
+ } catch (err) {
479
+ logger.warn(`[cron] Claim check threw for "${id}" — running uncoordinated`, { error: err });
480
+ }
481
+ if (!claimed) {
482
+ logger.info(`[cron] Slot ${nextRun.toISOString()} for "${id}" claimed by another instance — skipping`);
483
+ if (this.started && job.enabled) this.scheduleNext(id);
484
+ return;
485
+ }
486
+ }
487
+ await this.executeJob(job, false);
488
+ if (this.started && job.enabled) this.scheduleNext(id);
489
+ }, Math.max(rawDelay, MIN_SCHEDULE_INTERVAL_MS));
490
+ if (timer && typeof timer === "object" && "unref" in timer) timer.unref();
491
+ job.timerId = timer;
492
+ } catch (err) {
493
+ logger.error(`[cron] Failed to schedule "${id}"`, { error: err });
494
+ job.state = "error";
495
+ job.lastError = err instanceof Error ? err.message : String(err);
496
+ }
497
+ }
498
+ /**
499
+ * Run any slot that elapsed while no instance was holding a timer for it.
500
+ *
501
+ * Only jobs that opted in via `catchUpWindowSeconds` are considered, and
502
+ * only their single most recent missed slot — see the field's docs for why
503
+ * both limits are deliberate.
504
+ *
505
+ * The claim is what makes this safe. In the ordinary case — an instance
506
+ * restarting minutes after a slot ran normally — the most recent slot is
507
+ * already claimed, so this costs one `tryClaimRun` per job per boot and
508
+ * does nothing. A slot is only executed when no instance, past or present,
509
+ * ever claimed it.
510
+ *
511
+ * Never throws: a failure here must not take down a scheduler that is
512
+ * otherwise ticking correctly.
513
+ */
514
+ async catchUpMissedSlots() {
515
+ const candidates = [...this.jobs.values()].filter((job) => job.enabled && (job.definition.catchUpWindowSeconds ?? 0) > 0);
516
+ if (candidates.length === 0) return;
517
+ if (!this.store?.tryClaimRun) {
518
+ 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.`);
519
+ return;
520
+ }
521
+ const now = /* @__PURE__ */ new Date();
522
+ for (const job of candidates) try {
523
+ if (!this.started || !job.enabled || job.executing) continue;
524
+ const windowSeconds = job.definition.catchUpWindowSeconds;
525
+ const from = /* @__PURE__ */ new Date(now.getTime() - windowSeconds * 1e3);
526
+ const slot = findMostRecentSlot(job.definition.schedule, from, now);
527
+ if (!slot) continue;
528
+ const slotIso = slot.toISOString();
529
+ let claimed;
530
+ try {
531
+ claimed = await this.store.tryClaimRun(job.id, slotIso);
532
+ } catch (err) {
533
+ logger.warn(`[cron] Catch-up claim threw for "${job.id}" — skipping catch-up`, { error: err });
534
+ continue;
535
+ }
536
+ if (!claimed) continue;
537
+ const lateBy = Math.round((now.getTime() - slot.getTime()) / 1e3);
538
+ logger.info(`[cron] Catching up missed slot ${slotIso} for "${job.id}" (${lateBy}s late)`);
539
+ await this.executeJob(job, false, `⏰ Catch-up run for missed slot ${slotIso} (${lateBy}s late)`);
540
+ } catch (err) {
541
+ logger.error(`[cron] Catch-up failed for "${job.id}"`, { error: err });
542
+ }
543
+ }
544
+ /**
545
+ * Stop a single job's timer and clear its next run state.
546
+ */
547
+ stopJob(id) {
548
+ const job = this.jobs.get(id);
549
+ if (job?.timerId) {
550
+ clearTimeout(job.timerId);
551
+ job.timerId = void 0;
552
+ job.nextRunAt = void 0;
553
+ }
554
+ }
555
+ /**
556
+ * Execute a job's handler with full isolation and safety.
557
+ *
558
+ * - Sets a concurrency flag to prevent overlapping runs
559
+ * - Wraps handler in a timeout race
560
+ * - Captures all logs, errors, and results
561
+ * - Persists to store (non-blocking) if available
562
+ * - Always restores state even on catastrophic errors
563
+ */
564
+ async executeJob(job, manual, seedLog) {
565
+ const startedAt = /* @__PURE__ */ new Date();
566
+ const capturedLogs = seedLog ? [seedLog] : [];
567
+ job.executing = true;
568
+ const ctx = {
569
+ jobId: job.id,
570
+ scheduledAt: startedAt,
571
+ log: (...args) => {
572
+ const line = args.map((a) => typeof a === "string" ? a : JSON.stringify(a)).join(" ");
573
+ capturedLogs.push(line);
574
+ },
575
+ client: this.client
576
+ };
577
+ job.state = "running";
578
+ job.lastRunAt = startedAt;
579
+ job.totalRuns++;
580
+ let success = true;
581
+ let error;
582
+ let result;
583
+ try {
584
+ const timeout = (job.definition.timeoutSeconds ?? 300) * 1e3;
585
+ const handlerPromise = Promise.resolve(job.definition.handler(ctx));
586
+ let timeoutHandle;
587
+ const timeoutPromise = new Promise((_, reject) => {
588
+ timeoutHandle = setTimeout(() => reject(/* @__PURE__ */ new Error(`Cron job "${job.id}" timed out after ${timeout}ms`)), timeout);
589
+ });
590
+ try {
591
+ result = await Promise.race([handlerPromise, timeoutPromise]);
592
+ } finally {
593
+ clearTimeout(timeoutHandle);
594
+ }
595
+ } catch (err) {
596
+ success = false;
597
+ error = err instanceof Error ? err.message : String(err);
598
+ job.totalFailures++;
599
+ } finally {
600
+ job.executing = false;
601
+ }
602
+ const finishedAt = /* @__PURE__ */ new Date();
603
+ const durationMs = finishedAt.getTime() - startedAt.getTime();
604
+ job.state = success ? job.enabled ? "idle" : "disabled" : "error";
605
+ job.lastDurationMs = durationMs;
606
+ job.lastError = error;
607
+ const logEntry = {
608
+ jobId: job.id,
609
+ startedAt: startedAt.toISOString(),
610
+ finishedAt: finishedAt.toISOString(),
611
+ durationMs,
612
+ success,
613
+ error,
614
+ result: result !== void 0 ? result : void 0,
615
+ logs: capturedLogs,
616
+ manual
617
+ };
618
+ job.logs.push(logEntry);
619
+ if (job.logs.length > MAX_LOGS_PER_JOB) job.logs.shift();
620
+ if (this.store) this.store.insertLog(logEntry).catch((persistErr) => {
621
+ logger.error(`[cron] Failed to persist log for "${job.id}"`, { error: persistErr });
622
+ });
623
+ if (success) logger.info(`✅ [cron] "${job.id}" completed in ${durationMs}ms`);
624
+ else logger.error(`❌ [cron] "${job.id}" failed in ${durationMs}ms: ${error}`);
625
+ return logEntry;
626
+ }
627
+ toStatus(job) {
628
+ return {
629
+ id: job.id,
630
+ name: job.definition.name,
631
+ description: job.definition.description,
632
+ schedule: job.definition.schedule,
633
+ enabled: job.enabled,
634
+ state: job.state,
635
+ lastRunAt: job.lastRunAt?.toISOString(),
636
+ nextRunAt: job.nextRunAt?.toISOString(),
637
+ lastDurationMs: job.lastDurationMs,
638
+ lastError: job.lastError,
639
+ totalRuns: job.totalRuns,
640
+ totalFailures: job.totalFailures
641
+ };
642
+ }
643
+ };
644
+ //#endregion
645
+ export { cron_scheduler_exports as n, validateCronExpression as r, CronScheduler as t };
646
+
647
+ //# sourceMappingURL=cron-scheduler-B3RFt0HS.js.map