feat(cron): refactor cron utility with validation and metadata
- Add input validation for name, schedule expression, and handler - Store full CronEntry metadata (handler, schedule, timezone, registeredAt) instead of raw job instance to support introspection - Add JSDoc typedefs for CronEntry and improve all function docs - Use globalThis symbol store to survive Next.js hot-reload - Remove verbose per-run info logs to reduce noise - Replace `||` with `??` for runOnInit default to handle falsy correctly - Fix stop/stopAll to access `entry.job` from new storage structure
This commit is contained in:
+109
-81
@@ -9,11 +9,20 @@
|
||||
import cron from 'node-cron';
|
||||
import { done, fail, info } from '../../shared/lib/logger.js';
|
||||
|
||||
// Store for all scheduled cron jobs
|
||||
// Shared store — survives Next.js hot-reload and module-cache invalidation
|
||||
const CRON_JOBS_KEY = Symbol.for('__ZEN_CRON_JOBS__');
|
||||
|
||||
/**
|
||||
* Initialize cron jobs storage
|
||||
* @typedef {Object} CronEntry
|
||||
* @property {Object} job - node-cron task instance
|
||||
* @property {Function} handler - original handler function
|
||||
* @property {string} schedule - cron expression
|
||||
* @property {string} timezone - timezone used
|
||||
* @property {string} registeredAt - ISO timestamp of registration
|
||||
*/
|
||||
|
||||
/**
|
||||
* @returns {Map<string, CronEntry>}
|
||||
*/
|
||||
function getJobsStorage() {
|
||||
if (!globalThis[CRON_JOBS_KEY]) {
|
||||
@@ -23,66 +32,85 @@ function getJobsStorage() {
|
||||
}
|
||||
|
||||
/**
|
||||
* Schedule a cron job
|
||||
* @param {string} name - Unique name for the job
|
||||
* @param {string} schedule - Cron schedule expression
|
||||
* @param {Function} handler - Handler function to execute
|
||||
* @param {Object} options - Options
|
||||
* @param {string} options.timezone - Timezone (default: from env or America/Toronto)
|
||||
* @param {boolean} options.runOnInit - Run immediately on schedule (default: false)
|
||||
* @returns {Object} Cron job instance
|
||||
* Schedule a cron job.
|
||||
*
|
||||
* If a job with the same name already exists it is stopped and replaced.
|
||||
*
|
||||
* @param {string} name - Unique name for the job
|
||||
* @param {string} cronSchedule - Cron expression (5 or 6 fields)
|
||||
* @param {Function} handler - Async function to execute
|
||||
* @param {Object} [options]
|
||||
* @param {string} [options.timezone] - IANA timezone (default: ZEN_TIMEZONE env or America/Toronto)
|
||||
* @param {boolean} [options.runOnInit] - Run immediately when scheduled (default: false)
|
||||
* @returns {Object} node-cron task instance
|
||||
*
|
||||
* @example
|
||||
* schedule('my-task', '0 9 * * *', async () => {
|
||||
* console.log('Running every day at 9 AM');
|
||||
* schedule('daily-report', '0 9 * * *', async () => {
|
||||
* await sendReport();
|
||||
* });
|
||||
*
|
||||
* @example
|
||||
* schedule('reminder', ''\''*\/5 5-17 * * *'\'', async () => {
|
||||
* console.log('Every 5 minutes between 5 AM and 5 PM');
|
||||
* schedule('every-5min', '*\/5 * * * *', async () => {
|
||||
* await syncData();
|
||||
* }, { timezone: 'America/New_York' });
|
||||
*/
|
||||
export function schedule(name, cronSchedule, handler, options = {}) {
|
||||
if (!name || typeof name !== 'string') {
|
||||
throw new Error('Cron job name must be a non-empty string');
|
||||
}
|
||||
if (!validate(cronSchedule)) {
|
||||
throw new Error(`Invalid cron expression: "${cronSchedule}"`);
|
||||
}
|
||||
if (typeof handler !== 'function') {
|
||||
throw new Error('Cron job handler must be a function');
|
||||
}
|
||||
|
||||
const jobs = getJobsStorage();
|
||||
|
||||
// Stop existing job with same name
|
||||
// Replace existing job with same name
|
||||
if (jobs.has(name)) {
|
||||
jobs.get(name).stop();
|
||||
jobs.get(name).job.stop();
|
||||
info(`Cron replaced: ${name}`);
|
||||
}
|
||||
|
||||
const timezone = options.timezone || process.env.ZEN_TIMEZONE || 'America/Toronto';
|
||||
|
||||
const job = cron.schedule(cronSchedule, async () => {
|
||||
info(`Cron ${name} running at ${new Date().toISOString()}`);
|
||||
try {
|
||||
await handler();
|
||||
info(`Cron ${name} completed`);
|
||||
} catch (error) {
|
||||
fail(`Cron ${name}: ${error.message}`);
|
||||
}
|
||||
}, {
|
||||
scheduled: true,
|
||||
timezone,
|
||||
runOnInit: options.runOnInit || false
|
||||
runOnInit: options.runOnInit ?? false
|
||||
});
|
||||
|
||||
jobs.set(name, {
|
||||
job,
|
||||
handler,
|
||||
schedule: cronSchedule,
|
||||
timezone,
|
||||
registeredAt: new Date().toISOString()
|
||||
});
|
||||
|
||||
jobs.set(name, job);
|
||||
done(`Cron scheduled: ${name} (${cronSchedule})`);
|
||||
|
||||
return job;
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop a scheduled cron job
|
||||
* Stop and remove a scheduled cron job.
|
||||
*
|
||||
* @param {string} name - Job name
|
||||
* @returns {boolean} True if job was stopped
|
||||
* @returns {boolean} True if the job existed and was stopped
|
||||
*/
|
||||
export function stop(name) {
|
||||
const jobs = getJobsStorage();
|
||||
|
||||
if (jobs.has(name)) {
|
||||
jobs.get(name).stop();
|
||||
jobs.get(name).job.stop();
|
||||
jobs.delete(name);
|
||||
info(`Cron stopped: ${name}`);
|
||||
return true;
|
||||
@@ -92,67 +120,25 @@ export function stop(name) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop all cron jobs
|
||||
* Stop and remove all scheduled cron jobs.
|
||||
*/
|
||||
export function stopAll() {
|
||||
const jobs = getJobsStorage();
|
||||
|
||||
for (const [name, job] of jobs.entries()) {
|
||||
job.stop();
|
||||
for (const [name, entry] of jobs.entries()) {
|
||||
entry.job.stop();
|
||||
info(`Cron stopped: ${name}`);
|
||||
}
|
||||
|
||||
jobs.clear();
|
||||
done('All cron jobs stopped');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get status of all cron jobs
|
||||
* @returns {Object} Status of all jobs
|
||||
*/
|
||||
export function getStatus() {
|
||||
const jobs = getJobsStorage();
|
||||
const status = {};
|
||||
|
||||
for (const [name] of jobs.entries()) {
|
||||
status[name] = { running: true };
|
||||
}
|
||||
|
||||
return status;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a cron job is running
|
||||
* @param {string} name - Job name
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function isRunning(name) {
|
||||
const jobs = getJobsStorage();
|
||||
return jobs.has(name);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate a cron expression
|
||||
* @param {string} expression - Cron expression to validate
|
||||
* @returns {boolean} True if valid
|
||||
*/
|
||||
export function validate(expression) {
|
||||
return cron.validate(expression);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get list of all scheduled job names
|
||||
* @returns {string[]} Array of job names
|
||||
*/
|
||||
export function getJobs() {
|
||||
const jobs = getJobsStorage();
|
||||
return Array.from(jobs.keys());
|
||||
}
|
||||
|
||||
/**
|
||||
* Manually trigger a job by name
|
||||
* Manually trigger a job by name, bypassing its schedule.
|
||||
*
|
||||
* @param {string} name - Job name
|
||||
* @returns {Promise<void>}
|
||||
* @throws {Error} If the job does not exist
|
||||
*/
|
||||
export async function trigger(name) {
|
||||
const jobs = getJobsStorage();
|
||||
@@ -162,22 +148,64 @@ export async function trigger(name) {
|
||||
}
|
||||
|
||||
info(`Cron manual trigger: ${name}`);
|
||||
// Note: node-cron doesn't expose the handler directly,
|
||||
// so modules should keep their handler function accessible
|
||||
await jobs.get(name).handler();
|
||||
}
|
||||
|
||||
// Re-export the raw cron module for advanced usage
|
||||
export { cron };
|
||||
/**
|
||||
* Validate a cron expression.
|
||||
*
|
||||
* @param {string} expression
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function validate(expression) {
|
||||
return cron.validate(expression);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check whether a job is currently scheduled.
|
||||
*
|
||||
* @param {string} name - Job name
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function isRunning(name) {
|
||||
return getJobsStorage().has(name);
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the names of all scheduled jobs.
|
||||
*
|
||||
* @returns {string[]}
|
||||
*/
|
||||
export function getJobs() {
|
||||
return Array.from(getJobsStorage().keys());
|
||||
}
|
||||
|
||||
/**
|
||||
* Return metadata for all scheduled jobs.
|
||||
*
|
||||
* @returns {Object.<string, { schedule: string, timezone: string, registeredAt: string }>}
|
||||
*/
|
||||
export function getStatus() {
|
||||
const status = {};
|
||||
|
||||
for (const [name, entry] of getJobsStorage().entries()) {
|
||||
status[name] = {
|
||||
schedule: entry.schedule,
|
||||
timezone: entry.timezone,
|
||||
registeredAt: entry.registeredAt
|
||||
};
|
||||
}
|
||||
|
||||
return status;
|
||||
}
|
||||
|
||||
// Default export for convenience
|
||||
export default {
|
||||
schedule,
|
||||
stop,
|
||||
stopAll,
|
||||
getStatus,
|
||||
isRunning,
|
||||
validate,
|
||||
getJobs,
|
||||
trigger,
|
||||
cron
|
||||
validate,
|
||||
isRunning,
|
||||
getJobs,
|
||||
getStatus
|
||||
};
|
||||
|
||||
+17
-62
@@ -11,11 +11,11 @@ import {
|
||||
getAllDatabaseSchemas,
|
||||
isModuleEnabled
|
||||
} from './registry.js';
|
||||
import { schedule, stopAll, getStatus } from '@zen/core/cron';
|
||||
import { step, done, warn, fail, info } from '../../shared/lib/logger.js';
|
||||
|
||||
// Use globalThis to track initialization state
|
||||
const INIT_KEY = Symbol.for('__ZEN_MODULES_INITIALIZED__');
|
||||
const CRON_JOBS_KEY = Symbol.for('__ZEN_MODULE_CRON_JOBS__');
|
||||
|
||||
/**
|
||||
* Initialize all modules
|
||||
@@ -109,55 +109,29 @@ export async function initializeModuleDatabases() {
|
||||
}
|
||||
|
||||
/**
|
||||
* Start cron jobs for all enabled modules
|
||||
* Start cron jobs for all enabled modules.
|
||||
* Delegates scheduling to core/cron so all jobs share a single registry.
|
||||
* @returns {Promise<Object>} Cron job start result
|
||||
*/
|
||||
export async function startModuleCronJobs() {
|
||||
step('Starting cron jobs...');
|
||||
|
||||
// Stop existing cron jobs first
|
||||
// Clear any jobs registered by a previous init cycle
|
||||
stopModuleCronJobs();
|
||||
|
||||
const jobs = getAllCronJobs();
|
||||
const result = {
|
||||
started: [],
|
||||
errors: []
|
||||
};
|
||||
|
||||
// Initialize cron jobs storage
|
||||
if (!globalThis[CRON_JOBS_KEY]) {
|
||||
globalThis[CRON_JOBS_KEY] = new Map();
|
||||
}
|
||||
const result = { started: [], errors: [] };
|
||||
|
||||
for (const job of jobs) {
|
||||
try {
|
||||
if (job.handler && typeof job.handler === 'function') {
|
||||
// Dynamic import of node-cron
|
||||
const cron = (await import('node-cron')).default;
|
||||
if (typeof job.handler !== 'function') continue;
|
||||
|
||||
const cronJob = cron.schedule(job.schedule, async () => {
|
||||
info(`Cron ${job.name} running at ${new Date().toISOString()}`);
|
||||
try {
|
||||
await job.handler();
|
||||
info(`Cron ${job.name} completed`);
|
||||
} catch (error) {
|
||||
fail(`Cron ${job.name}: ${error.message}`);
|
||||
}
|
||||
}, {
|
||||
scheduled: true,
|
||||
timezone: job.timezone || process.env.ZEN_TIMEZONE || 'America/Toronto'
|
||||
});
|
||||
|
||||
globalThis[CRON_JOBS_KEY].set(job.name, cronJob);
|
||||
result.started.push(job.name);
|
||||
info(`Cron ready: ${job.name} (${job.schedule})`);
|
||||
}
|
||||
} catch (error) {
|
||||
result.errors.push({
|
||||
job: job.name,
|
||||
module: job.module,
|
||||
error: error.message
|
||||
schedule(job.name, job.schedule, job.handler, {
|
||||
timezone: job.timezone
|
||||
});
|
||||
result.started.push(job.name);
|
||||
} catch (error) {
|
||||
result.errors.push({ job: job.name, module: job.module, error: error.message });
|
||||
fail(`Cron error for ${job.name}: ${error.message}`);
|
||||
}
|
||||
}
|
||||
@@ -166,38 +140,19 @@ export async function startModuleCronJobs() {
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop all module cron jobs
|
||||
* Stop all module cron jobs.
|
||||
* Delegates to core/cron which owns the shared registry.
|
||||
*/
|
||||
export function stopModuleCronJobs() {
|
||||
if (globalThis[CRON_JOBS_KEY]) {
|
||||
for (const [name, job] of globalThis[CRON_JOBS_KEY].entries()) {
|
||||
try {
|
||||
job.stop();
|
||||
info(`Cron stopped: ${name}`);
|
||||
} catch (error) {
|
||||
fail(`Error stopping cron ${name}: ${error.message}`);
|
||||
}
|
||||
}
|
||||
globalThis[CRON_JOBS_KEY].clear();
|
||||
}
|
||||
stopAll();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get status of all cron jobs
|
||||
* @returns {Object} Cron job status
|
||||
* Get status of all cron jobs.
|
||||
* @returns {Object} Cron job status (from core/cron)
|
||||
*/
|
||||
export function getCronJobStatus() {
|
||||
const status = {};
|
||||
|
||||
if (globalThis[CRON_JOBS_KEY]) {
|
||||
for (const [name, job] of globalThis[CRON_JOBS_KEY].entries()) {
|
||||
status[name] = {
|
||||
running: true // node-cron doesn't expose a running state easily
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return status;
|
||||
return getStatus();
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+2
-1
@@ -15,6 +15,7 @@ export default defineConfig([
|
||||
'src/features/admin/components/index.js',
|
||||
'src/core/api/index.js',
|
||||
'src/core/api/route-handler.js',
|
||||
'src/core/cron/index.js',
|
||||
'src/core/database/index.js',
|
||||
'src/cli/database.js',
|
||||
'src/core/email/index.js',
|
||||
@@ -41,7 +42,7 @@ export default defineConfig([
|
||||
splitting: false,
|
||||
sourcemap: false,
|
||||
clean: true,
|
||||
external: ['react', 'react-dom', 'next', 'pg', 'dotenv', 'dotenv/config', 'resend', '@react-email/components', 'node-cron', 'readline', 'crypto', 'url', 'fs', 'path', 'net', 'dns', 'tls', '@zen/core/api', '@zen/core/database', '@zen/core/email', '@zen/core/email/templates', '@zen/core/storage', '@zen/core/toast', '@zen/core/modules/actions', '@zen/core/modules/storage', '@aws-sdk/client-s3', '@aws-sdk/s3-request-presigner'],
|
||||
external: ['react', 'react-dom', 'next', 'pg', 'dotenv', 'dotenv/config', 'resend', '@react-email/components', 'node-cron', 'readline', 'crypto', 'url', 'fs', 'path', 'net', 'dns', 'tls', '@zen/core/api', '@zen/core/cron', '@zen/core/database', '@zen/core/email', '@zen/core/email/templates', '@zen/core/storage', '@zen/core/toast', '@zen/core/modules/actions', '@zen/core/modules/storage', '@aws-sdk/client-s3', '@aws-sdk/s3-request-presigner'],
|
||||
noExternal: [],
|
||||
bundle: true,
|
||||
banner: {
|
||||
|
||||
Reference in New Issue
Block a user