@rebasepro/server-core 0.0.1-canary.94dff14 → 0.0.1-canary.bbcb8b4

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.
@@ -9,23 +9,95 @@ import type { RebaseClient } from "@rebasepro/client";
9
9
  import type { LoadedCronJob } from "./cron-loader";
10
10
  import type { CronStore } from "./cron-store";
11
11
 
12
+ // ─── Cron expression parser (minimal, no external dependency) ────────
13
+ // Supports standard 5-field cron (minute hour dom month dow).
14
+ // Returns the next Date after `after` that matches the expression.
15
+
12
16
  /**
13
- * Validates a standard cron expression.
17
+ * Expand a single cron field into an ordered array of allowed values.
18
+ * Supports: `*`, `N`, `N-M`, `N/S`, `N-M/S`, `*​/S`, and comma-separated combinations.
14
19
  */
15
- function isValidCronExpression(schedule: string): boolean {
16
- if (!schedule) return false;
17
- const parts = schedule.trim().split(/\s+/);
18
- // Typical cron has 5 fields.
19
- return parts.length === 5 && parts.every(p => p.length > 0);
20
+ function expandCronField(field: string, min: number, max: number): number[] {
21
+ const results = new Set<number>();
22
+ for (const segment of field.split(",")) {
23
+ const trimmed = segment.trim();
24
+ if (trimmed === "*") {
25
+ for (let i = min; i <= max; i++) results.add(i);
26
+ } else if (trimmed.includes("/")) {
27
+ const [rangeStr, stepStr] = trimmed.split("/");
28
+ const step = parseInt(stepStr, 10);
29
+ if (isNaN(step) || step <= 0) {
30
+ throw new Error(`Invalid step value "${stepStr}" in cron field "${field}"`);
31
+ }
32
+ let start = min;
33
+ let end = max;
34
+ if (rangeStr !== "*") {
35
+ if (rangeStr.includes("-")) {
36
+ const [a, b] = rangeStr.split("-").map(Number);
37
+ start = a;
38
+ end = b;
39
+ } else {
40
+ start = parseInt(rangeStr, 10);
41
+ }
42
+ }
43
+ for (let i = start; i <= end; i += step) results.add(i);
44
+ } else if (trimmed.includes("-")) {
45
+ const [a, b] = trimmed.split("-").map(Number);
46
+ for (let i = a; i <= b; i++) results.add(i);
47
+ } else {
48
+ const val = parseInt(trimmed, 10);
49
+ if (isNaN(val)) {
50
+ throw new Error(`Invalid value "${trimmed}" in cron field "${field}"`);
51
+ }
52
+ results.add(val);
53
+ }
54
+ }
55
+ return [...results].sort((a, b) => a - b);
20
56
  }
21
57
 
22
- // ─── Cron expression parser (minimal, no external dependency) ────────
23
- // Supports standard 5-field cron (minute hour dom month dow).
24
- // Returns the next Date after `after` that matches the expression.
58
+ /**
59
+ * Validates a standard 5-field cron expression structurally and semantically.
60
+ * Returns `{ valid: true }` or `{ valid: false, reason: string }`.
61
+ */
62
+ export function validateCronExpression(schedule: string): { valid: true } | { valid: false; reason: string } {
63
+ if (!schedule || typeof schedule !== "string") {
64
+ return { valid: false, reason: "Schedule must be a non-empty string" };
65
+ }
66
+ const parts = schedule.trim().split(/\s+/);
67
+ if (parts.length !== 5) {
68
+ return { valid: false, reason: `Expected 5 fields, got ${parts.length}` };
69
+ }
70
+ const fieldRanges: [string, number, number][] = [
71
+ ["minute", 0, 59],
72
+ ["hour", 0, 23],
73
+ ["day of month", 1, 31],
74
+ ["month", 1, 12],
75
+ ["day of week", 0, 6],
76
+ ];
77
+ for (let i = 0; i < 5; i++) {
78
+ const [name, min, max] = fieldRanges[i];
79
+ try {
80
+ const values = expandCronField(parts[i], min, max);
81
+ if (values.length === 0) {
82
+ return { valid: false, reason: `${name} field "${parts[i]}" produces no values` };
83
+ }
84
+ for (const v of values) {
85
+ if (v < min || v > max) {
86
+ return { valid: false, reason: `${name} field value ${v} out of range [${min}–${max}]` };
87
+ }
88
+ }
89
+ } catch (err) {
90
+ return { valid: false, reason: `${name} field: ${err instanceof Error ? err.message : String(err)}` };
91
+ }
92
+ }
93
+ return { valid: true };
94
+ }
25
95
 
96
+ /**
97
+ * Calculate the next Date after `after` that matches the cron expression.
98
+ * Throws on invalid expressions.
99
+ */
26
100
  function parseCronExpression(expression: string, after: Date): Date {
27
- // We implement a simple forward-search. For production-grade parsing
28
- // one would use a library, but we avoid adding dependencies.
29
101
  const parts = expression.trim().split(/\s+/);
30
102
  if (parts.length < 5) {
31
103
  throw new Error(`Invalid cron expression: "${expression}". Expected 5 fields.`);
@@ -33,41 +105,11 @@ function parseCronExpression(expression: string, after: Date): Date {
33
105
 
34
106
  const [minField, hourField, domField, monField, dowField] = parts;
35
107
 
36
- const expand = (field: string, min: number, max: number): number[] => {
37
- const results = new Set<number>();
38
- for (const segment of field.split(",")) {
39
- if (segment === "*") {
40
- for (let i = min; i <= max; i++) results.add(i);
41
- } else if (segment.includes("/")) {
42
- const [rangeStr, stepStr] = segment.split("/");
43
- const step = parseInt(stepStr, 10);
44
- let start = min;
45
- let end = max;
46
- if (rangeStr !== "*") {
47
- if (rangeStr.includes("-")) {
48
- const [a, b] = rangeStr.split("-").map(Number);
49
- start = a;
50
- end = b;
51
- } else {
52
- start = parseInt(rangeStr, 10);
53
- }
54
- }
55
- for (let i = start; i <= end; i += step) results.add(i);
56
- } else if (segment.includes("-")) {
57
- const [a, b] = segment.split("-").map(Number);
58
- for (let i = a; i <= b; i++) results.add(i);
59
- } else {
60
- results.add(parseInt(segment, 10));
61
- }
62
- }
63
- return [...results].sort((a, b) => a - b);
64
- };
65
-
66
- const minutes = expand(minField, 0, 59);
67
- const hours = expand(hourField, 0, 23);
68
- const doms = expand(domField, 1, 31);
69
- const months = expand(monField, 1, 12);
70
- const dows = expand(dowField, 0, 6); // 0=Sunday
108
+ const minutes = expandCronField(minField, 0, 59);
109
+ const hours = expandCronField(hourField, 0, 23);
110
+ const doms = expandCronField(domField, 1, 31);
111
+ const months = expandCronField(monField, 1, 12);
112
+ const dows = expandCronField(dowField, 0, 6); // 0=Sunday
71
113
 
72
114
  // Forward-search from `after + 1 minute`
73
115
  const candidate = new Date(after);
@@ -104,6 +146,12 @@ function parseCronExpression(expression: string, after: Date): Date {
104
146
 
105
147
  const MAX_LOGS_PER_JOB = 50;
106
148
 
149
+ /**
150
+ * Minimum milliseconds between scheduled executions of the same job.
151
+ * Prevents tight re-execution loops caused by jitter or clock drift.
152
+ */
153
+ const MIN_SCHEDULE_INTERVAL_MS = 5_000; // 5 seconds
154
+
107
155
  // ─── CronScheduler ───────────────────────────────────────────────────
108
156
 
109
157
  interface RegisteredJob {
@@ -119,6 +167,8 @@ interface RegisteredJob {
119
167
  totalFailures: number;
120
168
  timerId?: ReturnType<typeof setTimeout>;
121
169
  logs: CronJobLogEntry[];
170
+ /** True while a handler is actively executing (prevents concurrent runs). */
171
+ executing: boolean;
122
172
  }
123
173
 
124
174
  export class CronScheduler {
@@ -145,24 +195,47 @@ export class CronScheduler {
145
195
 
146
196
  /**
147
197
  * Register a batch of loaded cron jobs.
198
+ *
199
+ * If the scheduler is already started, newly registered jobs are
200
+ * automatically scheduled (so late-registered jobs don't sit idle).
201
+ *
202
+ * Validates the cron schedule on registration — invalid schedules
203
+ * are rejected with a warning and the job is NOT registered.
148
204
  */
149
205
  registerJobs(loadedJobs: LoadedCronJob[]): void {
150
206
  for (const loaded of loadedJobs) {
207
+ // Validate schedule up-front — reject invalid schedules
208
+ const validation = validateCronExpression(loaded.definition.schedule);
209
+ if (!validation.valid) {
210
+ console.error(
211
+ `[cron] Rejecting job "${loaded.id}": invalid schedule "${loaded.definition.schedule}" — ${validation.reason}`
212
+ );
213
+ continue;
214
+ }
215
+
151
216
  const existing = this.jobs.get(loaded.id);
152
217
  if (existing) {
153
218
  console.warn(`[cron] Duplicate cron job id: "${loaded.id}". Overwriting.`);
154
219
  this.stopJob(loaded.id);
155
220
  }
156
221
 
222
+ const enabled = loaded.definition.enabled !== false;
223
+
157
224
  this.jobs.set(loaded.id, {
158
225
  id: loaded.id,
159
226
  definition: loaded.definition,
160
- enabled: loaded.definition.enabled !== false,
161
- state: loaded.definition.enabled !== false ? "idle" : "disabled",
227
+ enabled,
228
+ state: enabled ? "idle" : "disabled",
162
229
  totalRuns: 0,
163
230
  totalFailures: 0,
164
- logs: []
231
+ logs: [],
232
+ executing: false
165
233
  });
234
+
235
+ // If the scheduler is already running, auto-schedule new jobs
236
+ if (this.started && enabled) {
237
+ this.scheduleNext(loaded.id);
238
+ }
166
239
  }
167
240
  }
168
241
 
@@ -201,6 +274,9 @@ export class CronScheduler {
201
274
 
202
275
  /**
203
276
  * Stop the scheduler and clear all timers.
277
+ *
278
+ * Currently-executing handlers run to completion (they are async),
279
+ * but no further scheduling occurs after stop.
204
280
  */
205
281
  stop(): void {
206
282
  this.started = false;
@@ -269,32 +345,93 @@ export class CronScheduler {
269
345
 
270
346
  /**
271
347
  * Manually trigger a job execution immediately.
348
+ *
349
+ * Returns `undefined` if the job doesn't exist.
350
+ * If the job is currently executing, returns the log entry with
351
+ * a `skipped: true` result rather than running concurrently.
272
352
  */
273
353
  async triggerJob(id: string): Promise<CronJobLogEntry | undefined> {
274
354
  const job = this.jobs.get(id);
275
355
  if (!job) return undefined;
356
+
357
+ // Concurrency guard — don't run two instances simultaneously
358
+ if (job.executing) {
359
+ console.warn(`[cron] Skipping manual trigger of "${id}" — already executing`);
360
+ const logEntry: CronJobLogEntry = {
361
+ jobId: id,
362
+ startedAt: new Date().toISOString(),
363
+ finishedAt: new Date().toISOString(),
364
+ durationMs: 0,
365
+ success: true,
366
+ result: { skipped: true, reason: "already_executing" },
367
+ logs: ["Skipped: job is already running"],
368
+ manual: true
369
+ };
370
+ job.logs.push(logEntry);
371
+ if (job.logs.length > MAX_LOGS_PER_JOB) job.logs.shift();
372
+ return logEntry;
373
+ }
374
+
276
375
  return this.executeJob(job, true);
277
376
  }
278
377
 
279
378
  // ─── Internal ────────────────────────────────────────────────────
280
379
 
380
+ /**
381
+ * Schedule the next execution for a job.
382
+ *
383
+ * Safety guarantees:
384
+ * 1. Clears any existing timer first (prevents leaked/duplicate timers)
385
+ * 2. Enforces a minimum delay to prevent tight loops from jitter
386
+ * 3. Unref's the timer so it doesn't prevent process exit
387
+ * 4. Re-checks enabled & started state before executing
388
+ * 5. Concurrency guard prevents overlapping handler executions
389
+ */
281
390
  private scheduleNext(id: string): void {
282
391
  const job = this.jobs.get(id);
283
392
  if (!job || !job.enabled || !this.started) return;
284
393
 
394
+ // Clear any previously scheduled timer to prevent double-firing
395
+ this.stopJob(id);
396
+
285
397
  try {
286
398
  const now = new Date();
287
399
  const nextRun = parseCronExpression(job.definition.schedule, now);
288
400
  job.nextRunAt = nextRun;
289
401
 
290
- const delay = Math.max(nextRun.getTime() - now.getTime(), 0);
402
+ const rawDelay = nextRun.getTime() - now.getTime();
403
+ // Enforce a minimum delay to prevent tight re-execution loops
404
+ // from event loop jitter or near-zero setTimeout drift
405
+ const delay = Math.max(rawDelay, MIN_SCHEDULE_INTERVAL_MS);
291
406
 
292
- job.timerId = setTimeout(async () => {
407
+ const timer = setTimeout(async () => {
408
+ // Re-check state: scheduler may have been stopped or job disabled
409
+ // between when we scheduled and when we fire
293
410
  if (!job.enabled || !this.started) return;
411
+
412
+ // Concurrency guard: if somehow we're already executing, skip
413
+ if (job.executing) {
414
+ console.warn(`[cron] Skipping scheduled run of "${id}" — still executing from previous run`);
415
+ // Re-schedule to try again later
416
+ this.scheduleNext(id);
417
+ return;
418
+ }
419
+
294
420
  await this.executeJob(job, false);
295
- // Schedule the next tick
296
- this.scheduleNext(id);
421
+
422
+ // Schedule the next tick (only if still started + enabled)
423
+ if (this.started && job.enabled) {
424
+ this.scheduleNext(id);
425
+ }
297
426
  }, delay);
427
+
428
+ // Unref the timer so it doesn't prevent Node.js from exiting
429
+ // during graceful shutdown
430
+ if (timer && typeof timer === "object" && "unref" in timer) {
431
+ timer.unref();
432
+ }
433
+
434
+ job.timerId = timer;
298
435
  } catch (err: unknown) {
299
436
  console.error(`[cron] Failed to schedule "${id}":`, err);
300
437
  job.state = "error";
@@ -302,6 +439,9 @@ export class CronScheduler {
302
439
  }
303
440
  }
304
441
 
442
+ /**
443
+ * Stop a single job's timer and clear its next run state.
444
+ */
305
445
  private stopJob(id: string): void {
306
446
  const job = this.jobs.get(id);
307
447
  if (job?.timerId) {
@@ -311,6 +451,15 @@ export class CronScheduler {
311
451
  }
312
452
  }
313
453
 
454
+ /**
455
+ * Execute a job's handler with full isolation and safety.
456
+ *
457
+ * - Sets a concurrency flag to prevent overlapping runs
458
+ * - Wraps handler in a timeout race
459
+ * - Captures all logs, errors, and results
460
+ * - Persists to store (non-blocking) if available
461
+ * - Always restores state even on catastrophic errors
462
+ */
314
463
  private async executeJob(
315
464
  job: RegisteredJob,
316
465
  manual: boolean
@@ -318,6 +467,9 @@ export class CronScheduler {
318
467
  const startedAt = new Date();
319
468
  const capturedLogs: string[] = [];
320
469
 
470
+ // Set executing flag — prevents concurrent runs
471
+ job.executing = true;
472
+
321
473
  const ctx: CronJobContext = {
322
474
  jobId: job.id,
323
475
  scheduledAt: startedAt,
@@ -342,15 +494,26 @@ export class CronScheduler {
342
494
  // Race with timeout
343
495
  const timeout = (job.definition.timeoutSeconds ?? 300) * 1000;
344
496
  const handlerPromise = Promise.resolve(job.definition.handler(ctx));
497
+ let timeoutHandle: ReturnType<typeof setTimeout>;
345
498
  const timeoutPromise = new Promise<never>((_, reject) => {
346
- setTimeout(() => reject(new Error(`Cron job "${job.id}" timed out after ${timeout}ms`)), timeout);
499
+ timeoutHandle = setTimeout(
500
+ () => reject(new Error(`Cron job "${job.id}" timed out after ${timeout}ms`)),
501
+ timeout
502
+ );
347
503
  });
348
504
 
349
- result = await Promise.race([handlerPromise, timeoutPromise]);
505
+ try {
506
+ result = await Promise.race([handlerPromise, timeoutPromise]);
507
+ } finally {
508
+ clearTimeout(timeoutHandle!);
509
+ }
350
510
  } catch (err: unknown) {
351
511
  success = false;
352
512
  error = err instanceof Error ? err.message : String(err);
353
513
  job.totalFailures++;
514
+ } finally {
515
+ // Always clear executing flag — even on catastrophic errors
516
+ job.executing = false;
354
517
  }
355
518
 
356
519
  const finishedAt = new Date();
@@ -380,8 +543,8 @@ export class CronScheduler {
380
543
 
381
544
  // Persist to database (non-blocking)
382
545
  if (this.store) {
383
- this.store.insertLog(logEntry).catch((err) => {
384
- console.error(`[cron] Failed to persist log for "${job.id}":`, err);
546
+ this.store.insertLog(logEntry).catch((persistErr) => {
547
+ console.error(`[cron] Failed to persist log for "${job.id}":`, persistErr);
385
548
  });
386
549
  }
387
550
 
package/src/cron/index.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  export { loadCronJobsFromDirectory } from "./cron-loader";
2
2
  export type { LoadedCronJob } from "./cron-loader";
3
- export { CronScheduler } from "./cron-scheduler";
3
+ export { CronScheduler, validateCronExpression } from "./cron-scheduler";
4
4
  export { createCronRoutes } from "./cron-routes";
5
5
  export { createCronStore } from "./cron-store";
6
6
  export type { CronStore } from "./cron-store";