Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .changeset/config.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
"commit": false,
"fixed": [],
"linked": [],
"access": "restricted",
"access": "public",
"baseBranch": "main",
"updateInternalDependencies": "patch",
"ignore": []
Expand Down
27 changes: 27 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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_<day>` mapping to Sunday=0…Saturday=6, fix `@every_<n>_months` to run on day 1 (`0 0 0 1 */n *`), and `@every_<n>_dayOfWeek` to `*/n` on day-of-week.
- Types: Replace `Timer` with `ReturnType<typeof setTimeout|setInterval>` 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
Expand Down
64 changes: 62 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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 |
Expand Down Expand Up @@ -309,6 +364,8 @@ const job = Cron.create({
console.error('Job failed:', error.message);
},
priority: 10,
immediate: false,
overrunProtection: true,
});

// Start the cron job
Expand All @@ -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
Expand Down Expand Up @@ -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).
Cronbake is released under the [MIT License](./LICENSE).
16 changes: 13 additions & 3 deletions index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import Baker from '@/lib';
import Baker from "./lib";

export {
type CronOptions,
Expand All @@ -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";
78 changes: 36 additions & 42 deletions lib/baker.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,17 @@
import Cron from '@/lib/cron';
import {
CronOptions,
IBaker,
IBakerOptions,
ICron,
Status,
ExecutionHistory,
import Cron from './cron';
import {
CronOptions,
IBaker,
IBakerOptions,
ICron,
Status,
ExecutionHistory,
JobMetrics,
SchedulerConfig,
PersistenceOptions
} from '@/lib/types';
import * as fs from 'fs';
import * as path from 'path';
PersistenceOptions,
} from './types';
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.
Expand All @@ -20,6 +20,7 @@ class Baker implements IBaker {
private crons: Map<string, ICron> = new Map();
private config: SchedulerConfig;
private persistence: PersistenceOptions;
private persistenceProvider?: PersistenceProvider;
private enableMetrics: boolean;
private onError?: (error: Error, jobName: string) => void;

Expand All @@ -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;
Expand Down Expand Up @@ -225,58 +239,40 @@ class Baker implements IBaker {
}

async saveState(): Promise<void> {
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<void> {
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,
Expand All @@ -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}`);
Expand All @@ -308,4 +302,4 @@ class Baker implements IBaker {
}
}

export default Baker;
export default Baker;
Loading