From 0256069afef1c6383652b8c8a4e18f1de56dae00 Mon Sep 17 00:00:00 2001 From: mohamedhoss123 <81392900+mohamedhoss123@users.noreply.github.com> Date: Mon, 13 Jul 2026 13:57:11 +0300 Subject: [PATCH 1/2] feat(queue): implement JobQueue class for managing job processing refactor(scheduler): integrate JobQueue into scheduled message processing fix(constants): reduce scheduler check interval to 1 second test(queue): add unit tests for JobQueue functionality --- packages/systems/src/index.ts | 6 +- packages/systems/src/queue/JobQueue.ts | 73 ++++++ packages/systems/src/queue/index.ts | 2 + .../src/scheduled-messages/constants.ts | 2 +- .../src/scheduled-messages/scheduler.ts | 55 ++--- .../systems/tests/unit/queue/JobQueue.test.ts | 220 ++++++++++++++++++ 6 files changed, 330 insertions(+), 28 deletions(-) create mode 100644 packages/systems/src/queue/JobQueue.ts create mode 100644 packages/systems/src/queue/index.ts create mode 100644 packages/systems/tests/unit/queue/JobQueue.test.ts diff --git a/packages/systems/src/index.ts b/packages/systems/src/index.ts index 8270300..5f17772 100644 --- a/packages/systems/src/index.ts +++ b/packages/systems/src/index.ts @@ -64,9 +64,13 @@ export { checkAndGrantRewards } from "./leveling/rewards.js"; // Scheduled Messages export { getScheduledMessages, getScheduledMessageById, createScheduledMessage, updateScheduledMessage, deleteScheduledMessage, getDueMessages, markMessageExecuted } from "./scheduled-messages/persistence.js"; -export { startScheduledMessageScheduler, stopScheduledMessageScheduler, processScheduledMessages } from "./scheduled-messages/scheduler.js"; +export { startScheduledMessageScheduler, stopScheduledMessageScheduler } from "./scheduled-messages/scheduler.js"; export { parseCronExpression, validateCronExpression, getNextCronRun, describeCron } from "./scheduled-messages/cron.js"; export { SCHEDULER_CHECK_INTERVAL_MS, MAX_SCHEDULED_MESSAGES_PER_GUILD, COMMON_TIMEZONES } from "./scheduled-messages/constants.js"; + +// Job Queue +export { JobQueue } from "./queue/index.js"; + // Custom Commands export { getCustomCommands, diff --git a/packages/systems/src/queue/JobQueue.ts b/packages/systems/src/queue/JobQueue.ts new file mode 100644 index 0000000..a1c5dd1 --- /dev/null +++ b/packages/systems/src/queue/JobQueue.ts @@ -0,0 +1,73 @@ +export interface Identifiable { + id: string | number; +} + +export class JobQueue { + private buffer: T[] = []; + private inFlight: Set = new Set(); + private consumer: (item: T) => Promise; + private _running = false; + private _processing = false; + + constructor(consumer: (item: T) => Promise) { + this.consumer = consumer; + } + + enqueue(items: T[]): void { + for (const item of items) { + if (!this.inFlight.has(item.id)) { + this.inFlight.add(item.id); + this.buffer.push(item); + } + } + if (this._running && !this._processing) { + this.processNext(); + } + } + + private async processNext(): Promise { + if (!this._running || this.buffer.length === 0) { + this._processing = false; + return; + } + + this._processing = true; + const item = this.buffer.shift()!; + + try { + await this.consumer(item); + } catch { + // Consumer errors are the consumer's responsibility to handle. + // We suppress here to prevent unhandled rejections. + } finally { + this.inFlight.delete(item.id); + if (this._running && this.buffer.length > 0) { + this.processNext(); + } else { + this._processing = false; + } + } + } + + start(): void { + this._running = true; + if (this.buffer.length > 0 && !this._processing) { + this.processNext(); + } + } + + stop(): void { + this._running = false; + this._processing = false; + this.buffer = []; + this.inFlight.clear(); + } + + get pending(): number { + return this.buffer.length; + } + + get isProcessing(): boolean { + return this._processing; + } +} diff --git a/packages/systems/src/queue/index.ts b/packages/systems/src/queue/index.ts new file mode 100644 index 0000000..7d1a7cf --- /dev/null +++ b/packages/systems/src/queue/index.ts @@ -0,0 +1,2 @@ +export { JobQueue } from "./JobQueue.js"; +export type { Identifiable } from "./JobQueue.js"; diff --git a/packages/systems/src/scheduled-messages/constants.ts b/packages/systems/src/scheduled-messages/constants.ts index d12132a..91defd5 100644 --- a/packages/systems/src/scheduled-messages/constants.ts +++ b/packages/systems/src/scheduled-messages/constants.ts @@ -1,5 +1,5 @@ /** Interval in milliseconds for checking due scheduled messages */ -export const SCHEDULER_CHECK_INTERVAL_MS = 60_000; // 60 seconds +export const SCHEDULER_CHECK_INTERVAL_MS = 1_000; // 1 second /** Maximum number of scheduled messages per guild */ export const MAX_SCHEDULED_MESSAGES_PER_GUILD = 25; diff --git a/packages/systems/src/scheduled-messages/scheduler.ts b/packages/systems/src/scheduled-messages/scheduler.ts index 80f8ac3..11a3c78 100644 --- a/packages/systems/src/scheduled-messages/scheduler.ts +++ b/packages/systems/src/scheduled-messages/scheduler.ts @@ -1,11 +1,13 @@ import type { Client, TextBasedChannel } from "discord.js"; import { EmbedBuilder } from "discord.js"; import { logger } from "@fluxcore/utils"; +import { JobQueue } from "../queue/JobQueue.js"; import { getDueMessages, markMessageExecuted } from "./persistence.js"; import { SCHEDULER_CHECK_INTERVAL_MS } from "./constants.js"; import type { ScheduledMessageRow } from "./types.js"; -let schedulerTimer: ReturnType | null = null; +let producerTimer: ReturnType | null = null; +let queue: JobQueue | null = null; function buildEmbed(embed: NonNullable): EmbedBuilder { const builder = new EmbedBuilder(); @@ -25,19 +27,16 @@ function buildEmbed(embed: NonNullable) return builder; } -export async function processScheduledMessages(client: Client): Promise { - const due = await getDueMessages(); - - for (const msg of due) { +export async function startScheduledMessageScheduler(client: Client): Promise { + queue = new JobQueue(async (msg) => { try { const guild = await client.guilds.fetch(msg.guildId); const channel = guild.channels.cache.get(msg.channelId) as TextBasedChannel | undefined; if (!channel || !("send" in channel)) { logger.warn(`Scheduled message ${msg.id}: channel ${msg.channelId} not found or not text-based`); - // Still update nextRunAt so we don't retry every tick await markMessageExecuted(msg.id, msg.cronExpr, msg.timezone); - continue; + return; } const response = msg.message; @@ -56,39 +55,43 @@ export async function processScheduledMessages(client: Client): Promise { `Scheduled message ${msg.id} failed`, error instanceof Error ? error : new Error(String(error)), ); - // Still advance nextRunAt to prevent retry loops try { await markMessageExecuted(msg.id, msg.cronExpr, msg.timezone); } catch { // Ignore secondary failure } } - } -} + }); + + queue.start(); -export function startScheduledMessageScheduler(client: Client): void { // Run immediately on startup - processScheduledMessages(client).catch((err: unknown) => - logger.error( - "Scheduled message check failed", - err instanceof Error ? err : new Error(String(err)), - ), - ); + const due = await getDueMessages(); + queue.enqueue(due); - schedulerTimer = setInterval(() => { - processScheduledMessages(client).catch((err: unknown) => + producerTimer = setInterval(async () => { + try { + const due = await getDueMessages(); + queue!.enqueue(due); + } catch (error) { logger.error( "Scheduled message check failed", - err instanceof Error ? err : new Error(String(err)), - ), - ); + error instanceof Error ? error : new Error(String(error)), + ); + } }, SCHEDULER_CHECK_INTERVAL_MS); - (schedulerTimer as unknown as { unref: () => void }).unref(); + (producerTimer as unknown as { unref: () => void }).unref(); + + logger.info("Scheduled messages scheduler started"); } export function stopScheduledMessageScheduler(): void { - if (schedulerTimer) { - clearInterval(schedulerTimer); - schedulerTimer = null; + if (producerTimer) { + clearInterval(producerTimer); + producerTimer = null; + } + if (queue) { + queue.stop(); + queue = null; } } diff --git a/packages/systems/tests/unit/queue/JobQueue.test.ts b/packages/systems/tests/unit/queue/JobQueue.test.ts new file mode 100644 index 0000000..75f66ab --- /dev/null +++ b/packages/systems/tests/unit/queue/JobQueue.test.ts @@ -0,0 +1,220 @@ +import { describe, it, expect, vi, afterEach } from "vitest"; +import { JobQueue } from "../../../src/queue/JobQueue.js"; + +interface TestItem { + id: number; + value: string; +} + +function createItem(id: number, value = `item-${id}`): TestItem { + return { id, value }; +} + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe("JobQueue", () => { + describe("enqueue", () => { + it("adds items to pending count", () => { + const consumer = vi.fn(); + const queue = new JobQueue(consumer); + + queue.enqueue([createItem(1), createItem(2)]); + + expect(queue.pending).toBe(2); + }); + + it("deduplicates items with same id via inFlight", () => { + const consumer = vi.fn(); + const queue = new JobQueue(consumer); + + queue.enqueue([createItem(1), createItem(1)]); + + expect(queue.pending).toBe(1); + }); + + it("does not start processing if not started", () => { + const consumer = vi.fn(); + const queue = new JobQueue(consumer); + + queue.enqueue([createItem(1)]); + + expect(consumer).not.toHaveBeenCalled(); + }); + }); + + describe("start", () => { + it("processes all buffered items immediately", async () => { + const results: string[] = []; + const consumer = vi.fn(async (item: TestItem) => { + results.push(item.value); + }); + const queue = new JobQueue(consumer); + + queue.enqueue([createItem(1, "a"), createItem(2, "b"), createItem(3, "c")]); + queue.start(); + + // Wait for async processing to complete + await vi.waitFor(() => { + expect(queue.pending).toBe(0); + }); + + expect(results).toEqual(["a", "b", "c"]); + expect(consumer).toHaveBeenCalledTimes(3); + }); + + it("no-ops if queue is empty", () => { + const consumer = vi.fn(); + const queue = new JobQueue(consumer); + + expect(() => queue.start()).not.toThrow(); + expect(consumer).not.toHaveBeenCalled(); + }); + }); + + describe("processing order", () => { + it("processes items in FIFO order", async () => { + const results: number[] = []; + const consumer = vi.fn(async (item: TestItem) => { + results.push(item.id); + }); + const queue = new JobQueue(consumer); + + queue.enqueue([createItem(1), createItem(2), createItem(3)]); + queue.start(); + + await vi.waitFor(() => { + expect(queue.pending).toBe(0); + }); + + expect(results).toEqual([1, 2, 3]); + }); + + it("processes items sequentially (one at a time)", async () => { + const concurrency = { current: 0, max: 0 }; + const consumer = vi.fn(async (item: TestItem) => { + concurrency.current++; + concurrency.max = Math.max(concurrency.max, concurrency.current); + // Simulate async work + await new Promise((resolve) => setTimeout(resolve, 10)); + concurrency.current--; + }); + const queue = new JobQueue(consumer); + + queue.enqueue([createItem(1), createItem(2), createItem(3)]); + queue.start(); + + await vi.waitFor(() => { + expect(queue.pending).toBe(0); + }); + + expect(concurrency.max).toBe(1); + }); + }); + + describe("producer-consumer flow", () => { + it("enqueues new items while draining previous batch", async () => { + const results: number[] = []; + const consumer = vi.fn(async (item: TestItem) => { + results.push(item.id); + }); + const queue = new JobQueue(consumer); + + // First batch + queue.enqueue([createItem(1)]); + queue.start(); + + await vi.waitFor(() => { + expect(results).toEqual([1]); + }); + + // Second batch — simulates next producer tick + queue.enqueue([createItem(2), createItem(3)]); + + await vi.waitFor(() => { + expect(results).toEqual([1, 2, 3]); + }); + + expect(queue.pending).toBe(0); + }); + + it("does not re-enqueue items still in-flight", async () => { + let resolveFirst: () => void; + const firstPromise = new Promise((resolve) => { resolveFirst = resolve; }); + + const consumer = vi.fn(async (item: TestItem) => { + if (item.id === 1) { + await firstPromise; + } + }); + const queue = new JobQueue(consumer); + + queue.enqueue([createItem(1)]); + queue.start(); + + // Try to re-enqueue item 1 while it's still processing + queue.enqueue([createItem(1)]); + + expect(queue.pending).toBe(0); // Should still be 0 (not re-added) + + // Clean up + resolveFirst!(); + await vi.waitFor(() => { + expect(queue.isProcessing).toBe(false); + }); + }); + }); + + describe("stop", () => { + it("clears the buffer and inFlight set", () => { + const consumer = vi.fn(); + const queue = new JobQueue(consumer); + + queue.enqueue([createItem(1), createItem(2)]); + queue.stop(); + + expect(queue.pending).toBe(0); + }); + + it("does not process items enqueued after stop", async () => { + const consumer = vi.fn(async () => {}); + const queue = new JobQueue(consumer); + + queue.start(); + + queue.enqueue([createItem(1)]); + await vi.waitFor(() => { + expect(queue.isProcessing).toBe(false); + }); + expect(consumer).toHaveBeenCalledTimes(1); + + queue.stop(); + queue.enqueue([createItem(2)]); + + // Item is buffered but not processed (queue is stopped) + expect(queue.pending).toBe(1); + expect(consumer).toHaveBeenCalledTimes(1); + }); + }); + + describe("consumer error handling", () => { + it("removes failed item from inFlight", async () => { + const consumer = vi.fn(async () => { + throw new Error("fail"); + }); + const queue = new JobQueue(consumer); + + queue.enqueue([createItem(1), createItem(2)]); + queue.start(); + + await vi.waitFor(() => { + expect(queue.isProcessing).toBe(false); + }); + + // Item 1 failed but was removed from inFlight. + // Item 2 should also have been processed. + expect(consumer).toHaveBeenCalledTimes(2); + }); + }); +}); From e4da367dc096abed1d5b51cd333af6fecada759d Mon Sep 17 00:00:00 2001 From: mohamedhoss123 <81392900+mohamedhoss123@users.noreply.github.com> Date: Mon, 13 Jul 2026 14:14:03 +0300 Subject: [PATCH 2/2] feat(cron): integrate cron-parser for improved cron expression handling --- packages/systems/package.json | 1 + packages/systems/src/index.ts | 2 +- .../systems/src/scheduled-messages/cron.ts | 254 ++---------------- .../unit/scheduled-messages/cron.test.ts | 52 ---- pnpm-lock.yaml | 17 ++ 5 files changed, 46 insertions(+), 280 deletions(-) diff --git a/packages/systems/package.json b/packages/systems/package.json index 182061e..c5e61cb 100644 --- a/packages/systems/package.json +++ b/packages/systems/package.json @@ -367,6 +367,7 @@ "@fluxcore/database": "workspace:*", "@fluxcore/utils": "workspace:*", "@napi-rs/canvas": "^0.1.97", + "cron-parser": "^5.6.2", "discord.js": "catalog:", "zod": "^3.25.76" }, diff --git a/packages/systems/src/index.ts b/packages/systems/src/index.ts index 5f17772..529f6ef 100644 --- a/packages/systems/src/index.ts +++ b/packages/systems/src/index.ts @@ -65,7 +65,7 @@ export { checkAndGrantRewards } from "./leveling/rewards.js"; // Scheduled Messages export { getScheduledMessages, getScheduledMessageById, createScheduledMessage, updateScheduledMessage, deleteScheduledMessage, getDueMessages, markMessageExecuted } from "./scheduled-messages/persistence.js"; export { startScheduledMessageScheduler, stopScheduledMessageScheduler } from "./scheduled-messages/scheduler.js"; -export { parseCronExpression, validateCronExpression, getNextCronRun, describeCron } from "./scheduled-messages/cron.js"; +export { validateCronExpression, getNextCronRun, describeCron } from "./scheduled-messages/cron.js"; export { SCHEDULER_CHECK_INTERVAL_MS, MAX_SCHEDULED_MESSAGES_PER_GUILD, COMMON_TIMEZONES } from "./scheduled-messages/constants.js"; // Job Queue diff --git a/packages/systems/src/scheduled-messages/cron.ts b/packages/systems/src/scheduled-messages/cron.ts index 50e1348..5f59a17 100644 --- a/packages/systems/src/scheduled-messages/cron.ts +++ b/packages/systems/src/scheduled-messages/cron.ts @@ -1,233 +1,27 @@ -/** - * Lightweight cron expression utilities. - * - * Supports standard 5-field cron: minute hour day-of-month month day-of-week - * Uses pure date math instead of pulling in cron-parser at runtime. - */ +import { CronExpressionParser } from "cron-parser"; -interface CronFields { - minutes: number[]; - hours: number[]; - daysOfMonth: number[]; - months: number[]; - daysOfWeek: number[]; -} - -function parseField(field: string, min: number, max: number): number[] { - const values: Set = new Set(); - - for (const part of field.split(",")) { - const stepMatch = part.match(/^(.+)\/(\d+)$/); - const step = stepMatch ? parseInt(stepMatch[2], 10) : 1; - const range = stepMatch ? stepMatch[1] : part; - - if (range === "*") { - for (let i = min; i <= max; i += step) values.add(i); - } else if (range.includes("-")) { - const [startStr, endStr] = range.split("-"); - const start = parseInt(startStr, 10); - const end = parseInt(endStr, 10); - if (Number.isNaN(start) || Number.isNaN(end)) throw new Error(`Invalid cron range: ${range}`); - for (let i = start; i <= end; i += step) values.add(i); - } else { - const val = parseInt(range, 10); - if (Number.isNaN(val)) throw new Error(`Invalid cron value: ${range}`); - values.add(val); - } - } - - return [...values].sort((a, b) => a - b); -} - -export function parseCronExpression(expr: string): CronFields { - const parts = expr.trim().split(/\s+/); - if (parts.length !== 5) { - throw new Error(`Invalid cron expression: expected 5 fields, got ${parts.length}`); - } - - return { - minutes: parseField(parts[0], 0, 59), - hours: parseField(parts[1], 0, 23), - daysOfMonth: parseField(parts[2], 1, 31), - months: parseField(parts[3], 1, 12), - daysOfWeek: parseField(parts[4], 0, 6), - }; -} - -/** - * Validate a cron expression string. - * Returns null if valid, or an error message string. - */ export function validateCronExpression(expr: string): string | null { + if (!expr.trim()) return "Cron expression is empty"; try { - parseCronExpression(expr); + CronExpressionParser.parse(expr); return null; } catch (error) { return error instanceof Error ? error.message : "Invalid cron expression"; } } -/** - * Calculate the next run time for a cron expression after the given date. - * Timezone is handled by adjusting the reference date. - */ -export function getNextCronRun(cronExpr: string, timezone: string = "UTC", after?: Date): Date { - const fields = parseCronExpression(cronExpr); - const now = after ?? new Date(); - - // Work in the specified timezone by using Intl.DateTimeFormat - const formatter = new Intl.DateTimeFormat("en-US", { - timeZone: timezone, - year: "numeric", - month: "2-digit", - day: "2-digit", - hour: "2-digit", - minute: "2-digit", - second: "2-digit", - hour12: false, +export function getNextCronRun( + cronExpr: string, + timezone: string = "UTC", + after?: Date, +): Date { + const interval = CronExpressionParser.parse(cronExpr, { + tz: timezone, + currentDate: after ?? new Date(), }); - - const parts = formatter.formatToParts(now); - const getValue = (type: string): number => { - const part = parts.find((p) => p.type === type); - return part ? parseInt(part.value, 10) : 0; - }; - - let year = getValue("year"); - let month = getValue("month"); - let day = getValue("day"); - let hour = getValue("hour"); - let minute = getValue("minute") + 1; // Start from next minute - - // Search up to 366 days ahead - const maxIterations = 366 * 24 * 60; - for (let i = 0; i < maxIterations; i++) { - // Handle minute overflow - if (minute >= 60) { - minute = 0; - hour++; - } - if (hour >= 24) { - hour = 0; - day++; - } - - // Handle month/day overflow - const daysInMonth = new Date(year, month, 0).getDate(); - if (day > daysInMonth) { - day = 1; - month++; - } - if (month > 12) { - month = 1; - year++; - } - - // Check if this time matches the cron expression - if (!fields.months.includes(month)) { - // Skip to the next valid month - day = 1; - hour = 0; - minute = 0; - month++; - continue; - } - - if (!fields.daysOfMonth.includes(day)) { - hour = 0; - minute = 0; - day++; - continue; - } - - // Check day of week - const candidateDate = new Date(year, month - 1, day); - const dow = candidateDate.getDay(); - if (!fields.daysOfWeek.includes(dow)) { - hour = 0; - minute = 0; - day++; - continue; - } - - if (!fields.hours.includes(hour)) { - minute = 0; - hour++; - continue; - } - - if (!fields.minutes.includes(minute)) { - minute++; - continue; - } - - // We found a match — convert back from timezone to UTC - const tzDateStr = `${year}-${String(month).padStart(2, "0")}-${String(day).padStart(2, "0")}T${String(hour).padStart(2, "0")}:${String(minute).padStart(2, "0")}:00`; - - // Use a trick: create a date in the target timezone and find the UTC equivalent - const utcDate = zonedToUtc(tzDateStr, timezone); - - // Ensure result is after 'now' - if (utcDate.getTime() > now.getTime()) { - return utcDate; - } - - minute++; - } - - // Fallback: 24 hours from now (should not happen with valid cron) - return new Date(now.getTime() + 24 * 60 * 60 * 1000); + return interval.next().toDate(); } -/** - * Convert a date string in a specific timezone to a UTC Date. - */ -function zonedToUtc(dateStr: string, timezone: string): Date { - if (timezone === "UTC") { - return new Date(dateStr + "Z"); - } - - // Create a date assuming UTC first - const utcGuess = new Date(dateStr + "Z"); - - // Find the offset of the target timezone at this point in time - const formatter = new Intl.DateTimeFormat("en-US", { - timeZone: timezone, - year: "numeric", - month: "2-digit", - day: "2-digit", - hour: "2-digit", - minute: "2-digit", - second: "2-digit", - hour12: false, - }); - - const parts = formatter.formatToParts(utcGuess); - const getValue = (type: string): number => { - const part = parts.find((p) => p.type === type); - return part ? parseInt(part.value, 10) : 0; - }; - - const tzYear = getValue("year"); - const tzMonth = getValue("month"); - const tzDay = getValue("day"); - const tzHour = getValue("hour"); - const tzMinute = getValue("minute"); - - // The difference between what we wanted and what the timezone shows - // tells us the offset - const wanted = new Date(dateStr + "Z"); - const actual = new Date( - Date.UTC(tzYear, tzMonth - 1, tzDay, tzHour, tzMinute, 0), - ); - - const offsetMs = actual.getTime() - wanted.getTime(); - return new Date(wanted.getTime() + offsetMs); -} - -/** - * Get a human-readable description of a cron expression. - */ export function describeCron(cronExpr: string): string { const presets: Record = { "0 * * * *": "Every hour", @@ -241,24 +35,30 @@ export function describeCron(cronExpr: string): string { if (presets[cronExpr]) return presets[cronExpr]; try { - const fields = parseCronExpression(cronExpr); + const interval = CronExpressionParser.parse(cronExpr); + const fields = interval.fields; const parts: string[] = []; - if (fields.minutes.length === 1 && fields.hours.length === 1) { + const minutes = Array.from(fields.minute.values); + const hours = Array.from(fields.hour.values); + const daysOfMonth = Array.from(fields.dayOfMonth.values); + const daysOfWeek = Array.from(fields.dayOfWeek.values); + + if (minutes.length === 1 && hours.length === 1) { parts.push( - `at ${String(fields.hours[0]).padStart(2, "0")}:${String(fields.minutes[0]).padStart(2, "0")}`, + `at ${String(hours[0]).padStart(2, "0")}:${String(minutes[0]).padStart(2, "0")}`, ); - } else if (fields.minutes.length === 1) { - parts.push(`at minute ${fields.minutes[0]}`); + } else if (minutes.length === 1) { + parts.push(`at minute ${minutes[0]}`); } - if (fields.daysOfWeek.length < 7) { + if (daysOfWeek.length < 7) { const dayNames = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"]; - parts.push(`on ${fields.daysOfWeek.map((d) => dayNames[d]).join(", ")}`); + parts.push(`on ${daysOfWeek.map((d) => dayNames[Number(d)]).join(", ")}`); } - if (fields.daysOfMonth.length < 31) { - parts.push(`on day ${fields.daysOfMonth.join(", ")}`); + if (daysOfMonth.length < 31) { + parts.push(`on day ${daysOfMonth.join(", ")}`); } return parts.length > 0 ? parts.join(" ") : cronExpr; diff --git a/packages/systems/tests/unit/scheduled-messages/cron.test.ts b/packages/systems/tests/unit/scheduled-messages/cron.test.ts index d816bfd..c1a2ef3 100644 --- a/packages/systems/tests/unit/scheduled-messages/cron.test.ts +++ b/packages/systems/tests/unit/scheduled-messages/cron.test.ts @@ -1,62 +1,10 @@ import { describe, it, expect } from "vitest"; import { - parseCronExpression, validateCronExpression, getNextCronRun, describeCron, } from "../../../src/scheduled-messages/cron.js"; -describe("parseCronExpression", () => { - it("parses a simple daily cron", () => { - const result = parseCronExpression("0 9 * * *"); - expect(result.minutes).toEqual([0]); - expect(result.hours).toEqual([9]); - expect(result.daysOfMonth.length).toBe(31); - expect(result.months.length).toBe(12); - expect(result.daysOfWeek.length).toBe(7); - }); - - it("parses every hour", () => { - const result = parseCronExpression("0 * * * *"); - expect(result.minutes).toEqual([0]); - expect(result.hours.length).toBe(24); - }); - - it("parses a specific weekday", () => { - const result = parseCronExpression("0 9 * * 1"); - expect(result.daysOfWeek).toEqual([1]); - }); - - it("parses step expressions", () => { - const result = parseCronExpression("*/15 * * * *"); - expect(result.minutes).toEqual([0, 15, 30, 45]); - }); - - it("parses range expressions", () => { - const result = parseCronExpression("0 9-17 * * *"); - expect(result.hours).toEqual([9, 10, 11, 12, 13, 14, 15, 16, 17]); - }); - - it("parses comma-separated values", () => { - const result = parseCronExpression("0 9,12,18 * * *"); - expect(result.hours).toEqual([9, 12, 18]); - }); - - it("parses combined range with step", () => { - const result = parseCronExpression("0 */6 * * *"); - expect(result.hours).toEqual([0, 6, 12, 18]); - }); - - it("throws for invalid number of fields", () => { - expect(() => parseCronExpression("0 9 * *")).toThrow("expected 5 fields"); - expect(() => parseCronExpression("0 9 * * * *")).toThrow("expected 5 fields"); - }); - - it("throws for invalid values", () => { - expect(() => parseCronExpression("abc 9 * * *")).toThrow(); - }); -}); - describe("validateCronExpression", () => { it("returns null for valid expressions", () => { expect(validateCronExpression("0 9 * * *")).toBeNull(); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index da4e01f..b0238fd 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -354,6 +354,9 @@ importers: '@napi-rs/canvas': specifier: ^0.1.97 version: 0.1.97 + cron-parser: + specifier: ^5.6.2 + version: 5.6.2 discord.js: specifier: 'catalog:' version: 14.25.1 @@ -2249,6 +2252,10 @@ packages: resolution: {integrity: sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==} engines: {node: '>=18'} + cron-parser@5.6.2: + resolution: {integrity: sha512-yJ/G1LVir6CnlkLI40CsampPLKl1SprGGlUagWtGJxewytRVYezv5xVyzzbT+Pvzx+VIRvZVF7/a0eEMTxdnTA==} + engines: {node: '>=18'} + cross-fetch@4.0.0: resolution: {integrity: sha512-e4a5N8lVvuLgAWgnCrLr2PP0YyDOTHa9H/Rj54dirp61qXnNq46m82bRhNqIA5VccJtWBvPTFRV3TtvHUKPB1g==} @@ -2831,6 +2838,10 @@ packages: peerDependencies: react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0 + luxon@3.7.2: + resolution: {integrity: sha512-vtEhXh/gNjI9Yg1u4jX/0YVPMvxzHuGgCm6tC5kZyb08yjGWGnqAjGJvcXbqQR2P3MyMEFnRbpcdFS6PBcLqew==} + engines: {node: '>=12'} + lz-string@1.5.0: resolution: {integrity: sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==} hasBin: true @@ -5555,6 +5566,10 @@ snapshots: cookie@1.1.1: {} + cron-parser@5.6.2: + dependencies: + luxon: 3.7.2 + cross-fetch@4.0.0: dependencies: node-fetch: 2.7.0 @@ -6147,6 +6162,8 @@ snapshots: dependencies: react: 19.2.4 + luxon@3.7.2: {} + lz-string@1.5.0: {} magic-bytes.js@1.13.0: {}