From 684a88a96395418d0ca2ecfb7b418bb13ac5332f Mon Sep 17 00:00:00 2001 From: Mohamed Achaq Date: Wed, 3 Sep 2025 11:49:51 +0100 Subject: [PATCH 1/6] refactor: update import paths to relative references and enhance code consistency --- .changeset/config.json | 2 +- index.ts | 6 +- lib/baker.ts | 6 +- lib/cron.ts | 116 +++++---- lib/index.test.ts | 518 +++++++++++++++++++++-------------------- lib/index.ts | 8 +- lib/parser.ts | 22 +- lib/utils.ts | 5 +- package.json | 9 +- 9 files changed, 366 insertions(+), 326 deletions(-) diff --git a/.changeset/config.json b/.changeset/config.json index 91b6a95..fce1c26 100644 --- a/.changeset/config.json +++ b/.changeset/config.json @@ -4,7 +4,7 @@ "commit": false, "fixed": [], "linked": [], - "access": "restricted", + "access": "public", "baseBranch": "main", "updateInternalDependencies": "patch", "ignore": [] diff --git a/index.ts b/index.ts index 494c456..6a0f524 100644 --- a/index.ts +++ b/index.ts @@ -1,4 +1,4 @@ -import Baker from '@/lib'; +import Baker from './lib'; export { type CronOptions, @@ -17,7 +17,7 @@ export { type OnDayStrType, type day, type unit, -} from '@/lib'; +} from './lib'; -export { Cron, Baker, CronParser } from '@/lib'; +export { Cron, Baker, CronParser } from './lib'; export default Baker; diff --git a/lib/baker.ts b/lib/baker.ts index fe3677c..edd7f99 100644 --- a/lib/baker.ts +++ b/lib/baker.ts @@ -1,4 +1,4 @@ -import Cron from '@/lib/cron'; +import Cron from './cron'; import { CronOptions, IBaker, @@ -9,7 +9,7 @@ import { JobMetrics, SchedulerConfig, PersistenceOptions -} from '@/lib/types'; +} from './types'; import * as fs from 'fs'; import * as path from 'path'; @@ -308,4 +308,4 @@ class Baker implements IBaker { } } -export default Baker; \ No newline at end of file +export default Baker; diff --git a/lib/cron.ts b/lib/cron.ts index 55a2428..108dbb0 100644 --- a/lib/cron.ts +++ b/lib/cron.ts @@ -7,9 +7,9 @@ import { type Status, type ExecutionHistory, type JobMetrics, -} from '@/lib/types'; -import CronParser from '@/lib/parser'; -import { CBResolver } from '@/lib/utils'; +} from "./types"; +import CronParser from "./parser"; +import { CBResolver } from "./utils"; /** * A class that implements the `ICron` interface and provides methods manage a cron job. @@ -22,10 +22,10 @@ class Cron implements ICron { onComplete: () => void; onError?: (error: Error) => void; priority: number; - private interval: Timer | null = null; - private timeout: Timer | null = null; + private interval: ReturnType | null = null; + private timeout: ReturnType | null = null; private next: Date | null = null; - private status: Status = 'stopped'; + private status: Status = "stopped"; private parser: ICronParser; private history: ExecutionHistory[] = []; private metrics: JobMetrics = { @@ -41,11 +41,14 @@ class Cron implements ICron { /** * Creates a new instance of the `Cron` class. */ - constructor(options: CronOptions, config?: { useCalculatedTimeouts?: boolean; pollingInterval?: number }) { - if (!options.name || typeof options.name !== 'string') { - throw new Error('Cron job name is required and must be a string'); + constructor( + options: CronOptions, + config?: { useCalculatedTimeouts?: boolean; pollingInterval?: number } + ) { + if (!options.name || typeof options.name !== "string") { + throw new Error("Cron job name is required and must be a string"); } - + this.name = options.name; this.cron = options.cron; this.callback = options.callback; @@ -56,7 +59,7 @@ class Cron implements ICron { this.maxHistory = options.maxHistory ?? 100; this.useCalculatedTimeouts = config?.useCalculatedTimeouts ?? true; this.pollingInterval = config?.pollingInterval ?? 1000; - + this.start = this.start.bind(this); this.stop = this.stop.bind(this); this.pause = this.pause.bind(this); @@ -71,10 +74,10 @@ class Cron implements ICron { this.getHistory = this.getHistory.bind(this); this.getMetrics = this.getMetrics.bind(this); this.resetMetrics = this.resetMetrics.bind(this); - + this.parser = new CronParser(this.cron); this.validateCronExpression(); - + if (options.start) { this.start(); } @@ -84,9 +87,9 @@ class Cron implements ICron { * Validates the cron expression and custom preset bounds */ private validateCronExpression(): void { - if (typeof this.cron === 'string') { - if (this.cron.includes('@every_')) { - const parts = this.cron.split('_'); + if (typeof this.cron === "string") { + if (this.cron.includes("@every_")) { + const parts = this.cron.split("_"); if (parts.length >= 3) { const value = parseInt(parts[1]); if (isNaN(value) || value <= 0) { @@ -95,7 +98,7 @@ class Cron implements ICron { } } - if (this.cron.includes('@at_')) { + if (this.cron.includes("@at_")) { const timeMatch = this.cron.match(/@at_(\d+):(\d+)/); if (timeMatch) { const hour = parseInt(timeMatch[1]); @@ -106,13 +109,15 @@ class Cron implements ICron { } } - if (this.cron.includes('@between_')) { + if (this.cron.includes("@between_")) { const betweenMatch = this.cron.match(/@between_(\d+)_(\d+)/); if (betweenMatch) { const start = parseInt(betweenMatch[1]); const end = parseInt(betweenMatch[2]); if (start < 0 || start > 23 || end < 0 || end > 23 || start >= end) { - throw new Error(`Invalid hour range in custom preset: ${this.cron}`); + throw new Error( + `Invalid hour range in custom preset: ${this.cron}` + ); } } } @@ -126,12 +131,12 @@ class Cron implements ICron { } start(): void { - if (this.status === 'running') { + if (this.status === "running") { return; } - this.status = 'running'; + this.status = "running"; this.next = this.parser.getNext(); - + if (this.useCalculatedTimeouts) { this.scheduleWithTimeout(); } else { @@ -143,14 +148,14 @@ class Cron implements ICron { * Schedules execution using calculated timeouts for better efficiency */ private scheduleWithTimeout(): void { - if (this.status !== 'running' || !this.next) return; - + if (this.status !== "running" || !this.next) return; + const delay = Math.max(0, this.next.getTime() - Date.now()); - + // JavaScript setTimeout has a maximum safe value of 2^31-1 (2147483647ms) // If the delay exceeds this, fall back to polling approach const MAX_TIMEOUT_VALUE = 2147483647; - + if (delay > MAX_TIMEOUT_VALUE) { // For very long delays, use polling instead // Clear any existing timeout first @@ -161,9 +166,9 @@ class Cron implements ICron { this.scheduleWithInterval(); return; } - + this.timeout = setTimeout(async () => { - if (this.status === 'running') { + if (this.status === "running") { await this.executeJob(); this.next = this.parser.getNext(); this.scheduleWithTimeout(); @@ -176,7 +181,11 @@ class Cron implements ICron { */ private scheduleWithInterval(): void { this.interval = setInterval(async () => { - if (this.next && this.next.getTime() <= Date.now() && this.status === 'running') { + if ( + this.next && + this.next.getTime() <= Date.now() && + this.status === "running" + ) { await this.executeJob(); this.next = this.parser.getNext(); } @@ -190,7 +199,7 @@ class Cron implements ICron { const startTime = Date.now(); let success = true; let error: string | undefined; - + try { await CBResolver(this.callback, this.onError); this.onTick(); @@ -200,29 +209,31 @@ class Cron implements ICron { error = err instanceof Error ? err.message : String(err); this.metrics.failedExecutions++; this.metrics.lastError = error; - + if (this.onError) { try { this.onError(err instanceof Error ? err : new Error(String(err))); } catch (handlerError) { - console.warn('Error handler failed:', handlerError); + console.warn("Error handler failed:", handlerError); } } } - + const duration = Date.now() - startTime; this.metrics.totalExecutions++; this.metrics.lastExecutionTime = duration; - this.metrics.averageExecutionTime = - (this.metrics.averageExecutionTime * (this.metrics.totalExecutions - 1) + duration) / this.metrics.totalExecutions; - + this.metrics.averageExecutionTime = + (this.metrics.averageExecutionTime * (this.metrics.totalExecutions - 1) + + duration) / + this.metrics.totalExecutions; + const historyEntry: ExecutionHistory = { timestamp: new Date(startTime), duration, success, error, }; - + this.history.unshift(historyEntry); if (this.history.length > this.maxHistory) { this.history = this.history.slice(0, this.maxHistory); @@ -230,28 +241,28 @@ class Cron implements ICron { } stop(): void { - if (this.status === 'stopped') { + if (this.status === "stopped") { return; } - this.status = 'stopped'; + this.status = "stopped"; this.clearSchedulers(); } pause(): void { - if (this.status !== 'running') { + if (this.status !== "running") { return; } - this.status = 'paused'; + this.status = "paused"; this.clearSchedulers(); } resume(): void { - if (this.status !== 'paused') { + if (this.status !== "paused") { return; } - this.status = 'running'; + this.status = "running"; this.next = this.parser.getNext(); - + if (this.useCalculatedTimeouts) { this.scheduleWithTimeout(); } else { @@ -275,7 +286,7 @@ class Cron implements ICron { destroy(): void { this.clearSchedulers(); - this.status = 'stopped'; + this.status = "stopped"; this.onComplete(); } @@ -284,7 +295,7 @@ class Cron implements ICron { } isRunning(): boolean { - return this.status === 'running'; + return this.status === "running"; } lastExecution(): Date { @@ -325,7 +336,10 @@ class Cron implements ICron { * Creates a new cron job with the specified options. * @returns A new `ICron` object representing the cron job. */ - static create(options: CronOptions, config?: { useCalculatedTimeouts?: boolean; pollingInterval?: number }): ICron { + static create( + options: CronOptions, + config?: { useCalculatedTimeouts?: boolean; pollingInterval?: number } + ): ICron { return new Cron(options, config); } @@ -334,7 +348,7 @@ class Cron implements ICron { * @returns A `CronTime` object representing the parsed cron expression. */ static parse( - cron: CronExpressionType, + cron: CronExpressionType ): CronTime { return new CronParser(cron).parse(); } @@ -353,7 +367,7 @@ class Cron implements ICron { * @returns A `Date` object representing the previous execution time. */ static getPrevious( - cron: CronExpressionType, + cron: CronExpressionType ): Date { return new CronParser(cron).getPrevious(); } @@ -363,7 +377,7 @@ class Cron implements ICron { * @returns `true` if the string is a valid cron expression, `false` otherwise. */ static isValid( - cron: CronExpressionType, + cron: CronExpressionType ): boolean { try { new CronParser(cron).parse(); @@ -374,4 +388,4 @@ class Cron implements ICron { } } -export default Cron; \ No newline at end of file +export default Cron; diff --git a/lib/index.test.ts b/lib/index.test.ts index 8edd079..833ce21 100644 --- a/lib/index.test.ts +++ b/lib/index.test.ts @@ -1,6 +1,6 @@ -import Baker from '@/lib/baker'; -import Cron from '@/lib/cron'; -import CronParser from '@/lib/parser'; +import Baker from "@/lib/baker"; +import Cron from "@/lib/cron"; +import CronParser from "@/lib/parser"; import { expect, describe, @@ -9,9 +9,9 @@ import { beforeEach, jest, Mock, -} from 'bun:test'; +} from "bun:test"; -describe('Baker', () => { +describe("Baker", () => { let baker: Baker; beforeEach(() => { baker = new Baker(); @@ -21,8 +21,8 @@ describe('Baker', () => { baker.destroyAll(); }); - it('Should check all the presets', () => { - const presets = ['@daily', '@hourly', '@monthly', '@weekly', '@yearly']; + it("Should check all the presets", () => { + const presets = ["@daily", "@hourly", "@monthly", "@weekly", "@yearly"]; presets.forEach((preset) => { const parser = new CronParser(preset); const cronTime = parser.parse(); @@ -30,273 +30,273 @@ describe('Baker', () => { }); }); - it('should add a cron job', () => { + it("should add a cron job", () => { const cron = baker.add({ - name: 'test', - cron: '* * * * * *', + name: "test", + cron: "* * * * * *", callback: jest.fn(), }); expect(cron).toBeDefined(); - expect(cron.name).toBe('test'); + expect(cron.name).toBe("test"); }); - it('should prevent duplicate job names', () => { + it("should prevent duplicate job names", () => { baker.add({ - name: 'test', - cron: '* * * * * *', + name: "test", + cron: "* * * * * *", callback: jest.fn(), }); - + expect(() => { baker.add({ - name: 'test', - cron: '0 * * * * *', + name: "test", + cron: "0 * * * * *", callback: jest.fn(), }); }).toThrow("Cron job with name 'test' already exists"); }); - it('should validate cron job names', () => { + it("should validate cron job names", () => { expect(() => { baker.add({ - name: '', - cron: '* * * * * *', + name: "", + cron: "* * * * * *", callback: jest.fn(), }); - }).toThrow('Cron job name is required and must be a string'); + }).toThrow("Cron job name is required and must be a string"); }); - it('should validate custom preset bounds', () => { + it("should validate custom preset bounds", () => { expect(() => { baker.add({ - name: 'test', - cron: '@every_0_seconds', + name: "test", + cron: "@every_0_seconds", callback: jest.fn(), }); - }).toThrow('Invalid value in custom preset'); + }).toThrow("Invalid value in custom preset"); expect(() => { baker.add({ - name: 'test2', - cron: '@at_25:30', + name: "test2", + cron: "@at_25:30", callback: jest.fn(), }); - }).toThrow('Invalid time in custom preset'); + }).toThrow("Invalid time in custom preset"); expect(() => { baker.add({ - name: 'test3', - cron: '@between_23_5', + name: "test3", + cron: "@between_23_5", callback: jest.fn(), }); - }).toThrow('Invalid hour range in custom preset'); + }).toThrow("Invalid hour range in custom preset"); }); - it('should remove a cron job', () => { + it("should remove a cron job", () => { baker.add({ - name: 'test', - cron: '* * * * * *', + name: "test", + cron: "* * * * * *", callback: jest.fn(), }); - baker.remove('test'); - expect(baker.isRunning('test')).toBeFalsy(); + baker.remove("test"); + expect(baker.isRunning("test")).toBeFalsy(); }); - it('should start a cron job', () => { + it("should start a cron job", () => { baker.add({ - name: 'test', - cron: '* * * * * *', + name: "test", + cron: "* * * * * *", callback: jest.fn(), }); - baker.bake('test'); - expect(baker.isRunning('test')).toBeTruthy(); + baker.bake("test"); + expect(baker.isRunning("test")).toBeTruthy(); }); - it('should stop a cron job', () => { + it("should stop a cron job", () => { baker.add({ - name: 'test', - cron: '* * * * * *', + name: "test", + cron: "* * * * * *", callback: jest.fn(), }); - baker.bake('test'); - baker.stop('test'); - expect(baker.isRunning('test')).toBeFalsy(); + baker.bake("test"); + baker.stop("test"); + expect(baker.isRunning("test")).toBeFalsy(); }); - it('should pause and resume a cron job', () => { + it("should pause and resume a cron job", () => { const cron = baker.add({ - name: 'test', - cron: '* * * * * *', + name: "test", + cron: "* * * * * *", callback: jest.fn(), }); - + cron.start(); - expect(cron.getStatus()).toBe('running'); - - baker.pause('test'); - expect(baker.getStatus('test')).toBe('paused'); - - baker.resume('test'); - expect(baker.getStatus('test')).toBe('running'); + expect(cron.getStatus()).toBe("running"); + + baker.pause("test"); + expect(baker.getStatus("test")).toBe("paused"); + + baker.resume("test"); + expect(baker.getStatus("test")).toBe("running"); }); - it('should destroy a cron job', () => { + it("should destroy a cron job", () => { baker.add({ - name: 'test', - cron: '* * * * * *', + name: "test", + cron: "* * * * * *", callback: jest.fn(), }); - baker.destroy('test'); - expect(baker.isRunning('test')).toBeFalsy(); + baker.destroy("test"); + expect(baker.isRunning("test")).toBeFalsy(); }); - it('should start all cron jobs', () => { + it("should start all cron jobs", () => { baker.add({ - name: 'test1', - cron: '* * * * * *', + name: "test1", + cron: "* * * * * *", callback: jest.fn(), }); baker.add({ - name: 'test2', - cron: '* * * * * *', + name: "test2", + cron: "* * * * * *", callback: jest.fn(), }); baker.bakeAll(); - expect(baker.isRunning('test1')).toBeTruthy(); - expect(baker.isRunning('test2')).toBeTruthy(); + expect(baker.isRunning("test1")).toBeTruthy(); + expect(baker.isRunning("test2")).toBeTruthy(); }); - it('should stop all cron jobs', () => { + it("should stop all cron jobs", () => { baker.add({ - name: 'test1', - cron: '* * * * * *', + name: "test1", + cron: "* * * * * *", callback: jest.fn(), }); baker.add({ - name: 'test2', - cron: '* * * * * *', + name: "test2", + cron: "* * * * * *", callback: jest.fn(), }); baker.bakeAll(); baker.stopAll(); - expect(baker.isRunning('test1')).toBeFalsy(); - expect(baker.isRunning('test2')).toBeFalsy(); + expect(baker.isRunning("test1")).toBeFalsy(); + expect(baker.isRunning("test2")).toBeFalsy(); }); - it('should pause all cron jobs', () => { + it("should pause all cron jobs", () => { baker.add({ - name: 'test1', - cron: '* * * * * *', + name: "test1", + cron: "* * * * * *", callback: jest.fn(), }); baker.add({ - name: 'test2', - cron: '* * * * * *', + name: "test2", + cron: "* * * * * *", callback: jest.fn(), }); baker.bakeAll(); baker.pauseAll(); - expect(baker.getStatus('test1')).toBe('paused'); - expect(baker.getStatus('test2')).toBe('paused'); + expect(baker.getStatus("test1")).toBe("paused"); + expect(baker.getStatus("test2")).toBe("paused"); }); - it('should resume all cron jobs', () => { + it("should resume all cron jobs", () => { baker.add({ - name: 'test1', - cron: '* * * * * *', + name: "test1", + cron: "* * * * * *", callback: jest.fn(), }); baker.add({ - name: 'test2', - cron: '* * * * * *', + name: "test2", + cron: "* * * * * *", callback: jest.fn(), }); baker.bakeAll(); baker.pauseAll(); baker.resumeAll(); - expect(baker.getStatus('test1')).toBe('running'); - expect(baker.getStatus('test2')).toBe('running'); + expect(baker.getStatus("test1")).toBe("running"); + expect(baker.getStatus("test2")).toBe("running"); }); - it('should destroy all cron jobs', () => { + it("should destroy all cron jobs", () => { baker.add({ - name: 'test1', - cron: '* * * * * *', + name: "test1", + cron: "* * * * * *", callback: jest.fn(), }); baker.add({ - name: 'test2', - cron: '* * * * * *', + name: "test2", + cron: "* * * * * *", callback: jest.fn(), }); baker.destroyAll(); - expect(baker.isRunning('test1')).toBeFalsy(); - expect(baker.isRunning('test2')).toBeFalsy(); + expect(baker.isRunning("test1")).toBeFalsy(); + expect(baker.isRunning("test2")).toBeFalsy(); }); - it('should get job names', () => { + it("should get job names", () => { baker.add({ - name: 'test1', - cron: '* * * * * *', + name: "test1", + cron: "* * * * * *", callback: jest.fn(), }); baker.add({ - name: 'test2', - cron: '* * * * * *', + name: "test2", + cron: "* * * * * *", callback: jest.fn(), }); - + const names = baker.getJobNames(); - expect(names).toContain('test1'); - expect(names).toContain('test2'); + expect(names).toContain("test1"); + expect(names).toContain("test2"); expect(names.length).toBe(2); }); - it('should get all jobs', () => { + it("should get all jobs", () => { const cron1 = baker.add({ - name: 'test1', - cron: '* * * * * *', + name: "test1", + cron: "* * * * * *", callback: jest.fn(), }); const cron2 = baker.add({ - name: 'test2', - cron: '* * * * * *', + name: "test2", + cron: "* * * * * *", callback: jest.fn(), }); - + const allJobs = baker.getAllJobs(); - expect(allJobs.get('test1')).toBe(cron1); - expect(allJobs.get('test2')).toBe(cron2); + expect(allJobs.get("test1")).toBe(cron1); + expect(allJobs.get("test2")).toBe(cron2); expect(allJobs.size).toBe(2); }); - it('should track metrics without long execution', () => { + it("should track metrics without long execution", () => { const callback = jest.fn(); const cron = baker.add({ - name: 'test', - cron: '0 0 0 1 1 *', + name: "test", + cron: "0 0 0 1 1 *", callback, }); - const metrics = baker.getMetrics('test'); - const history = baker.getHistory('test'); - + const metrics = baker.getMetrics("test"); + const history = baker.getHistory("test"); + expect(metrics.totalExecutions).toBe(0); expect(history.length).toBe(0); - expect(typeof metrics.averageExecutionTime).toBe('number'); + expect(typeof metrics.averageExecutionTime).toBe("number"); }); - it('should handle job errors in configuration', () => { + it("should handle job errors in configuration", () => { const errorCallback = jest.fn(() => { - throw new Error('Test error'); + throw new Error("Test error"); }); const onError = jest.fn(); const cron = baker.add({ - name: 'test', - cron: '0 0 0 1 1 *', + name: "test", + cron: "0 0 0 1 1 *", callback: errorCallback, onError, }); @@ -304,22 +304,22 @@ describe('Baker', () => { expect(cron.onError).toBeDefined(); }); - it('should reset metrics', () => { + it("should reset metrics", () => { const cron = baker.add({ - name: 'test', - cron: '* * * * * *', + name: "test", + cron: "* * * * * *", callback: jest.fn(), }); cron.resetMetrics(); - - const metrics = baker.getMetrics('test'); + + const metrics = baker.getMetrics("test"); expect(metrics.totalExecutions).toBe(0); expect(metrics.successfulExecutions).toBe(0); expect(metrics.failedExecutions).toBe(0); }); - it('should handle Baker configuration options', () => { + it("should handle Baker configuration options", () => { const onError = jest.fn(); const configuredBaker = new Baker({ autoStart: false, @@ -333,46 +333,46 @@ describe('Baker', () => { }); const cron = configuredBaker.add({ - name: 'test', - cron: '* * * * * *', + name: "test", + cron: "* * * * * *", callback: jest.fn(), }); expect(cron).toBeDefined(); - expect(configuredBaker.isRunning('test')).toBeFalsy(); - + expect(configuredBaker.isRunning("test")).toBeFalsy(); + configuredBaker.destroyAll(); }); - it('should handle persistence configuration', async () => { + it("should handle persistence configuration", async () => { const persistenceBaker = new Baker({ persistence: { enabled: false, - filePath: './test-state.json', + filePath: "./test-state.json", autoRestore: false, }, }); const cron = persistenceBaker.add({ - name: 'test', - cron: '* * * * * *', + name: "test", + cron: "* * * * * *", callback: jest.fn(), }); expect(cron).toBeDefined(); - + persistenceBaker.destroyAll(); }); }); -describe('CronParser', () => { +describe("CronParser", () => { let parser: CronParser; beforeEach(() => { - parser = new CronParser('* * * * * *'); + parser = new CronParser("* * * * * *"); }); - it('should parse the cron expression', () => { + it("should parse the cron expression", () => { const cronTime = parser.parse(); expect(cronTime).toBeDefined(); expect(cronTime.second).toBeDefined(); @@ -383,71 +383,91 @@ describe('CronParser', () => { expect(cronTime.dayOfWeek).toBeDefined(); }); - it('should get the next execution time', () => { + it("should get the next execution time", () => { const nextExecution = parser.getNext(); expect(nextExecution).toBeInstanceOf(Date); expect(nextExecution.getTime()).toBeGreaterThan(Date.now()); }); - it('should get the previous execution time', () => { + it("should get the previous execution time", () => { const previousExecution = parser.getPrevious(); expect(previousExecution).toBeInstanceOf(Date); expect(previousExecution.getTime()).toBeLessThan(Date.now()); }); - it('should parse custom presets correctly', () => { - const customParser = new CronParser('@every_5_minutes'); + it("should parse custom presets correctly", () => { + const customParser = new CronParser("@every_5_minutes"); const cronTime = customParser.parse(); - expect(cronTime.minute).toEqual([0, 5, 10, 15, 20, 25, 30, 35, 40, 45, 50, 55]); + expect(cronTime.minute).toEqual([ + 0, 5, 10, 15, 20, 25, 30, 35, 40, 45, 50, 55, + ]); }); - it('should parse @at_ presets correctly', () => { - const atParser = new CronParser('@at_14:30'); + it("should parse @at_ presets correctly", () => { + const atParser = new CronParser("@at_14:30"); const cronTime = atParser.parse(); expect(cronTime.hour).toEqual([14]); expect(cronTime.minute).toEqual([30]); }); - it('should parse @between_ presets correctly', () => { - const betweenParser = new CronParser('@between_9_17'); + it("should parse @between_ presets correctly", () => { + const betweenParser = new CronParser("@between_9_17"); const cronTime = betweenParser.parse(); expect(cronTime.hour).toEqual([9, 10, 11, 12, 13, 14, 15, 16, 17]); }); - it('should parse @on_ presets correctly', () => { - const onParser = new CronParser('@on_monday'); + it("should parse @on_ presets correctly", () => { + const onParser = new CronParser("@on_monday"); const cronTime = onParser.parse(); - expect(cronTime.dayOfWeek).toEqual([2]); + expect(cronTime.dayOfWeek).toEqual([1]); }); - it('should handle range expressions', () => { - const rangeParser = new CronParser('1-5 * * * * *'); + it("should handle range expressions", () => { + const rangeParser = new CronParser("1-5 * * * * *"); const cronTime = rangeParser.parse(); expect(cronTime.second).toEqual([1, 2, 3, 4, 5]); }); - it('should handle step expressions', () => { - const stepParser = new CronParser('*/5 * * * * *'); + it("should handle step expressions", () => { + const stepParser = new CronParser("*/5 * * * * *"); const cronTime = stepParser.parse(); expect(cronTime.second).toContain(0); expect(cronTime.second).toContain(5); expect(cronTime.second).toContain(10); }); - it('should handle list expressions', () => { - const listParser = new CronParser('1,3,5 * * * * *'); + it("should handle list expressions", () => { + const listParser = new CronParser("1,3,5 * * * * *"); const cronTime = listParser.parse(); expect(cronTime.second).toEqual([1, 3, 5]); }); + + it("should parse @every_3_months correctly", () => { + const monthsParser = new CronParser("@every_3_months"); + const cronTime = monthsParser.parse(); + expect(cronTime.month).toEqual([1, 4, 7, 10]); + }); + + it("should parse @every_2_dayOfWeek correctly", () => { + const dowParser = new CronParser("@every_2_dayOfWeek"); + const cronTime = dowParser.parse(); + expect(cronTime.dayOfWeek).toEqual([0, 2, 4, 6]); + }); + + it("should parse @on_saturday correctly", () => { + const onSat = new CronParser("@on_saturday"); + const cronTime = onSat.parse(); + expect(cronTime.dayOfWeek).toEqual([6]); + }); }); -describe('Cron', () => { +describe("Cron", () => { let cron: Cron; beforeEach(() => { cron = new Cron({ - name: 'test', - cron: '0 0 0 1 1 *', + name: "test", + cron: "0 0 0 1 1 *", callback: jest.fn(), }); }); @@ -456,103 +476,103 @@ describe('Cron', () => { cron.destroy(); }); - it('should validate cron job name', () => { + it("should validate cron job name", () => { expect(() => { new Cron({ - name: '', - cron: '* * * * * *', + name: "", + cron: "* * * * * *", callback: jest.fn(), }); - }).toThrow('Cron job name is required and must be a string'); + }).toThrow("Cron job name is required and must be a string"); }); - it('should validate cron expressions', () => { + it("should validate cron expressions", () => { expect(() => { new Cron({ - name: 'test', - cron: '* * * * *', + name: "test", + cron: "* * * * *", callback: jest.fn(), }); - }).toThrow('Invalid cron expression'); + }).toThrow("Invalid cron expression"); }); - it('should start the cron job', () => { + it("should start the cron job", () => { cron.start(); expect(cron.isRunning()).toBeTruthy(); - expect(cron.getStatus()).toBe('running'); + expect(cron.getStatus()).toBe("running"); }); - it('should stop the cron job', () => { + it("should stop the cron job", () => { cron.start(); cron.stop(); expect(cron.isRunning()).toBeFalsy(); - expect(cron.getStatus()).toBe('stopped'); + expect(cron.getStatus()).toBe("stopped"); }); - it('should pause and resume the cron job', () => { + it("should pause and resume the cron job", () => { cron.start(); - expect(cron.getStatus()).toBe('running'); - + expect(cron.getStatus()).toBe("running"); + cron.pause(); - expect(cron.getStatus()).toBe('paused'); + expect(cron.getStatus()).toBe("paused"); expect(cron.isRunning()).toBeFalsy(); - + cron.resume(); - expect(cron.getStatus()).toBe('running'); + expect(cron.getStatus()).toBe("running"); expect(cron.isRunning()).toBeTruthy(); }); - it('should destroy the cron job', () => { + it("should destroy the cron job", () => { cron.destroy(); expect(cron.isRunning()).toBeFalsy(); - expect(cron.getStatus()).toBe('stopped'); + expect(cron.getStatus()).toBe("stopped"); }); - it('should get the status of the cron job', () => { + it("should get the status of the cron job", () => { const status = cron.getStatus(); expect(status).toBeDefined(); - expect(['running', 'stopped', 'paused', 'error']).toContain(status); + expect(["running", "stopped", "paused", "error"]).toContain(status); }); - it('should check if the cron job is running', () => { + it("should check if the cron job is running", () => { const isRunning = cron.isRunning(); - expect(typeof isRunning).toBe('boolean'); + expect(typeof isRunning).toBe("boolean"); }); - it('should get the date of the last execution of the cron job', () => { + it("should get the date of the last execution of the cron job", () => { const lastExecution = cron.lastExecution(); expect(lastExecution).toBeInstanceOf(Date); }); - it('should get the date of the next execution of the cron job', () => { + it("should get the date of the next execution of the cron job", () => { const nextExecution = cron.nextExecution(); expect(nextExecution).toBeInstanceOf(Date); }); - it('should get the remaining time until the next execution of the cron job', () => { + it("should get the remaining time until the next execution of the cron job", () => { const remaining = cron.remaining(); - expect(typeof remaining).toBe('number'); + expect(typeof remaining).toBe("number"); }); - it('should get the time until the next execution of the cron job', () => { + it("should get the time until the next execution of the cron job", () => { const time = cron.time(); - expect(typeof time).toBe('number'); + expect(typeof time).toBe("number"); }); - it('should get execution history', () => { + it("should get execution history", () => { const history = cron.getHistory(); expect(Array.isArray(history)).toBe(true); }); - it('should get metrics', () => { + it("should get metrics", () => { const metrics = cron.getMetrics(); - expect(metrics).toHaveProperty('totalExecutions'); - expect(metrics).toHaveProperty('successfulExecutions'); - expect(metrics).toHaveProperty('failedExecutions'); - expect(metrics).toHaveProperty('averageExecutionTime'); + expect(metrics).toHaveProperty("totalExecutions"); + expect(metrics).toHaveProperty("successfulExecutions"); + expect(metrics).toHaveProperty("failedExecutions"); + expect(metrics).toHaveProperty("averageExecutionTime"); }); - it('should reset metrics', () => { + it("should reset metrics", () => { cron.resetMetrics(); const metrics = cron.getMetrics(); expect(metrics.totalExecutions).toBe(0); @@ -561,52 +581,52 @@ describe('Cron', () => { expect(metrics.averageExecutionTime).toBe(0); }); - it('should validate cron expressions using static method', () => { - expect(Cron.isValid('* * * * * *')).toBe(true); - expect(Cron.isValid('@daily')).toBe(true); - expect(Cron.isValid('invalid')).toBe(false); + it("should validate cron expressions using static method", () => { + expect(Cron.isValid("* * * * * *")).toBe(true); + expect(Cron.isValid("@daily")).toBe(true); + expect(Cron.isValid("invalid")).toBe(false); }); - it('should parse cron expressions using static method', () => { - const cronTime = Cron.parse('* * * * * *'); + it("should parse cron expressions using static method", () => { + const cronTime = Cron.parse("* * * * * *"); expect(cronTime).toBeDefined(); expect(cronTime.second).toBeDefined(); }); - it('should get next execution using static method', () => { - const next = Cron.getNext('* * * * * *'); + it("should get next execution using static method", () => { + const next = Cron.getNext("* * * * * *"); expect(next).toBeInstanceOf(Date); expect(next.getTime()).toBeGreaterThan(Date.now()); }); - it('should get previous execution using static method', () => { - const previous = Cron.getPrevious('* * * * * *'); + it("should get previous execution using static method", () => { + const previous = Cron.getPrevious("* * * * * *"); expect(previous).toBeInstanceOf(Date); expect(previous.getTime()).toBeLessThan(Date.now()); }); - it('should handle async callbacks configuration', async () => { + it("should handle async callbacks configuration", async () => { let executed = false; const asyncCallback = async () => { - await new Promise(resolve => setTimeout(resolve, 1)); + await new Promise((resolve) => setTimeout(resolve, 1)); executed = true; }; const asyncCron = new Cron({ - name: 'async-test', - cron: '0 0 0 1 1 *', + name: "async-test", + cron: "0 0 0 1 1 *", callback: asyncCallback, }); expect(asyncCron.callback).toBeDefined(); - + asyncCron.destroy(); }); - it('should support priority levels', () => { + it("should support priority levels", () => { const highPriorityCron = new Cron({ - name: 'high-priority', - cron: '* * * * * *', + name: "high-priority", + cron: "* * * * * *", callback: jest.fn(), priority: 10, }); @@ -615,54 +635,56 @@ describe('Cron', () => { highPriorityCron.destroy(); }); - it('should handle configuration options', () => { - const configuredCron = new Cron({ - name: 'configured-test', - cron: '* * * * * *', - callback: jest.fn(), - maxHistory: 200, - onError: jest.fn(), - onTick: jest.fn(), - onComplete: jest.fn(), - }, - { - useCalculatedTimeouts: false, - pollingInterval: 2000, - }); + it("should handle configuration options", () => { + const configuredCron = new Cron( + { + name: "configured-test", + cron: "* * * * * *", + callback: jest.fn(), + maxHistory: 200, + onError: jest.fn(), + onTick: jest.fn(), + onComplete: jest.fn(), + }, + { + useCalculatedTimeouts: false, + pollingInterval: 2000, + } + ); - expect(configuredCron.name).toBe('configured-test'); + expect(configuredCron.name).toBe("configured-test"); expect(configuredCron.priority).toBe(0); - + configuredCron.destroy(); }); - it('should validate custom preset values', () => { + it("should validate custom preset values", () => { expect(() => { new Cron({ - name: 'test', - cron: '@every_-1_seconds', + name: "test", + cron: "@every_-1_seconds", callback: jest.fn(), }); - }).toThrow('Invalid value in custom preset'); + }).toThrow("Invalid value in custom preset"); }); - it('should validate @at_ preset time bounds', () => { + it("should validate @at_ preset time bounds", () => { expect(() => { new Cron({ - name: 'test', - cron: '@at_24:00', + name: "test", + cron: "@at_24:00", callback: jest.fn(), }); - }).toThrow('Invalid time in custom preset'); + }).toThrow("Invalid time in custom preset"); }); - it('should validate @between_ preset hour ranges', () => { + it("should validate @between_ preset hour ranges", () => { expect(() => { new Cron({ - name: 'test', - cron: '@between_18_6', + name: "test", + cron: "@between_18_6", callback: jest.fn(), }); - }).toThrow('Invalid hour range in custom preset'); + }).toThrow("Invalid hour range in custom preset"); }); -}); \ No newline at end of file +}); diff --git a/lib/index.ts b/lib/index.ts index c50f6dc..52017c9 100644 --- a/lib/index.ts +++ b/lib/index.ts @@ -1,6 +1,6 @@ -import Cron from "@/lib/cron"; -import Baker from "@/lib/baker"; -import CronParser from "@/lib/parser"; +import Cron from "./cron"; +import Baker from "./baker"; +import CronParser from "./parser"; export { type CronOptions, @@ -19,7 +19,7 @@ export { type OnDayStrType, type day, type unit, -} from "@/lib/types"; +} from "./types"; export { Cron, Baker, CronParser }; export default Baker; diff --git a/lib/parser.ts b/lib/parser.ts index be3037f..0666475 100644 --- a/lib/parser.ts +++ b/lib/parser.ts @@ -7,7 +7,7 @@ import { type EveryStrType, type ICronParser, type OnDayStrType, -} from "@/lib/types"; +} from "./types"; /** * A class that implements the `ICronParser` interface and provides methods to parse a cron expression @@ -48,9 +48,11 @@ class CronParser implements ICronParser { case "dayOfMonth": return `0 0 0 */${value} * *`; case "months": - return `0 0 0 0 */${value} *`; + // Run at midnight on the 1st day every N months + return `0 0 0 1 */${value} *`; case "dayOfWeek": - return `0 0 0 0 0 */${value}`; + // Run at midnight on every Nth day-of-week (e.g., */2 => Sun, Tue, Thu, Sat) + return `0 0 0 * * */${value}`; default: return "* * * * * *"; } @@ -71,13 +73,13 @@ class CronParser implements ICronParser { private parseOnDayStr(str: OnDayStrType): string { const [, day, _unit] = str.split("_"); const days = new Map([ - ["sunday", 1], - ["monday", 2], - ["tuesday", 3], - ["wednesday", 4], - ["thursday", 5], - ["friday", 6], - ["saturday", 7], + ["sunday", 0], + ["monday", 1], + ["tuesday", 2], + ["wednesday", 3], + ["thursday", 4], + ["friday", 5], + ["saturday", 6], ]); return `0 0 0 * * ${days.get(day)}`; } diff --git a/lib/utils.ts b/lib/utils.ts index 4e3839c..937ccf1 100644 --- a/lib/utils.ts +++ b/lib/utils.ts @@ -6,7 +6,10 @@ const resolveIfPromise = async (value: any) => * @param callback - The callback function to execute * @param onError - Optional error handler */ -const CBResolver = async (callback?: () => void, onError?: (error: Error) => void) => { +const CBResolver = async ( + callback?: () => void | Promise, + onError?: (error: Error) => void +) => { try { if (callback) { await resolveIfPromise(callback()); diff --git a/package.json b/package.json index a055f4b..c5f9357 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "cronbake", "description": "A powerful and flexible cron job manager built with TypeScript", - "module": "index.ts", + "module": "dist/index.js", "version": "0.2.0", "publishConfig": { "access": "public" @@ -41,7 +41,8 @@ "devDependencies": { "bun-types": "latest", "tsup": "^8.0.0", - "typescript": "^5.0.0" + "typescript": "^5.0.0", + "@changesets/cli": "^2.27.1" }, "license": "MIT", "keywords": [ @@ -65,7 +66,5 @@ "task schedule manager task", "task schedule manager job task" ], - "dependencies": { - "@changesets/cli": "^2.27.1" - } + "dependencies": {} } From b5e0ba4d35fefc9a6a9f46bb6a2297fb4a1b91f6 Mon Sep 17 00:00:00 2001 From: Mohamed Achaq Date: Wed, 3 Sep 2025 11:53:54 +0100 Subject: [PATCH 2/6] feat: add immediate execution and delay options for cron jobs --- lib/cron.ts | 59 ++++++++++++++++++++++++++++++++++++++++++++++- lib/index.test.ts | 31 ++++++++++++++++++++++++- lib/types.ts | 9 ++++++++ 3 files changed, 97 insertions(+), 2 deletions(-) diff --git a/lib/cron.ts b/lib/cron.ts index 108dbb0..dc0e79d 100644 --- a/lib/cron.ts +++ b/lib/cron.ts @@ -37,6 +37,8 @@ class Cron implements ICron { private maxHistory: number; private useCalculatedTimeouts: boolean; private pollingInterval: number; + private immediate: boolean; + private initialDelayMs?: number; /** * Creates a new instance of the `Cron` class. @@ -59,6 +61,8 @@ class Cron implements ICron { this.maxHistory = options.maxHistory ?? 100; this.useCalculatedTimeouts = config?.useCalculatedTimeouts ?? true; this.pollingInterval = config?.pollingInterval ?? 1000; + this.immediate = options.immediate ?? false; + this.initialDelayMs = this.parseDelay(options.delay); this.start = this.start.bind(this); this.stop = this.stop.bind(this); @@ -135,8 +139,13 @@ class Cron implements ICron { return; } this.status = "running"; - this.next = this.parser.getNext(); + if (this.immediate) { + this.scheduleImmediateFirstRun(); + return; + } + + this.next = this.parser.getNext(); if (this.useCalculatedTimeouts) { this.scheduleWithTimeout(); } else { @@ -176,6 +185,41 @@ class Cron implements ICron { }, delay); } + /** + * Schedule a one-off immediate or delayed first run, then fall back to normal scheduling + */ + private scheduleImmediateFirstRun(): void { + if (this.status !== "running") return; + const targetDelay = Math.max(0, this.initialDelayMs ?? 0); + const MAX_TIMEOUT_VALUE = 2147483647; // ~24.8 days + + if (targetDelay > MAX_TIMEOUT_VALUE) { + const targetTime = Date.now() + targetDelay; + this.interval = setInterval(async () => { + if (this.status !== "running") return; + if (Date.now() >= targetTime) { + if (this.interval) { + clearInterval(this.interval); + this.interval = null; + } + await this.executeJob(); + this.next = this.parser.getNext(); + if (this.useCalculatedTimeouts) this.scheduleWithTimeout(); + else this.scheduleWithInterval(); + } + }, Math.min(this.pollingInterval, 1000)); + return; + } + + this.timeout = setTimeout(async () => { + if (this.status !== "running") return; + await this.executeJob(); + this.next = this.parser.getNext(); + if (this.useCalculatedTimeouts) this.scheduleWithTimeout(); + else this.scheduleWithInterval(); + }, targetDelay); + } + /** * Schedules execution using traditional polling interval */ @@ -332,6 +376,19 @@ class Cron implements ICron { }; } + /** Parse a human-friendly delay string or number into milliseconds */ + private parseDelay(delay?: number | string): number | undefined { + if (delay === undefined || delay === null) return undefined; + if (typeof delay === "number") return delay >= 0 ? delay : 0; + const str = String(delay).trim().toLowerCase(); + const match = str.match(/^(\d+)\s*(ms|s|m|h|d)?$/); + if (!match) return undefined; + const value = parseInt(match[1], 10); + const unit = match[2] ?? "ms"; + const factor = unit === "ms" ? 1 : unit === "s" ? 1000 : unit === "m" ? 60000 : unit === "h" ? 3600000 : 86400000; + return value * factor; + } + /** * Creates a new cron job with the specified options. * @returns A new `ICron` object representing the cron job. diff --git a/lib/index.test.ts b/lib/index.test.ts index 833ce21..c24686e 100644 --- a/lib/index.test.ts +++ b/lib/index.test.ts @@ -360,9 +360,38 @@ describe("Baker", () => { }); expect(cron).toBeDefined(); - + persistenceBaker.destroyAll(); }); + + it("should run immediately when immediate is true", async () => { + const cb = jest.fn(); + baker.add({ + name: "immediate", + cron: "0 0 0 1 1 *", + callback: cb, + immediate: true, + }); + baker.bake("immediate"); + await new Promise((r) => setTimeout(r, 30)); + expect(cb).toHaveBeenCalled(); + }); + + it("should delay first run when immediate and delay are set", async () => { + const cb = jest.fn(); + baker.add({ + name: "delayed", + cron: "0 0 0 1 1 *", + callback: cb, + immediate: true, + delay: "50ms", + }); + baker.bake("delayed"); + await new Promise((r) => setTimeout(r, 20)); + expect(cb).not.toHaveBeenCalled(); + await new Promise((r) => setTimeout(r, 60)); + expect(cb).toHaveBeenCalledTimes(1); + }); }); describe("CronParser", () => { diff --git a/lib/types.ts b/lib/types.ts index 7740174..e0131ba 100644 --- a/lib/types.ts +++ b/lib/types.ts @@ -252,6 +252,15 @@ type CronOptions = { * Whether to persist this job across restarts */ persist?: boolean; + /** + * If true, run the first callback immediately on start (before schedule) + */ + immediate?: boolean; + /** + * If set with `immediate: true`, delay the first run by this amount. + * Accepts number (ms) or strings like '500ms', '10s', '2m', '1h'. + */ + delay?: number | string; }; type Status = 'running' | 'stopped' | 'paused' | 'error'; From 89f6937f797944419509c4363f3d9ba7c0876c0a Mon Sep 17 00:00:00 2001 From: Mohamed Achaq Date: Wed, 3 Sep 2025 12:03:35 +0100 Subject: [PATCH 3/6] feat: implement overrun protection for cron jobs to prevent overlapping executions --- lib/cron.ts | 19 ++++++++++---- lib/index.test.ts | 65 +++++++++++++++++++++++++++++++++++++++++++++++ lib/types.ts | 10 ++++++++ 3 files changed, 89 insertions(+), 5 deletions(-) diff --git a/lib/cron.ts b/lib/cron.ts index dc0e79d..9617615 100644 --- a/lib/cron.ts +++ b/lib/cron.ts @@ -37,6 +37,8 @@ class Cron implements ICron { private maxHistory: number; private useCalculatedTimeouts: boolean; private pollingInterval: number; + private overrunProtection: boolean; + private isExecuting: boolean = false; private immediate: boolean; private initialDelayMs?: number; @@ -61,6 +63,7 @@ class Cron implements ICron { this.maxHistory = options.maxHistory ?? 100; this.useCalculatedTimeouts = config?.useCalculatedTimeouts ?? true; this.pollingInterval = config?.pollingInterval ?? 1000; + this.overrunProtection = options.overrunProtection ?? true; this.immediate = options.immediate ?? false; this.initialDelayMs = this.parseDelay(options.delay); @@ -225,11 +228,13 @@ class Cron implements ICron { */ private scheduleWithInterval(): void { this.interval = setInterval(async () => { - if ( - this.next && - this.next.getTime() <= Date.now() && - this.status === "running" - ) { + if (this.overrunProtection && this.isExecuting) { + // Overrun protection: skip overlapping start + this.metrics.skippedExecutions = (this.metrics.skippedExecutions ?? 0) + 1; + return; + } + + if (this.next && this.next.getTime() <= Date.now() && this.status === "running") { await this.executeJob(); this.next = this.parser.getNext(); } @@ -243,6 +248,7 @@ class Cron implements ICron { const startTime = Date.now(); let success = true; let error: string | undefined; + this.isExecuting = true; try { await CBResolver(this.callback, this.onError); @@ -262,6 +268,9 @@ class Cron implements ICron { } } } + finally { + this.isExecuting = false; + } const duration = Date.now() - startTime; this.metrics.totalExecutions++; diff --git a/lib/index.test.ts b/lib/index.test.ts index c24686e..c5ef751 100644 --- a/lib/index.test.ts +++ b/lib/index.test.ts @@ -364,6 +364,71 @@ describe("Baker", () => { persistenceBaker.destroyAll(); }); + it("should prevent overrun when enabled (interval mode)", async () => { + const configuredBaker = new Baker({ + schedulerConfig: { + pollingInterval: 5, + useCalculatedTimeouts: false, + }, + }); + + let calls = 0; + const slow = async () => { + calls++; + await new Promise((r) => setTimeout(r, 150)); + }; + + configuredBaker.add({ + name: "ovp-on", + cron: "* * * * * *", + callback: slow, + overrunProtection: true, + }); + + // Start and wait until next second boundary passes + configuredBaker.bake("ovp-on"); + const msToNext = 1000 - (Date.now() % 1000); + await new Promise((r) => setTimeout(r, msToNext + 50)); + + // Allow some time for potential overlapping + await new Promise((r) => setTimeout(r, 80)); + expect(calls).toBeLessThanOrEqual(1); + + configuredBaker.destroyAll(); + }); + + it("should allow overrun when disabled (interval mode)", async () => { + const configuredBaker = new Baker({ + schedulerConfig: { + pollingInterval: 5, + useCalculatedTimeouts: false, + }, + }); + + let calls = 0; + const slow = async () => { + calls++; + await new Promise((r) => setTimeout(r, 150)); + }; + + configuredBaker.add({ + name: "ovp-off", + cron: "* * * * * *", + callback: slow, + overrunProtection: false, + }); + + configuredBaker.bake("ovp-off"); + const msToNext = 1000 - (Date.now() % 1000); + await new Promise((r) => setTimeout(r, msToNext + 50)); + + // Allow some time for overlapping starts + await new Promise((r) => setTimeout(r, 80)); + expect(calls).toBeGreaterThanOrEqual(2); + + configuredBaker.destroyAll(); + }); + it("should run immediately when immediate is true", async () => { const cb = jest.fn(); baker.add({ diff --git a/lib/types.ts b/lib/types.ts index e0131ba..73a3eb0 100644 --- a/lib/types.ts +++ b/lib/types.ts @@ -252,6 +252,15 @@ type CronOptions = { * Whether to persist this job across restarts */ persist?: boolean; + /** + * Enable overrun protection: if a previous execution is still running + * when the next schedule is due, skip starting a new one. + * Defaults to true for safety. + */ + overrunProtection?: boolean; + /** + * If true, run the first callback immediately on start (before schedule) + */ /** * If true, run the first callback immediately on start (before schedule) */ @@ -285,6 +294,7 @@ type JobMetrics = { averageExecutionTime: number; lastExecutionTime?: number; lastError?: string; + skippedExecutions?: number; }; /** From 3f6d3b40db8d1fd9fcb573c3ed124cdbbf89c239 Mon Sep 17 00:00:00 2001 From: Mohamed Achaq Date: Wed, 3 Sep 2025 12:20:17 +0100 Subject: [PATCH 4/6] feat: add file and Redis persistence providers for cron job state management --- lib/baker.ts | 72 ++++++++++++++++++---------------------- lib/index.test.ts | 1 + lib/index.ts | 4 ++- lib/persistence.test.ts | 47 ++++++++++++++++++++++++++ lib/persistence/file.ts | 28 ++++++++++++++++ lib/persistence/index.ts | 4 +++ lib/persistence/redis.ts | 36 ++++++++++++++++++++ lib/persistence/types.ts | 31 +++++++++++++++++ lib/test/utils.ts | 42 +++++++++++++++++++++++ lib/types.ts | 13 ++++++++ 10 files changed, 238 insertions(+), 40 deletions(-) create mode 100644 lib/persistence.test.ts create mode 100644 lib/persistence/file.ts create mode 100644 lib/persistence/index.ts create mode 100644 lib/persistence/redis.ts create mode 100644 lib/persistence/types.ts create mode 100644 lib/test/utils.ts diff --git a/lib/baker.ts b/lib/baker.ts index edd7f99..b3af0e9 100644 --- a/lib/baker.ts +++ b/lib/baker.ts @@ -1,17 +1,17 @@ import Cron from './cron'; -import { - CronOptions, - IBaker, - IBakerOptions, - ICron, - Status, - ExecutionHistory, +import { + CronOptions, + IBaker, + IBakerOptions, + ICron, + Status, + ExecutionHistory, JobMetrics, SchedulerConfig, - PersistenceOptions + PersistenceOptions, } from './types'; -import * as fs from 'fs'; -import * as path from 'path'; +import { FilePersistenceProvider } from './persistence/file'; +import type { PersistenceProvider } from './persistence/types'; /** * A class that implements the `IBaker` interface and provides methods to manage cron jobs. @@ -20,6 +20,7 @@ class Baker implements IBaker { private crons: Map = new Map(); private config: SchedulerConfig; private persistence: PersistenceOptions; + private persistenceProvider?: PersistenceProvider; private enableMetrics: boolean; private onError?: (error: Error, jobName: string) => void; @@ -34,7 +35,20 @@ class Baker implements IBaker { enabled: options.persistence?.enabled ?? false, filePath: options.persistence?.filePath ?? './cronbake-state.json', autoRestore: options.persistence?.autoRestore ?? true, + strategy: options.persistence?.strategy ?? 'file', + provider: options.persistence?.provider, + redis: options.persistence?.redis, }; + + if (this.persistence.enabled) { + if (this.persistence.provider) { + this.persistenceProvider = this.persistence.provider; + } else if (this.persistence.strategy === 'file') { + this.persistenceProvider = new FilePersistenceProvider(this.persistence.filePath!); + } else if (this.persistence.strategy === 'redis') { + throw new Error('Redis persistence selected but no provider supplied. Pass persistence.provider or use FilePersistenceProvider.'); + } + } this.enableMetrics = options.enableMetrics ?? true; this.onError = options.onError; @@ -225,58 +239,40 @@ class Baker implements IBaker { } async saveState(): Promise { - if (!this.persistence.enabled) return; - + if (!this.persistence.enabled || !this.persistenceProvider) return; try { const state = { + version: 1, timestamp: new Date().toISOString(), jobs: Array.from(this.crons.entries()).map(([name, cron]) => ({ name, - cron: cron.cron, + cron: String(cron.cron), status: cron.getStatus(), priority: cron.priority, metrics: this.enableMetrics ? cron.getMetrics() : undefined, history: this.enableMetrics ? cron.getHistory() : undefined, })), config: this.config, - }; - - const filePath = path.resolve(this.persistence.filePath!); - const dir = path.dirname(filePath); - - if (!fs.existsSync(dir)) { - fs.mkdirSync(dir, { recursive: true }); - } - - await fs.promises.writeFile(filePath, JSON.stringify(state, null, 2), 'utf8'); + } as const; + await this.persistenceProvider.save(state); } catch (error) { throw new Error(`Failed to save state: ${error}`); } } async restoreState(): Promise { - if (!this.persistence.enabled) return; - + if (!this.persistence.enabled || !this.persistenceProvider) return; try { - const filePath = path.resolve(this.persistence.filePath!); - - if (!fs.existsSync(filePath)) { - return; - } - - const data = await fs.promises.readFile(filePath, 'utf8'); - const state = JSON.parse(data); - + const state = await this.persistenceProvider.load(); + if (!state) return; if (!state.jobs || !Array.isArray(state.jobs)) { - throw new Error('Invalid state file format'); + throw new Error('Invalid state format'); } - for (const jobData of state.jobs) { if (!jobData.name || !jobData.cron) { console.warn('Skipping invalid job data:', jobData); continue; } - try { const options: CronOptions = { name: jobData.name, @@ -287,13 +283,11 @@ class Baker implements IBaker { priority: jobData.priority, start: jobData.status === 'running', }; - this.add(options); } catch (error) { console.warn(`Failed to restore job '${jobData.name}':`, error); } } - console.log(`Restored ${state.jobs.length} cron jobs from persistence`); } catch (error) { throw new Error(`Failed to restore state: ${error}`); diff --git a/lib/index.test.ts b/lib/index.test.ts index c5ef751..b5b9a1a 100644 --- a/lib/index.test.ts +++ b/lib/index.test.ts @@ -364,6 +364,7 @@ describe("Baker", () => { persistenceBaker.destroyAll(); }); + it("should prevent overrun when enabled (interval mode)", async () => { const configuredBaker = new Baker({ schedulerConfig: { diff --git a/lib/index.ts b/lib/index.ts index 52017c9..9d0679d 100644 --- a/lib/index.ts +++ b/lib/index.ts @@ -1,6 +1,8 @@ import Cron from "./cron"; import Baker from "./baker"; import CronParser from "./parser"; +import { FilePersistenceProvider } from "./persistence/file"; +import { RedisPersistenceProvider } from "./persistence/redis"; export { type CronOptions, @@ -21,5 +23,5 @@ export { type unit, } from "./types"; -export { Cron, Baker, CronParser }; +export { Cron, Baker, CronParser, FilePersistenceProvider, RedisPersistenceProvider }; export default Baker; diff --git a/lib/persistence.test.ts b/lib/persistence.test.ts new file mode 100644 index 0000000..e865730 --- /dev/null +++ b/lib/persistence.test.ts @@ -0,0 +1,47 @@ +import { describe, it, expect } from 'bun:test'; +import Baker from '@/lib/baker'; +import { createFileProvider, createRedisProvider, cleanupFile, sleep } from '@/lib/test/utils'; + +describe('Persistence Providers', () => { + it('File provider: saves and restores jobs', async () => { + const { provider, filePath } = createFileProvider(); + + const baker1 = new Baker({ + persistence: { enabled: true, autoRestore: false, strategy: 'file', provider }, + }); + + baker1.add({ name: 'file-job', cron: '* * * * * *', callback: () => {} }); + await baker1.saveState(); + + const baker2 = new Baker({ + persistence: { enabled: true, autoRestore: true, strategy: 'file', provider }, + }); + await sleep(10); + expect(baker2.getJobNames()).toContain('file-job'); + baker2.destroyAll(); + baker1.destroyAll(); + cleanupFile(filePath); + }); + + it('Redis provider: saves and restores jobs with fake client', async () => { + const { provider } = createRedisProvider('test:cronbake'); + + const baker1 = new Baker({ + persistence: { enabled: true, autoRestore: false, strategy: 'redis', provider }, + }); + baker1.add({ name: 'redis-job', cron: '* * * * * *', callback: () => {} }); + await baker1.saveState(); + + const baker2 = new Baker({ + persistence: { enabled: true, autoRestore: true, strategy: 'redis', provider }, + }); + await sleep(10); + expect(baker2.getJobNames()).toContain('redis-job'); + baker2.destroyAll(); + baker1.destroyAll(); + }); + + it('Throws if redis strategy without provider', () => { + expect(() => new Baker({ persistence: { enabled: true, strategy: 'redis' } })).toThrow(); + }); +}); diff --git a/lib/persistence/file.ts b/lib/persistence/file.ts new file mode 100644 index 0000000..79407d2 --- /dev/null +++ b/lib/persistence/file.ts @@ -0,0 +1,28 @@ +import * as fs from "fs"; +import * as path from "path"; +import { PersistedState, PersistenceProvider } from "./types"; + +class FilePersistenceProvider implements PersistenceProvider { + constructor(private filePath: string) {} + + async save(state: PersistedState): Promise { + const filePath = path.resolve(this.filePath); + const dir = path.dirname(filePath); + if (!fs.existsSync(dir)) { + fs.mkdirSync(dir, { recursive: true }); + } + await fs.promises.writeFile(filePath, JSON.stringify(state, null, 2), "utf8"); + } + + async load(): Promise { + const filePath = path.resolve(this.filePath); + if (!fs.existsSync(filePath)) return null; + const data = await fs.promises.readFile(filePath, "utf8"); + const state = JSON.parse(data); + if (!state || typeof state !== "object") return null; + return state as PersistedState; + } +} + +export { FilePersistenceProvider }; + diff --git a/lib/persistence/index.ts b/lib/persistence/index.ts new file mode 100644 index 0000000..491f221 --- /dev/null +++ b/lib/persistence/index.ts @@ -0,0 +1,4 @@ +export { type PersistedState, type PersistedJob, type PersistenceProvider, type RedisLikeClient } from "./types"; +export { FilePersistenceProvider } from "./file"; +export { RedisPersistenceProvider, type RedisProviderOptions } from "./redis"; + diff --git a/lib/persistence/redis.ts b/lib/persistence/redis.ts new file mode 100644 index 0000000..5003b66 --- /dev/null +++ b/lib/persistence/redis.ts @@ -0,0 +1,36 @@ +import { PersistedState, PersistenceProvider, RedisLikeClient } from "./types"; + +type RedisProviderOptions = { + client: RedisLikeClient; + key?: string; +}; + +class RedisPersistenceProvider implements PersistenceProvider { + private key: string; + constructor(private options: RedisProviderOptions) { + if (!options?.client) { + throw new Error("RedisPersistenceProvider requires a redis-like client with get/set"); + } + this.key = options.key ?? "cronbake:state"; + } + + async save(state: PersistedState): Promise { + await this.options.client.set(this.key, JSON.stringify(state)); + } + + async load(): Promise { + const raw = await this.options.client.get(this.key); + if (!raw) return null; + try { + const parsed = JSON.parse(raw) as PersistedState; + if (!parsed || typeof parsed !== "object") return null; + return parsed; + } catch { + return null; + } + } +} + +export { RedisPersistenceProvider }; +export type { RedisProviderOptions }; + diff --git a/lib/persistence/types.ts b/lib/persistence/types.ts new file mode 100644 index 0000000..d43f959 --- /dev/null +++ b/lib/persistence/types.ts @@ -0,0 +1,31 @@ +import { ExecutionHistory, JobMetrics, SchedulerConfig, Status } from "../types"; + +type PersistedJob = { + name: string; + cron: string; + status: Status; + priority: number; + metrics?: JobMetrics; + history?: ExecutionHistory[]; +}; + +type PersistedState = { + version: number; + timestamp: string; + config: SchedulerConfig; + jobs: PersistedJob[]; +}; + +interface PersistenceProvider { + save(state: PersistedState): Promise; + load(): Promise; +} + +/** Minimal redis-like client */ +interface RedisLikeClient { + get(key: string): Promise; + set(key: string, value: string): Promise; +} + +export type { PersistedState, PersistedJob, PersistenceProvider, RedisLikeClient }; + diff --git a/lib/test/utils.ts b/lib/test/utils.ts new file mode 100644 index 0000000..4072133 --- /dev/null +++ b/lib/test/utils.ts @@ -0,0 +1,42 @@ +import * as fs from 'fs'; +import * as path from 'path'; +import * as os from 'os'; +import { FilePersistenceProvider } from '@/lib/persistence/file'; +import { RedisPersistenceProvider } from '@/lib/persistence/redis'; +import type { RedisLikeClient } from '@/lib/persistence/types'; + +const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); + +const tmpFile = (prefix = 'cronbake-test-'): string => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), prefix)); + return path.join(dir, 'state.json'); +}; + +const cleanupFile = (filePath: string) => { + try { fs.unlinkSync(filePath); } catch {} + try { fs.rmdirSync(path.dirname(filePath)); } catch {} +}; + +class FakeRedis implements RedisLikeClient { + private store = new Map(); + async get(key: string): Promise { + return this.store.get(key) ?? null; + } + async set(key: string, value: string): Promise { + this.store.set(key, value); + } +} + +const createFileProvider = (filePath?: string) => { + const fp = filePath ?? tmpFile(); + return { provider: new FilePersistenceProvider(fp), filePath: fp }; +}; + +const createRedisProvider = (key = 'test:cronbake') => { + const client = new FakeRedis(); + const provider = new RedisPersistenceProvider({ client, key }); + return { provider, client }; +}; + +export { sleep, tmpFile, cleanupFile, FakeRedis, createFileProvider, createRedisProvider }; + diff --git a/lib/types.ts b/lib/types.ts index 73a3eb0..cd0af47 100644 --- a/lib/types.ts +++ b/lib/types.ts @@ -300,10 +300,22 @@ type JobMetrics = { /** * Persistence options for cron jobs */ +import type { PersistenceProvider } from "./persistence/types"; + +type PersistenceStrategy = 'file' | 'redis' | 'custom'; + +type RedisPersistenceOptions = { + key?: string; + // user must provide a client via provider for now; kept for future extension +}; + type PersistenceOptions = { enabled: boolean; filePath?: string; autoRestore?: boolean; + strategy?: PersistenceStrategy; + provider?: PersistenceProvider; + redis?: RedisPersistenceOptions; }; /** @@ -485,4 +497,5 @@ export { type JobMetrics, type PersistenceOptions, type SchedulerConfig, + type PersistenceStrategy, }; From 9cfc0ddc239b8a7f78e8b4b7e4cf196f08987eed Mon Sep 17 00:00:00 2001 From: Mohamed Achaq Date: Wed, 3 Sep 2025 12:27:58 +0100 Subject: [PATCH 5/6] chore: release version 0.3.0 with new features including immediate/delayed first run, overrun protection, and pluggable persistence providers --- CHANGELOG.md | 27 +++++++++++++++++++++++++++ package.json | 2 +- 2 files changed, 28 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c066a01..d8a9ab1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,32 @@ # cronbake +## 0.3.0 + +### Minor Changes + +- Immediate/delayed first run: Add `immediate` and `delay` options so the first callback can run right away or after a configurable delay (e.g., `"10s"`). +- Overrun protection: Add `overrunProtection` (default: true) to skip starting a new run if the previous execution is still running; tracks `skippedExecutions` in metrics. +- Pluggable persistence: Refactor persistence to use providers. Add `FilePersistenceProvider` (JSON on disk) and `RedisPersistenceProvider` (single-key JSON via injected client). New `persistence.strategy` and `persistence.provider` options. + +### Features + +- New Cron options: `immediate`, `delay`, `overrunProtection`. +- New metrics: `skippedExecutions`. +- New persistence API: `PersistenceProvider` with `save/load` and types for persisted state. +- Export providers from package API: `FilePersistenceProvider`, `RedisPersistenceProvider`. + +### Fixes + +- Parser: Correct `@on_` mapping to Sunday=0…Saturday=6, fix `@every__months` to run on day 1 (`0 0 0 1 */n *`), and `@every__dayOfWeek` to `*/n` on day-of-week. +- Types: Replace `Timer` with `ReturnType` for portability. +- Build: Replace `@/lib` path aliases in source with relative imports to avoid bundler resolution issues. +- Packaging: Set `module` to `dist/index.js`, move `@changesets/cli` to devDependencies, ensure Changesets access is public. + +### Tests + +- Add tests for immediate/delayed first run and overrun protection. +- Add provider-focused tests and shared test utilities for persistence (file and Redis via a fake client). + ## 0.2.0 ### Minor Changes diff --git a/package.json b/package.json index c5f9357..e1f9013 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "cronbake", "description": "A powerful and flexible cron job manager built with TypeScript", "module": "dist/index.js", - "version": "0.2.0", + "version": "0.3.0", "publishConfig": { "access": "public" }, From b4393e5bc5e7cde60b7e4e4da55fdddd2e2074f0 Mon Sep 17 00:00:00 2001 From: Mohamed Achaq Date: Wed, 3 Sep 2025 12:45:14 +0100 Subject: [PATCH 6/6] refactor: standardize import statements and enhance README with immediate/delayed first run and overrun protection examples --- README.md | 64 +++++++++++++++++++++++++++++++++++++++-- index.ts | 16 +++++++++-- lib/persistence.test.ts | 6 ++-- lib/persistence/file.ts | 30 +++++++++++++++---- 4 files changed, 103 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index 6b3c151..1ca759a 100644 --- a/README.md +++ b/README.md @@ -46,6 +46,11 @@ Cronbake provides two scheduling modes for optimal performance: - **Calculated Timeouts** (default): More efficient scheduling using precise timeout calculations - **Polling Interval**: Traditional polling-based scheduling with configurable intervals +Additional scheduling controls: + +- **Immediate/Delayed First Run**: Run the first callback immediately (`immediate: true`) or after a delay (`delay: '10s'`). +- **Overrun Protection**: Skip new starts if a previous run is still executing (`overrunProtection: true`). + #### Cron Job Management Cronbake provides a simple and intuitive interface for managing cron jobs. You can easily add, remove, start, stop, pause, resume, and destroy cron jobs using the `Baker` class. @@ -77,7 +82,7 @@ Track detailed execution history and performance metrics including: Cronbake supports job persistence across application restarts: -- Save job state to file system +- Save job state to file system or Redis (pluggable providers) - Automatic restoration on startup - Configurable persistence options @@ -156,6 +161,27 @@ const everyMinuteJob = baker.add({ baker.bakeAll(); ``` +#### Immediate/Delayed First Run and Overrun Protection + +```typescript +import Baker from 'cronbake'; + +const baker = Baker.create(); + +baker.add({ + name: 'fast-job', + cron: '@every_10_seconds', + immediate: true, // run the first time right away + delay: '2s', // but wait 2 seconds before that first run + overrunProtection: true, // skip overlaps if a previous run still executes + callback: async () => { + // Do work + }, +}); + +baker.bakeAll(); +``` + #### Advanced Configuration You can configure the Baker with advanced options: @@ -254,6 +280,35 @@ await baker.saveState(); await baker.restoreState(); ``` +#### Persistence Providers (File and Redis) + +Cronbake uses pluggable providers for persistence. A file provider is included by default. A Redis provider is available; you inject your Redis client. + +```typescript +import { Baker, FilePersistenceProvider, RedisPersistenceProvider } from 'cronbake'; + +// File-based +const bakerFile = Baker.create({ + persistence: { + enabled: true, + strategy: 'file', + provider: new FilePersistenceProvider('./cronbake-state.json'), + autoRestore: true, + }, +}); + +// Redis-based (provide your own client implementing get/set) +const redisProvider = new RedisPersistenceProvider({ client: redisClient, key: 'cronbake:state' }); +const bakerRedis = Baker.create({ + persistence: { + enabled: true, + strategy: 'redis', + provider: redisProvider, + autoRestore: true, + }, +}); +``` + ### Baker Methods | Method | Description | @@ -309,6 +364,8 @@ const job = Cron.create({ console.error('Job failed:', error.message); }, priority: 10, + immediate: false, + overrunProtection: true, }); // Start the cron job @@ -326,8 +383,11 @@ const nextExecution = job.nextExecution(); // Get metrics and history const metrics = job.getMetrics(); const history = job.getHistory(); +// Metrics include skippedExecutions when overrun protection skips overlaps ``` + + Cronbake also provides utility functions for parsing cron expressions, getting the next or previous execution times, and validating cron expressions. ```typescript @@ -370,4 +430,4 @@ Contributions are welcome! If you find any issues or have suggestions for improv ## License -Cronbake is released under the [MIT License](./LICENSE). \ No newline at end of file +Cronbake is released under the [MIT License](./LICENSE). diff --git a/index.ts b/index.ts index 6a0f524..8475e1b 100644 --- a/index.ts +++ b/index.ts @@ -1,4 +1,4 @@ -import Baker from './lib'; +import Baker from "./lib"; export { type CronOptions, @@ -17,7 +17,17 @@ export { type OnDayStrType, type day, type unit, -} from './lib'; +} from "./lib"; -export { Cron, Baker, CronParser } from './lib'; +export { Cron, Baker, CronParser } from "./lib"; export default Baker; + +export { + FilePersistenceProvider, + RedisPersistenceProvider, + PersistedJob, + PersistedState, + PersistenceProvider, + RedisLikeClient, + RedisProviderOptions, +} from "./lib/persistence"; diff --git a/lib/persistence.test.ts b/lib/persistence.test.ts index e865730..771cc56 100644 --- a/lib/persistence.test.ts +++ b/lib/persistence.test.ts @@ -18,8 +18,10 @@ describe('Persistence Providers', () => { }); await sleep(10); expect(baker2.getJobNames()).toContain('file-job'); - baker2.destroyAll(); - baker1.destroyAll(); + // Stop timers and persist final state before cleanup + baker2.stopAll(); + baker1.stopAll(); + await baker2.saveState(); cleanupFile(filePath); }); diff --git a/lib/persistence/file.ts b/lib/persistence/file.ts index 79407d2..c09ee34 100644 --- a/lib/persistence/file.ts +++ b/lib/persistence/file.ts @@ -3,26 +3,44 @@ import * as path from "path"; import { PersistedState, PersistenceProvider } from "./types"; class FilePersistenceProvider implements PersistenceProvider { + private queue: Promise = Promise.resolve(); + constructor(private filePath: string) {} async save(state: PersistedState): Promise { const filePath = path.resolve(this.filePath); const dir = path.dirname(filePath); + const tmp = `${filePath}.tmp`; + if (!fs.existsSync(dir)) { fs.mkdirSync(dir, { recursive: true }); } - await fs.promises.writeFile(filePath, JSON.stringify(state, null, 2), "utf8"); + + const write = async () => { + const data = JSON.stringify(state, null, 2); + // Atomic write: write to temp, then rename over destination + await fs.promises.writeFile(tmp, data, "utf8"); + await fs.promises.rename(tmp, filePath); + }; + + // Serialize writes to avoid interleaving + this.queue = this.queue.then(write, write); + return this.queue; } async load(): Promise { const filePath = path.resolve(this.filePath); if (!fs.existsSync(filePath)) return null; - const data = await fs.promises.readFile(filePath, "utf8"); - const state = JSON.parse(data); - if (!state || typeof state !== "object") return null; - return state as PersistedState; + try { + const data = await fs.promises.readFile(filePath, "utf8"); + const state = JSON.parse(data); + if (!state || typeof state !== "object") return null; + return state as PersistedState; + } catch { + // If file is mid-write or corrupted, return null so caller can retry later + return null; + } } } export { FilePersistenceProvider }; -