├── bun.lockb ├── .changeset ├── config.json └── README.md ├── lib ├── persistence │ ├── index.ts │ ├── types.ts │ ├── redis.ts │ └── file.ts ├── index.ts ├── utils.ts ├── test │ └── utils.ts ├── logger.test.ts ├── persistence.test.ts ├── parser.ts ├── baker.ts ├── types.ts ├── cron.ts └── index.test.ts ├── tsconfig.json ├── .github └── workflows │ ├── ci.yml │ └── release.yml ├── index.ts ├── examples ├── basic.ts ├── README.md ├── persistence.ts └── logging.ts ├── LICENSE ├── package.json ├── .gitignore ├── CHANGELOG.md └── README.md /bun.lockb: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chaqchase/cronbake/HEAD/bun.lockb -------------------------------------------------------------------------------- /.changeset/config.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://unpkg.com/@changesets/config@3.0.0/schema.json", 3 | "changelog": "@changesets/cli/changelog", 4 | "commit": false, 5 | "fixed": [], 6 | "linked": [], 7 | "access": "public", 8 | "baseBranch": "main", 9 | "updateInternalDependencies": "patch", 10 | "ignore": [] 11 | } 12 | -------------------------------------------------------------------------------- /lib/persistence/index.ts: -------------------------------------------------------------------------------- 1 | export { 2 | type PersistedState, 3 | type PersistedJob, 4 | type PersistedExecutionHistory, 5 | type PersistenceProvider, 6 | type RedisLikeClient, 7 | } from "./types"; 8 | export { FilePersistenceProvider } from "./file"; 9 | export { RedisPersistenceProvider, type RedisProviderOptions } from "./redis"; 10 | 11 | -------------------------------------------------------------------------------- /.changeset/README.md: -------------------------------------------------------------------------------- 1 | # Changesets 2 | 3 | Hello and welcome! This folder has been automatically generated by `@changesets/cli`, a build tool that works 4 | with multi-package repos, or single-package repos to help you version and publish your code. You can 5 | find the full documentation for it [in our repository](https://github.com/changesets/changesets) 6 | 7 | We have a quick list of common questions to get you started engaging with this project in 8 | [our documentation](https://github.com/changesets/changesets/blob/main/docs/common-questions.md) 9 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "lib": ["ESNext"], 4 | "module": "esnext", 5 | "target": "esnext", 6 | "moduleResolution": "Node", 7 | "moduleDetection": "force", 8 | "strict": true, 9 | "downlevelIteration": true, 10 | "skipLibCheck": true, 11 | "jsx": "preserve", 12 | "allowSyntheticDefaultImports": true, 13 | "forceConsistentCasingInFileNames": true, 14 | "allowJs": true, 15 | "noEmit": true, 16 | "types": [ 17 | "bun-types" // add Bun global 18 | ], 19 | "baseUrl": ".", 20 | "paths": { 21 | "@/*": ["./*"], 22 | } 23 | }, 24 | } 25 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: ['main'] 6 | pull_request: 7 | types: [opened, synchronize] 8 | 9 | jobs: 10 | test: 11 | name: Test Bun 12 | runs-on: ubuntu-latest 13 | 14 | steps: 15 | - uses: actions/checkout@v2 16 | 17 | - name: Install Bun 18 | run: | 19 | curl -fsSL https://bun.sh/install | bash 20 | echo "$HOME/.bun/bin" >> $GITHUB_PATH 21 | 22 | - name: Install Dependencies 23 | run: bun install 24 | 25 | - name: Build 26 | run: bun run build 27 | 28 | - name: Run Test 29 | run: bun test -------------------------------------------------------------------------------- /lib/index.ts: -------------------------------------------------------------------------------- 1 | import Cron from "./cron"; 2 | import Baker from "./baker"; 3 | import CronParser from "./parser"; 4 | import { FilePersistenceProvider } from "./persistence/file"; 5 | import { RedisPersistenceProvider } from "./persistence/redis"; 6 | 7 | export { 8 | type CronOptions, 9 | type CronTime, 10 | type ICron, 11 | type IBaker, 12 | type IBakerOptions, 13 | type ICronParser, 14 | type Status, 15 | type AtHourStrType, 16 | type BetweenStrType, 17 | type CronExpression, 18 | type CronExpressionType, 19 | type CronExprs, 20 | type EveryStrType, 21 | type OnDayStrType, 22 | type day, 23 | type unit, 24 | type Logger, 25 | } from "./types"; 26 | 27 | export { Cron, Baker, CronParser, FilePersistenceProvider, RedisPersistenceProvider }; 28 | export default Baker; 29 | -------------------------------------------------------------------------------- /index.ts: -------------------------------------------------------------------------------- 1 | import Baker from "./lib"; 2 | 3 | export { 4 | type CronOptions, 5 | type CronTime, 6 | type ICron, 7 | type IBaker, 8 | type IBakerOptions, 9 | type ICronParser, 10 | type Status, 11 | type AtHourStrType, 12 | type BetweenStrType, 13 | type CronExpression, 14 | type CronExpressionType, 15 | type CronExprs, 16 | type EveryStrType, 17 | type OnDayStrType, 18 | type day, 19 | type unit, 20 | type Logger, 21 | } from "./lib"; 22 | 23 | export { Cron, Baker, CronParser } from "./lib"; 24 | export default Baker; 25 | 26 | export { 27 | FilePersistenceProvider, 28 | RedisPersistenceProvider, 29 | PersistedJob, 30 | PersistedState, 31 | PersistedExecutionHistory, 32 | PersistenceProvider, 33 | RedisLikeClient, 34 | RedisProviderOptions, 35 | } from "./lib/persistence"; 36 | -------------------------------------------------------------------------------- /lib/utils.ts: -------------------------------------------------------------------------------- 1 | import { Logger } from "./types"; 2 | 3 | const resolveIfPromise = async (value: any) => 4 | value instanceof Promise ? await value : value; 5 | 6 | /** 7 | * Resolves a callback function, handling both sync and async functions 8 | * @param callback - The callback function to execute 9 | * @param onError - Optional error handler 10 | * @param logger - Optional logger 11 | */ 12 | const CBResolver = async ( 13 | callback?: () => void | Promise, 14 | onError?: (error: Error) => void, 15 | logger: Logger = console 16 | ) => { 17 | try { 18 | if (callback) { 19 | await resolveIfPromise(callback()); 20 | } 21 | } catch (error) { 22 | if (onError) { 23 | onError(error as Error); 24 | } else { 25 | logger.warn('Callback execution failed:', error); 26 | } 27 | } 28 | }; 29 | 30 | export { resolveIfPromise, CBResolver }; 31 | -------------------------------------------------------------------------------- /examples/basic.ts: -------------------------------------------------------------------------------- 1 | import Baker from "../lib/index"; 2 | 3 | async function main() { 4 | const baker = Baker.create({ 5 | autoStart: true, 6 | }); 7 | 8 | baker.add({ 9 | name: "log-every-minute", 10 | cron: "@every_minute", 11 | callback: () => { 12 | console.log(`[${new Date().toISOString()}] Hello from Cronbake!`); 13 | }, 14 | }); 15 | 16 | baker.add({ 17 | name: "at-noon", 18 | cron: "0 0 12 * * *", 19 | callback: () => { 20 | console.log("It's noon somewhere! Time to stretch."); 21 | }, 22 | }); 23 | 24 | const shutdown = () => { 25 | console.log("Stopping all jobs..."); 26 | baker.stopAll(); 27 | process.exit(0); 28 | }; 29 | 30 | process.once("SIGINT", shutdown); 31 | process.once("SIGTERM", shutdown); 32 | } 33 | 34 | main().catch((error) => { 35 | console.error("Example failed:", error); 36 | process.exit(1); 37 | }); 38 | -------------------------------------------------------------------------------- /lib/persistence/types.ts: -------------------------------------------------------------------------------- 1 | import { JobMetrics, SchedulerConfig, Status } from "../types"; 2 | 3 | type PersistedExecutionHistory = { 4 | timestamp: string; 5 | duration: number; 6 | success: boolean; 7 | error?: string; 8 | }; 9 | 10 | type PersistedJob = { 11 | name: string; 12 | cron: string; 13 | status: Status; 14 | priority: number; 15 | metrics?: JobMetrics; 16 | history?: PersistedExecutionHistory[]; 17 | persist?: boolean; 18 | }; 19 | 20 | type PersistedState = { 21 | version: number; 22 | timestamp: string; 23 | config: SchedulerConfig; 24 | jobs: PersistedJob[]; 25 | }; 26 | 27 | interface PersistenceProvider { 28 | save(state: PersistedState): Promise; 29 | load(): Promise; 30 | } 31 | 32 | /** Minimal redis-like client */ 33 | interface RedisLikeClient { 34 | get(key: string): Promise; 35 | set(key: string, value: string): Promise; 36 | } 37 | 38 | export type { 39 | PersistedState, 40 | PersistedJob, 41 | PersistedExecutionHistory, 42 | PersistenceProvider, 43 | RedisLikeClient, 44 | }; 45 | 46 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Mohamed Achaq 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | 8 | concurrency: 9 | ${{ github.workflow }}-${{ github.ref }} 10 | 11 | jobs: 12 | release: 13 | name: Release 14 | runs-on: ubuntu-latest 15 | 16 | steps: 17 | - name: Checkout Repo 18 | uses: actions/checkout@v2 19 | 20 | - name: Install Bun 21 | run: | 22 | curl -fsSL https://bun.sh/install | bash 23 | echo "$HOME/.bun/bin" >> $GITHUB_PATH 24 | 25 | - name: Install Dependencies 26 | run: bun install 27 | 28 | - name: Build 29 | run: bun run build 30 | 31 | - name: Test Package 32 | run: bun test 33 | 34 | - name: Create Release PR or Publish Packages 35 | id: changesets 36 | uses: changesets/action@v1 37 | with: 38 | publish: bun run release 39 | version: bun run version 40 | commit: 'chore: update package versions' 41 | title: 'chore: update package versions' 42 | env: 43 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 44 | NPM_TOKEN: ${{ secrets.NPM_TOKEN }} -------------------------------------------------------------------------------- /examples/README.md: -------------------------------------------------------------------------------- 1 | # Cronbake Examples 2 | 3 | These TypeScript scripts demonstrate common Cronbake setups. They live outside the published bundle (`package.json` only ships the `dist/` folder), so they are safe to keep in the repo. 4 | 5 | ## Prerequisites 6 | 7 | From the project root: 8 | 9 | ```bash 10 | bun install 11 | ``` 12 | 13 | ## Run the stateless example 14 | 15 | ```bash 16 | bun run examples/basic.ts 17 | ``` 18 | 19 | This spawns two in-memory jobs and listens for Ctrl+C to stop them. 20 | 21 | ## Run the persistence example 22 | 23 | ```bash 24 | bun run examples/persistence.ts 25 | ``` 26 | 27 | The script writes state to `./.cronbake-state.json`, demonstrating `persist: true` vs. `persist: false` jobs and `await baker.ready()`. 28 | 29 | ## Run the logging example 30 | 31 | ```bash 32 | bun run examples/logging.ts 33 | ``` 34 | 35 | This demonstrates custom logger support: 36 | 37 | - Creating a custom logger with colored output 38 | - Using a global logger for all jobs via Baker 39 | - Overriding the logger per job 40 | - Error logging when jobs fail 41 | 42 | Feel free to copy these files or adapt them into your own app bootstrap logic. 43 | -------------------------------------------------------------------------------- /lib/persistence/redis.ts: -------------------------------------------------------------------------------- 1 | import { PersistedState, PersistenceProvider, RedisLikeClient } from "./types"; 2 | 3 | type RedisProviderOptions = { 4 | client: RedisLikeClient; 5 | key?: string; 6 | }; 7 | 8 | class RedisPersistenceProvider implements PersistenceProvider { 9 | private key: string; 10 | constructor(private options: RedisProviderOptions) { 11 | if (!options?.client) { 12 | throw new Error("RedisPersistenceProvider requires a redis-like client with get/set"); 13 | } 14 | this.key = options.key ?? "cronbake:state"; 15 | } 16 | 17 | async save(state: PersistedState): Promise { 18 | await this.options.client.set(this.key, JSON.stringify(state)); 19 | } 20 | 21 | async load(): Promise { 22 | const raw = await this.options.client.get(this.key); 23 | if (!raw) return null; 24 | try { 25 | const parsed = JSON.parse(raw) as PersistedState; 26 | if (!parsed || typeof parsed !== "object") return null; 27 | return parsed; 28 | } catch { 29 | return null; 30 | } 31 | } 32 | } 33 | 34 | export { RedisPersistenceProvider }; 35 | export type { RedisProviderOptions }; 36 | 37 | -------------------------------------------------------------------------------- /examples/persistence.ts: -------------------------------------------------------------------------------- 1 | import Baker from "../lib/index"; 2 | import { FilePersistenceProvider } from "../lib/persistence/index"; 3 | 4 | async function main() { 5 | const baker = Baker.create({ 6 | autoStart: true, 7 | persistence: { 8 | enabled: true, 9 | strategy: "file", 10 | provider: new FilePersistenceProvider("./.cronbake-state.json"), 11 | autoRestore: true, 12 | }, 13 | }); 14 | 15 | await baker.ready(); 16 | 17 | baker.add({ 18 | name: "daily-report", 19 | cron: "@daily", 20 | persist: true, 21 | callback: () => { 22 | console.log(`[${new Date().toISOString()}] Generating daily summary...`); 23 | }, 24 | }); 25 | 26 | baker.add({ 27 | name: "ephemeral-debug", 28 | cron: "@every_10_seconds", 29 | persist: false, 30 | callback: () => { 31 | console.log("Debug job ran – this will not be restored after restart."); 32 | }, 33 | }); 34 | 35 | const shutdown = async () => { 36 | console.log("Stopping all jobs and saving state..."); 37 | baker.stopAll(); 38 | await baker.saveState(); 39 | process.exit(0); 40 | }; 41 | 42 | process.once("SIGINT", shutdown); 43 | process.once("SIGTERM", shutdown); 44 | } 45 | 46 | main().catch((error) => { 47 | console.error("Persistence example failed:", error); 48 | process.exit(1); 49 | }); 50 | -------------------------------------------------------------------------------- /lib/test/utils.ts: -------------------------------------------------------------------------------- 1 | import * as fs from 'fs'; 2 | import * as path from 'path'; 3 | import * as os from 'os'; 4 | import { FilePersistenceProvider } from '@/lib/persistence/file'; 5 | import { RedisPersistenceProvider } from '@/lib/persistence/redis'; 6 | import type { RedisLikeClient } from '@/lib/persistence/types'; 7 | 8 | const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); 9 | 10 | const tmpFile = (prefix = 'cronbake-test-'): string => { 11 | const dir = fs.mkdtempSync(path.join(os.tmpdir(), prefix)); 12 | return path.join(dir, 'state.json'); 13 | }; 14 | 15 | const cleanupFile = (filePath: string) => { 16 | try { fs.unlinkSync(filePath); } catch {} 17 | try { fs.rmdirSync(path.dirname(filePath)); } catch {} 18 | }; 19 | 20 | class FakeRedis implements RedisLikeClient { 21 | private store = new Map(); 22 | async get(key: string): Promise { 23 | return this.store.get(key) ?? null; 24 | } 25 | async set(key: string, value: string): Promise { 26 | this.store.set(key, value); 27 | } 28 | } 29 | 30 | const createFileProvider = (filePath?: string) => { 31 | const fp = filePath ?? tmpFile(); 32 | return { provider: new FilePersistenceProvider(fp), filePath: fp }; 33 | }; 34 | 35 | const createRedisProvider = (key = 'test:cronbake') => { 36 | const client = new FakeRedis(); 37 | const provider = new RedisPersistenceProvider({ client, key }); 38 | return { provider, client }; 39 | }; 40 | 41 | export { sleep, tmpFile, cleanupFile, FakeRedis, createFileProvider, createRedisProvider }; 42 | 43 | -------------------------------------------------------------------------------- /lib/persistence/file.ts: -------------------------------------------------------------------------------- 1 | import * as fs from "fs"; 2 | import * as path from "path"; 3 | import { PersistedState, PersistenceProvider } from "./types"; 4 | 5 | class FilePersistenceProvider implements PersistenceProvider { 6 | private queue: Promise = Promise.resolve(); 7 | 8 | constructor(private filePath: string) {} 9 | 10 | async save(state: PersistedState): Promise { 11 | const filePath = path.resolve(this.filePath); 12 | const dir = path.dirname(filePath); 13 | const tmp = `${filePath}.tmp`; 14 | 15 | if (!fs.existsSync(dir)) { 16 | fs.mkdirSync(dir, { recursive: true }); 17 | } 18 | 19 | const write = async () => { 20 | const data = JSON.stringify(state, null, 2); 21 | // Atomic write: write to temp, then rename over destination 22 | await fs.promises.writeFile(tmp, data, "utf8"); 23 | await fs.promises.rename(tmp, filePath); 24 | }; 25 | 26 | // Serialize writes to avoid interleaving 27 | this.queue = this.queue.then(write, write); 28 | return this.queue; 29 | } 30 | 31 | async load(): Promise { 32 | const filePath = path.resolve(this.filePath); 33 | if (!fs.existsSync(filePath)) return null; 34 | try { 35 | const data = await fs.promises.readFile(filePath, "utf8"); 36 | const state = JSON.parse(data); 37 | if (!state || typeof state !== "object") return null; 38 | return state as PersistedState; 39 | } catch { 40 | // If file is mid-write or corrupted, return null so caller can retry later 41 | return null; 42 | } 43 | } 44 | } 45 | 46 | export { FilePersistenceProvider }; 47 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "cronbake", 3 | "description": "A powerful and flexible cron job manager built with TypeScript", 4 | "module": "dist/index.js", 5 | "version": "0.4.0", 6 | "publishConfig": { 7 | "access": "public" 8 | }, 9 | "repository": { 10 | "type": "git", 11 | "url": "https://github.com/chaqchase/cronbake.git" 12 | }, 13 | "bugs": { 14 | "url": "https://github.com/chaqchase/cronbake/issues" 15 | }, 16 | "homepage": "https://github.com/chaqchase/cronbake", 17 | "main": "dist/index.js", 18 | "author": { 19 | "name": "Mohamed Achaq", 20 | "email": "hi@achaq.dev", 21 | "url": "https://achaq.dev" 22 | }, 23 | "type": "module", 24 | "exports": { 25 | ".": { 26 | "types": "./dist/index.d.ts", 27 | "import": "./dist/index.js", 28 | "require": "./dist/index.cjs" 29 | } 30 | }, 31 | "files": [ 32 | "dist" 33 | ], 34 | "scripts": { 35 | "check": "tsc", 36 | "build": "rm -rf dist && tsup lib/index.ts --format cjs,esm --dts", 37 | "test": "bun test", 38 | "version": "changeset version", 39 | "release": "changeset publish" 40 | }, 41 | "devDependencies": { 42 | "bun-types": "latest", 43 | "tsup": "^8.0.0", 44 | "typescript": "^5.0.0", 45 | "@changesets/cli": "^2.27.1" 46 | }, 47 | "license": "MIT", 48 | "keywords": [ 49 | "cron", 50 | "cron job", 51 | "cron job manager", 52 | "cronbake", 53 | "bake", 54 | "schedule", 55 | "schedule job", 56 | "schedule manager", 57 | "schedule task", 58 | "task", 59 | "task manager", 60 | "task scheduler", 61 | "task schedule", 62 | "task schedule manager", 63 | "task schedule job", 64 | "task schedule task", 65 | "task schedule manager job", 66 | "task schedule manager task", 67 | "task schedule manager job task" 68 | ], 69 | "dependencies": {} 70 | } 71 | -------------------------------------------------------------------------------- /lib/logger.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, it, expect, jest } from "bun:test"; 2 | import Baker from "./baker"; 3 | import Cron from "./cron"; 4 | import { Logger } from "./types"; 5 | 6 | describe("Custom Logger", () => { 7 | it("should use custom logger in Cron when passed via Baker", async () => { 8 | const mockLogger: Logger = { 9 | info: jest.fn(), 10 | error: jest.fn(), 11 | warn: jest.fn(), 12 | debug: jest.fn(), 13 | }; 14 | 15 | const baker = new Baker({ 16 | logger: mockLogger, 17 | }); 18 | 19 | const cron = baker.add({ 20 | name: "test-cron-logger", 21 | cron: "* * * * * *", 22 | callback: () => { 23 | throw new Error("Test error"); 24 | }, 25 | start: true, 26 | immediate: true, 27 | }); 28 | 29 | await new Promise((r) => setTimeout(r, 30)); 30 | 31 | cron.stop(); 32 | baker.destroyAll(); 33 | 34 | // Should have logged a warning 35 | expect(mockLogger.warn).toHaveBeenCalled(); 36 | }); 37 | 38 | it("should use custom logger in Cron when passed directly", async () => { 39 | const mockLogger: Logger = { 40 | info: jest.fn(), 41 | error: jest.fn(), 42 | warn: jest.fn(), 43 | debug: jest.fn(), 44 | }; 45 | 46 | const cron = new Cron({ 47 | name: "test-cron-direct-logger", 48 | cron: "* * * * * *", 49 | callback: () => { 50 | throw new Error("Test error direct"); 51 | }, 52 | logger: mockLogger, 53 | start: true, 54 | immediate: true, 55 | }); 56 | 57 | await new Promise((r) => setTimeout(r, 30)); 58 | 59 | cron.stop(); 60 | cron.destroy(); 61 | 62 | expect(mockLogger.warn).toHaveBeenCalled(); 63 | }); 64 | 65 | it("should allow overriding logger per job in Baker", async () => { 66 | const bakerLogger: Logger = { 67 | info: jest.fn(), 68 | error: jest.fn(), 69 | warn: jest.fn(), 70 | debug: jest.fn(), 71 | }; 72 | 73 | const jobLogger: Logger = { 74 | info: jest.fn(), 75 | error: jest.fn(), 76 | warn: jest.fn(), 77 | debug: jest.fn(), 78 | }; 79 | 80 | const baker = new Baker({ 81 | logger: bakerLogger, 82 | }); 83 | 84 | const cron = baker.add({ 85 | name: "test-override-logger", 86 | cron: "* * * * * *", 87 | callback: () => { 88 | throw new Error("Test error override"); 89 | }, 90 | logger: jobLogger, 91 | start: true, 92 | immediate: true, 93 | }); 94 | 95 | await new Promise((r) => setTimeout(r, 30)); 96 | 97 | cron.stop(); 98 | baker.destroyAll(); 99 | 100 | expect(jobLogger.warn).toHaveBeenCalled(); 101 | expect(bakerLogger.warn).not.toHaveBeenCalled(); 102 | }); 103 | }); 104 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Based on https://raw.githubusercontent.com/github/gitignore/main/Node.gitignore 2 | 3 | # Logs 4 | 5 | logs 6 | _.log 7 | npm-debug.log_ 8 | yarn-debug.log* 9 | yarn-error.log* 10 | lerna-debug.log* 11 | .pnpm-debug.log* 12 | 13 | # Diagnostic reports (https://nodejs.org/api/report.html) 14 | 15 | report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json 16 | 17 | # Runtime data 18 | 19 | pids 20 | _.pid 21 | _.seed 22 | \*.pid.lock 23 | 24 | # Directory for instrumented libs generated by jscoverage/JSCover 25 | 26 | lib-cov 27 | 28 | # Coverage directory used by tools like istanbul 29 | 30 | coverage 31 | \*.lcov 32 | 33 | # nyc test coverage 34 | 35 | .nyc_output 36 | 37 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 38 | 39 | .grunt 40 | 41 | # Bower dependency directory (https://bower.io/) 42 | 43 | bower_components 44 | 45 | # node-waf configuration 46 | 47 | .lock-wscript 48 | 49 | # Compiled binary addons (https://nodejs.org/api/addons.html) 50 | 51 | build/Release 52 | 53 | # Dependency directories 54 | 55 | node_modules/ 56 | jspm_packages/ 57 | 58 | # Snowpack dependency directory (https://snowpack.dev/) 59 | 60 | web_modules/ 61 | 62 | # TypeScript cache 63 | 64 | \*.tsbuildinfo 65 | 66 | # Optional npm cache directory 67 | 68 | .npm 69 | 70 | # Optional eslint cache 71 | 72 | .eslintcache 73 | 74 | # Optional stylelint cache 75 | 76 | .stylelintcache 77 | 78 | # Microbundle cache 79 | 80 | .rpt2_cache/ 81 | .rts2_cache_cjs/ 82 | .rts2_cache_es/ 83 | .rts2_cache_umd/ 84 | 85 | # Optional REPL history 86 | 87 | .node_repl_history 88 | 89 | # Output of 'npm pack' 90 | 91 | \*.tgz 92 | 93 | # Yarn Integrity file 94 | 95 | .yarn-integrity 96 | 97 | # dotenv environment variable files 98 | 99 | .env 100 | .env.development.local 101 | .env.test.local 102 | .env.production.local 103 | .env.local 104 | 105 | # parcel-bundler cache (https://parceljs.org/) 106 | 107 | .cache 108 | .parcel-cache 109 | 110 | # Next.js build output 111 | 112 | .next 113 | out 114 | 115 | # Nuxt.js build / generate output 116 | 117 | .nuxt 118 | dist 119 | 120 | # Gatsby files 121 | 122 | .cache/ 123 | 124 | # Comment in the public line in if your project uses Gatsby and not Next.js 125 | 126 | # https://nextjs.org/blog/next-9-1#public-directory-support 127 | 128 | # public 129 | 130 | # vuepress build output 131 | 132 | .vuepress/dist 133 | 134 | # vuepress v2.x temp and cache directory 135 | 136 | .temp 137 | .cache 138 | 139 | # Docusaurus cache and generated files 140 | 141 | .docusaurus 142 | 143 | # Serverless directories 144 | 145 | .serverless/ 146 | 147 | # FuseBox cache 148 | 149 | .fusebox/ 150 | 151 | # DynamoDB Local files 152 | 153 | .dynamodb/ 154 | 155 | # TernJS port file 156 | 157 | .tern-port 158 | 159 | # Stores VSCode versions used for testing VSCode extensions 160 | 161 | .vscode-test 162 | 163 | # yarn v2 164 | 165 | .yarn/cache 166 | .yarn/unplugged 167 | .yarn/build-state.yml 168 | .yarn/install-state.gz 169 | .pnp.\* 170 | 171 | node_modules 172 | dist 173 | .DS_Store 174 | 175 | # Example persistence state files 176 | examples/.cronbake-state.json 177 | examples/cronbake-state.json 178 | *.cronbake-state.json -------------------------------------------------------------------------------- /examples/logging.ts: -------------------------------------------------------------------------------- 1 | import Baker, { Logger } from "../lib/index"; 2 | 3 | /** 4 | * Custom logger with colored output and timestamps. 5 | * You can replace this with any logger that implements the Logger interface, 6 | * such as pino, winston, bunyan, or logtape. 7 | */ 8 | const createColoredLogger = (prefix: string): Logger => { 9 | const colors = { 10 | reset: "\x1b[0m", 11 | dim: "\x1b[2m", 12 | blue: "\x1b[34m", 13 | yellow: "\x1b[33m", 14 | red: "\x1b[31m", 15 | cyan: "\x1b[36m", 16 | }; 17 | 18 | const timestamp = () => new Date().toISOString(); 19 | 20 | return { 21 | info: (message, ...args) => { 22 | console.log( 23 | `${colors.dim}${timestamp()}${colors.reset} ${colors.blue}[INFO]${ 24 | colors.reset 25 | } ${colors.cyan}[${prefix}]${colors.reset}`, 26 | message, 27 | ...args 28 | ); 29 | }, 30 | warn: (message, ...args) => { 31 | console.warn( 32 | `${colors.dim}${timestamp()}${colors.reset} ${colors.yellow}[WARN]${ 33 | colors.reset 34 | } ${colors.cyan}[${prefix}]${colors.reset}`, 35 | message, 36 | ...args 37 | ); 38 | }, 39 | error: (message, ...args) => { 40 | console.error( 41 | `${colors.dim}${timestamp()}${colors.reset} ${colors.red}[ERROR]${ 42 | colors.reset 43 | } ${colors.cyan}[${prefix}]${colors.reset}`, 44 | message, 45 | ...args 46 | ); 47 | }, 48 | debug: (message, ...args) => { 49 | console.debug( 50 | `${colors.dim}${timestamp()}${colors.reset} ${colors.dim}[DEBUG]${ 51 | colors.reset 52 | } ${colors.cyan}[${prefix}]${colors.reset}`, 53 | message, 54 | ...args 55 | ); 56 | }, 57 | }; 58 | }; 59 | 60 | async function main() { 61 | // Create a custom logger for the Baker instance 62 | const bakerLogger = createColoredLogger("cronbake"); 63 | 64 | const baker = Baker.create({ 65 | autoStart: true, 66 | logger: bakerLogger, 67 | }); 68 | 69 | // This job uses the Baker's logger (bakerLogger) 70 | baker.add({ 71 | name: "heartbeat", 72 | cron: "@every_5_seconds", 73 | immediate: true, 74 | callback: () => { 75 | console.log("💓 Heartbeat job executed successfully"); 76 | }, 77 | }); 78 | 79 | // This job has its own dedicated logger 80 | const paymentLogger = createColoredLogger("payments"); 81 | baker.add({ 82 | name: "process-payments", 83 | cron: "@every_10_seconds", 84 | logger: paymentLogger, // Override with job-specific logger 85 | immediate: true, 86 | callback: () => { 87 | console.log("💰 Processing payments..."); 88 | }, 89 | }); 90 | 91 | // This job will fail, demonstrating error logging 92 | baker.add({ 93 | name: "flaky-job", 94 | cron: "@every_15_seconds", 95 | immediate: true, 96 | delay: "2s", 97 | callback: () => { 98 | // Simulate a random failure 99 | if (Math.random() > 0.5) { 100 | throw new Error("Random failure occurred!"); 101 | } 102 | console.log("🎲 Flaky job succeeded this time!"); 103 | }, 104 | }); 105 | 106 | console.log("\n🚀 Logging example started!"); 107 | console.log(" - heartbeat: runs every 5 seconds (uses Baker logger)"); 108 | console.log( 109 | " - process-payments: runs every 10 seconds (uses custom payments logger)" 110 | ); 111 | console.log( 112 | " - flaky-job: runs every 15 seconds, fails randomly to demo error logging" 113 | ); 114 | console.log("\nPress Ctrl+C to stop.\n"); 115 | 116 | const shutdown = () => { 117 | console.log("\n👋 Stopping all jobs..."); 118 | baker.destroyAll(); 119 | process.exit(0); 120 | }; 121 | 122 | process.once("SIGINT", shutdown); 123 | process.once("SIGTERM", shutdown); 124 | } 125 | 126 | main().catch((error) => { 127 | console.error("Logging example failed:", error); 128 | process.exit(1); 129 | }); 130 | -------------------------------------------------------------------------------- /lib/persistence.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, it, expect } from 'bun:test'; 2 | import Baker from '@/lib/baker'; 3 | import { createFileProvider, createRedisProvider, cleanupFile, sleep } from '@/lib/test/utils'; 4 | 5 | describe('Persistence Providers', () => { 6 | it('File provider: saves and restores jobs', async () => { 7 | const { provider, filePath } = createFileProvider(); 8 | 9 | const baker1 = new Baker({ 10 | persistence: { enabled: true, autoRestore: false, strategy: 'file', provider }, 11 | }); 12 | 13 | baker1.add({ name: 'file-job', cron: '* * * * * *', callback: () => {} }); 14 | await baker1.saveState(); 15 | 16 | const baker2 = new Baker({ 17 | persistence: { enabled: true, autoRestore: true, strategy: 'file', provider }, 18 | }); 19 | await baker2.ready(); 20 | expect(baker2.getJobNames()).toContain('file-job'); 21 | // Stop timers and persist final state before cleanup 22 | baker2.stopAll(); 23 | baker1.stopAll(); 24 | await baker2.saveState(); 25 | cleanupFile(filePath); 26 | }); 27 | 28 | it('Redis provider: saves and restores jobs with fake client', async () => { 29 | const { provider } = createRedisProvider('test:cronbake'); 30 | 31 | const baker1 = new Baker({ 32 | persistence: { enabled: true, autoRestore: false, strategy: 'redis', provider }, 33 | }); 34 | baker1.add({ name: 'redis-job', cron: '* * * * * *', callback: () => {} }); 35 | await baker1.saveState(); 36 | 37 | const baker2 = new Baker({ 38 | persistence: { enabled: true, autoRestore: true, strategy: 'redis', provider }, 39 | }); 40 | await baker2.ready(); 41 | expect(baker2.getJobNames()).toContain('redis-job'); 42 | baker2.destroyAll(); 43 | baker1.destroyAll(); 44 | }); 45 | 46 | it('Throws if redis strategy without provider', () => { 47 | expect(() => new Baker({ persistence: { enabled: true, strategy: 'redis' } })).toThrow(); 48 | }); 49 | 50 | it('allows overriding restored jobs with new definitions', async () => { 51 | const { provider, filePath } = createFileProvider(); 52 | 53 | const baker1 = new Baker({ 54 | persistence: { enabled: true, autoRestore: false, strategy: 'file', provider }, 55 | }); 56 | baker1.add({ name: 'restored-job', cron: '@daily', callback: () => {} }); 57 | await baker1.saveState(); 58 | baker1.destroyAll(); 59 | 60 | const baker2 = new Baker({ 61 | persistence: { enabled: true, autoRestore: true, strategy: 'file', provider }, 62 | }); 63 | await baker2.ready(); 64 | const restored = baker2.getAllJobs().get('restored-job'); 65 | expect(restored?.cron).toBe('@daily'); 66 | 67 | baker2.add({ name: 'restored-job', cron: '@hourly', callback: () => {} }); 68 | const updated = baker2.getAllJobs().get('restored-job'); 69 | expect(updated?.cron).toBe('@hourly'); 70 | 71 | baker2.destroyAll(); 72 | await baker2.saveState(); 73 | cleanupFile(filePath); 74 | }); 75 | 76 | it('skips restoring jobs already defined before restore runs', async () => { 77 | const { provider, filePath } = createFileProvider(); 78 | 79 | const baker1 = new Baker({ 80 | persistence: { enabled: true, autoRestore: false, strategy: 'file', provider }, 81 | }); 82 | baker1.add({ name: 'predefined-job', cron: '@daily', callback: () => {} }); 83 | await baker1.saveState(); 84 | baker1.destroyAll(); 85 | 86 | const baker2 = new Baker({ 87 | persistence: { enabled: true, autoRestore: false, strategy: 'file', provider }, 88 | }); 89 | baker2.add({ name: 'predefined-job', cron: '@hourly', callback: () => {} }); 90 | await baker2.restoreState(); 91 | const job = baker2.getAllJobs().get('predefined-job'); 92 | expect(job?.cron).toBe('@hourly'); 93 | 94 | baker2.destroyAll(); 95 | await baker2.saveState(); 96 | cleanupFile(filePath); 97 | }); 98 | 99 | it('respects per-job persistence flags', async () => { 100 | const { provider, filePath } = createFileProvider(); 101 | 102 | const baker1 = new Baker({ 103 | persistence: { enabled: true, autoRestore: false, strategy: 'file', provider }, 104 | }); 105 | 106 | baker1.add({ name: 'persisted-job', cron: '@daily', callback: () => {}, persist: true }); 107 | baker1.add({ name: 'ephemeral-job', cron: '@hourly', callback: () => {}, persist: false }); 108 | await baker1.saveState(); 109 | baker1.destroyAll(); 110 | 111 | const baker2 = new Baker({ 112 | persistence: { enabled: true, autoRestore: true, strategy: 'file', provider }, 113 | }); 114 | await baker2.ready(); 115 | const jobNames = baker2.getJobNames(); 116 | expect(jobNames).toContain('persisted-job'); 117 | expect(jobNames).not.toContain('ephemeral-job'); 118 | 119 | baker2.destroyAll(); 120 | await baker2.saveState(); 121 | cleanupFile(filePath); 122 | }); 123 | 124 | it('restores metrics and history snapshots', async () => { 125 | const { provider, filePath } = createFileProvider(); 126 | 127 | const baker1 = new Baker({ 128 | enableMetrics: true, 129 | persistence: { enabled: true, autoRestore: false, strategy: 'file', provider }, 130 | }); 131 | 132 | baker1.add({ 133 | name: 'metrics-job', 134 | cron: '@every_second', 135 | callback: () => {}, 136 | start: true, 137 | immediate: true, 138 | persist: true, 139 | }); 140 | 141 | await sleep(120); 142 | baker1.stopAll(); 143 | await baker1.saveState(); 144 | baker1.destroyAll(); 145 | 146 | const baker2 = new Baker({ 147 | enableMetrics: true, 148 | persistence: { enabled: true, autoRestore: true, strategy: 'file', provider }, 149 | }); 150 | await baker2.ready(); 151 | const metricsJob = baker2.getAllJobs().get('metrics-job'); 152 | expect(metricsJob?.getMetrics().totalExecutions ?? 0).toBeGreaterThan(0); 153 | expect(metricsJob?.getHistory().length ?? 0).toBeGreaterThan(0); 154 | 155 | baker2.destroyAll(); 156 | await baker2.saveState(); 157 | cleanupFile(filePath); 158 | }); 159 | }); 160 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # cronbake 2 | 3 | ## 0.4.0 4 | 5 | ### Features 6 | 7 | - **Custom Logger Support**: Add pluggable logger interface allowing integration with any logging library (pino, winston, bunyan, logtape, etc.) 8 | - New `Logger` interface exported from package API 9 | - Pass `logger` option to `Baker.create()` to set a global logger for all jobs 10 | - Override logger per job via `logger` option in `baker.add()` or `Cron.create()` 11 | - Default behavior unchanged (falls back to `console`) 12 | - Thanks to [@nemvince](https://github.com/nemvince) for the work in [PR #17](https://github.com/chaqchase/cronbake/pull/17) 13 | 14 | ### Examples 15 | 16 | - Added `examples/logging.ts` demonstrating custom logger usage with colored output, global logger, per-job overrides, and error logging 17 | 18 | ### Documentation 19 | 20 | - Updated README with comprehensive custom logger documentation and examples 21 | 22 | ## 0.3.2 23 | 24 | ### Fixes 25 | 26 | - Persistence: Fix job redefinition warning by allowing user-defined jobs to replace restored jobs without conflicts ([#14](https://github.com/chaqchase/cronbake/issues/14)). Previously, restarting an app with updated job configurations would show a warning "Failed to restore job 'my-job': warn: Cron job with name 'my-job' already exists". Now, user-defined configurations take precedence over restored state. 27 | - Runtime: Failed job executions now bubble up properly so metrics/history and `lastError` reflect the failure, and `onError` handlers still fire exactly once. 28 | - Runtime: `baker.lastExecution()` uses the timestamp of the most recent actual run and `Cron.time()` once again reports the remaining delay rather than the current epoch time, matching the documented API. 29 | 30 | ### Minor Changes 31 | 32 | - Persistence: Honor the `persist` flag per job so only opted-in jobs are saved/restored, and hydrate metrics/history snapshots during restore. 33 | - API: Added `baker.ready()` to await automatic restoration before interacting with jobs; `autoStart` now waits for persistence to finish. 34 | - DX: Prevent redundant persistence writes during restore for faster startups and fewer warnings. 35 | - Metadata: Point npm metadata to `chaqchase/cronbake`. 36 | - Docs: Added `examples/` folder plus README guidance so common use cases have copy-paste starting points without bloating published bundles. 37 | 38 | ## 0.3.1 39 | 40 | ### Patch Changes 41 | 42 | - Fix critical parser bugs in cron expression aliases: 43 | - `@hourly`: Corrected from running every minute to running every hour at minute 0 44 | - `@daily`: Fixed from running every hour to running once daily at midnight 45 | - `@weekly`: Fixed invalid cron expression (had month=0) to run at midnight every Sunday 46 | - `@monthly`: Corrected hour from 1 AM to midnight on the 1st of each month 47 | - `@yearly` & `@annually`: Corrected hour from 1 AM to midnight on January 1st 48 | - `parseOnDayStr`: Fixed parsing logic to correctly handle `@on_` format 49 | 50 | ## 0.3.0 51 | 52 | ### Minor Changes 53 | 54 | - 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"`). 55 | - Overrun protection: Add `overrunProtection` (default: true) to skip starting a new run if the previous execution is still running; tracks `skippedExecutions` in metrics. 56 | - 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. 57 | 58 | ### Features 59 | 60 | - New Cron options: `immediate`, `delay`, `overrunProtection`. 61 | - New metrics: `skippedExecutions`. 62 | - New persistence API: `PersistenceProvider` with `save/load` and types for persisted state. 63 | - Export providers from package API: `FilePersistenceProvider`, `RedisPersistenceProvider`. 64 | 65 | ### Fixes 66 | 67 | - 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. 68 | - Types: Replace `Timer` with `ReturnType` for portability. 69 | - Build: Replace `@/lib` path aliases in source with relative imports to avoid bundler resolution issues. 70 | - Packaging: Set `module` to `dist/index.js`, move `@changesets/cli` to devDependencies, ensure Changesets access is public. 71 | 72 | ### Tests 73 | 74 | - Add tests for immediate/delayed first run and overrun protection. 75 | - Add provider-focused tests and shared test utilities for persistence (file and Redis via a fake client). 76 | 77 | ## 0.2.0 78 | 79 | ### Minor Changes 80 | 81 | - **Enhanced Job Control**: Added pause and resume functionality for fine-grained job control 82 | - **Job Persistence**: Added support for saving and restoring job state across application restarts 83 | - **Execution Metrics & History**: Comprehensive tracking of job performance including execution counts, success/failure rates, average execution time, and detailed execution history 84 | - **Priority System**: Jobs can now be assigned priority levels for execution ordering 85 | - **Advanced Scheduling**: Added two scheduling modes - calculated timeouts (default) for efficiency and traditional polling-based scheduling 86 | - **Enhanced Error Handling**: Improved error handling with custom error handlers and detailed error tracking 87 | - **Async Callback Support**: Job callbacks now support both synchronous and asynchronous functions 88 | - **Job Metrics API**: New methods to access execution history, performance metrics, and reset statistics 89 | - **Configurable Options**: Enhanced configuration options for polling intervals, history retention, and timeout calculations 90 | - **Type Safety Improvements**: Enhanced TypeScript definitions for better development experience 91 | 92 | ### Features 93 | 94 | - **New Job Control Methods**: `pause()`, `resume()`, `getHistory()`, `getMetrics()`, `resetMetrics()` 95 | - **Persistence Options**: Configurable file-based state persistence with automatic restoration 96 | - **Job Status Tracking**: Extended status states including 'paused' and 'error' states 97 | - **Performance Monitoring**: Track total executions, success/failure counts, execution duration, and error details 98 | - **Priority-based Execution**: Assign and manage job priorities for execution ordering 99 | - **Enhanced Baker Configuration**: New options for scheduler configuration, persistence settings, and global error handling 100 | - **Improved Documentation**: Comprehensive README updates with new feature examples and usage patterns 101 | 102 | ## 0.1.2 103 | 104 | ### Patch Changes 105 | 106 | - 833dfa8: Update the docs 107 | 108 | ## 0.1.1 109 | 110 | ### Patch Changes 111 | 112 | - 5ed68b2: Add license and fix readme 113 | 114 | ## 0.1.0 115 | 116 | ### Minor Changes 117 | 118 | - 3d2e50a: First minor release 119 | 120 | ## 0.0.1 121 | 122 | ### Patch Changes 123 | 124 | - 6e02baf: Initial release 125 | -------------------------------------------------------------------------------- /lib/parser.ts: -------------------------------------------------------------------------------- 1 | import { 2 | type AtHourStrType, 3 | type BetweenStrType, 4 | type CronExpression, 5 | type CronExpressionType, 6 | type CronTime, 7 | type EveryStrType, 8 | type ICronParser, 9 | type OnDayStrType, 10 | } from "./types"; 11 | 12 | /** 13 | * A class that implements the `ICronParser` interface and provides methods to parse a cron expression 14 | * and get the next and previous execution times. 15 | */ 16 | class CronParser implements ICronParser { 17 | /** 18 | * Creates a new instance of the `CronParser` class. 19 | */ 20 | constructor(private cron: CronExpressionType) {} 21 | 22 | /** 23 | * A map of cron expression aliases to their corresponding cron expressions. 24 | * * * * * * * (second minute hour day month day-of-week) 25 | * | | | | | | 26 | * | | | | | +-- day of the week (0 - 6) (Sunday to Saturday) 27 | * | | | | +---- month (1 - 12) 28 | * | | | +------ day of the month (1 - 31) 29 | * | | +-------- hour (0 - 23) 30 | * | +---------- minute (0 - 59) 31 | * +------------ second (0 - 59) 32 | */ 33 | private readonly aliases: Map = new Map([ 34 | ["@every_second", "* * * * * *"], 35 | ["@every_minute", "0 * * * * *"], 36 | ["@yearly", "0 0 0 1 1 *"], // 00:00:00 at day 1 of month and Jan 37 | ["@annually", "0 0 0 1 1 *"], // 00:00:00 at day 1 of month and Jan 38 | ["@monthly", "0 0 0 1 * *"], // 00:00:00 at day 1 of month 39 | ["@weekly", "0 0 0 * * 0"], // 00:00:00 at sun 40 | ["@daily", "0 0 0 * * *"], // 00:00:00 every day 41 | ["@hourly", "0 0 * * * *"], // 00:00 every hour 42 | ]); 43 | 44 | /** 45 | * Parses a string in the format "@every__" and returns the corresponding cron expression. 46 | */ 47 | private parseEveryStr(str: EveryStrType): string { 48 | const [, value, unit] = str.split("_"); 49 | switch (unit) { 50 | case "seconds": 51 | return `*/${value} * * * * *`; 52 | case "minutes": 53 | return `0 */${value} * * * *`; 54 | case "hours": 55 | return `0 0 */${value} * * *`; 56 | case "dayOfMonth": 57 | return `0 0 0 */${value} * *`; 58 | case "months": 59 | // Run at midnight on the 1st day every N months 60 | return `0 0 0 1 */${value} *`; 61 | case "dayOfWeek": 62 | // Run at midnight on every Nth day-of-week (e.g., */2 => Sun, Tue, Thu, Sat) 63 | return `0 0 0 * * */${value}`; 64 | default: 65 | return "* * * * * *"; 66 | } 67 | } 68 | 69 | /** 70 | * Parses a string in the format "@at_