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:
2026-04-13 15:30:17 -04:00
parent 060eb367d8
commit 6521179e10
3 changed files with 139 additions and 155 deletions
+109 -81
View File
@@ -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
View File
@@ -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
View File
@@ -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: {