diff --git a/.husky/pre-push b/.husky/pre-push index 465de7e..8ce4d1d 100755 --- a/.husky/pre-push +++ b/.husky/pre-push @@ -1,5 +1,2 @@ -#!/usr/bin/env sh -. "$(dirname -- "$0")/_/husky.sh" - npm run typecheck npm run test:cov \ No newline at end of file diff --git a/package.json b/package.json index 27e8bea..3f2e968 100644 --- a/package.json +++ b/package.json @@ -1,14 +1,14 @@ { "name": "@ciscode/scheduler-kit", "version": "0.0.0", - "description": "Typed job scheduler for NestJS. @Cron and @Interval and @Timeout decorators. Dynamic runtime scheduling. Concurrent execution guard.", + "description": "NestJS advanced scheduler wrapper with dynamic control and concurrency guards.", "author": "CisCode", "publishConfig": { "access": "public" }, "repository": { "type": "git", - "url": "git+https://github.com/CISCODE-MA/SchedulerKit.git" + "url": "git+https://github.com/CISCODE-MA/" }, "license": "MIT", "files": [ @@ -45,14 +45,10 @@ "peerDependencies": { "@nestjs/common": "^10 || ^11", "@nestjs/core": "^10 || ^11", - "@nestjs/platform-express": "^10 || ^11", + "@nestjs/schedule": "^4 || ^5", "reflect-metadata": "^0.2.2", "rxjs": "^7" }, - "dependencies": { - "class-transformer": "^0.5.1", - "class-validator": "^0.14.1" - }, "devDependencies": { "@changesets/cli": "^2.27.7", "@eslint/js": "^9.18.0", @@ -60,11 +56,15 @@ "@nestjs/core": "^10.4.0", "@nestjs/mapped-types": "^2.0.0", "@nestjs/platform-express": "^10.4.0", + "@nestjs/schedule": "^6.1.1", "@nestjs/testing": "^10.4.0", "@types/jest": "^29.5.14", "@types/node": "^22.10.7", + "cron": "^4.4.0", "eslint": "^9.18.0", + "eslint-config-prettier": "^10.1.8", "eslint-plugin-import": "^2.32.0", + "eslint-plugin-prettier": "^5.5.5", "globals": "^16.5.0", "husky": "^9.1.7", "jest": "^29.7.0", diff --git a/src/cron-builder.spec.ts b/src/cron-builder.spec.ts new file mode 100644 index 0000000..5f9d935 --- /dev/null +++ b/src/cron-builder.spec.ts @@ -0,0 +1,113 @@ +import { cron } from "./cron-builder"; + +describe("cron builder", () => { + describe("every(n).minutes()", () => { + it("should build every-5-minutes expression", () => { + expect(cron.every(5).minutes()).toBe("*/5 * * * *"); + }); + + it("should build every-1-minute expression", () => { + expect(cron.every(1).minutes()).toBe("*/1 * * * *"); + }); + + it("should build every-30-minutes expression", () => { + expect(cron.every(30).minutes()).toBe("*/30 * * * *"); + }); + }); + + describe("every(n).hours()", () => { + it("should build every-2-hours expression", () => { + expect(cron.every(2).hours()).toBe("0 */2 * * *"); + }); + + it("should build every-6-hours expression", () => { + expect(cron.every(6).hours()).toBe("0 */6 * * *"); + }); + }); + + describe("dailyAt()", () => { + it('should parse "9am" → 09:00', () => { + expect(cron.dailyAt("9am")).toBe("0 9 * * *"); + }); + + it('should parse "9pm" → 21:00', () => { + expect(cron.dailyAt("9pm")).toBe("0 21 * * *"); + }); + + it('should parse "9:30am" → 09:30', () => { + expect(cron.dailyAt("9:30am")).toBe("30 9 * * *"); + }); + + it('should parse "9:30pm" → 21:30', () => { + expect(cron.dailyAt("9:30pm")).toBe("30 21 * * *"); + }); + + it('should parse "12am" (midnight) → 00:00', () => { + expect(cron.dailyAt("12am")).toBe("0 0 * * *"); + }); + + it('should parse "12pm" (noon) → 12:00', () => { + expect(cron.dailyAt("12pm")).toBe("0 12 * * *"); + }); + + it('should parse 24-hour "14:30" → 14:30', () => { + expect(cron.dailyAt("14:30")).toBe("30 14 * * *"); + }); + + it('should parse 24-hour "00:00" → midnight', () => { + expect(cron.dailyAt("00:00")).toBe("0 0 * * *"); + }); + + it("should throw on invalid time format", () => { + expect(() => cron.dailyAt("invalid")).toThrow(); + }); + }); + + describe("weekdaysAt()", () => { + it("should produce Mon–Fri expression", () => { + expect(cron.weekdaysAt("9am")).toBe("0 9 * * 1-5"); + }); + + it("should respect time with minutes", () => { + expect(cron.weekdaysAt("9:30am")).toBe("30 9 * * 1-5"); + }); + }); + + describe("weekendsAt()", () => { + it("should produce Sat+Sun expression", () => { + expect(cron.weekendsAt("10am")).toBe("0 10 * * 6,0"); + }); + }); + + describe("weeklyOn()", () => { + it("should build monday expression", () => { + expect(cron.weeklyOn("monday", "9am")).toBe("0 9 * * 1"); + }); + + it("should build friday expression", () => { + expect(cron.weeklyOn("friday", "6pm")).toBe("0 18 * * 5"); + }); + + it("should build sunday expression", () => { + expect(cron.weeklyOn("sunday", "12am")).toBe("0 0 * * 0"); + }); + + it("should build saturday expression", () => { + expect(cron.weeklyOn("saturday", "8am")).toBe("0 8 * * 6"); + }); + }); + + describe("monthlyOn()", () => { + it("should build 1st of month at midnight", () => { + expect(cron.monthlyOn(1, "12am")).toBe("0 0 1 * *"); + }); + + it("should build 15th of month at noon", () => { + expect(cron.monthlyOn(15, "12pm")).toBe("0 12 15 * *"); + }); + + it("should build last-ish day at 9am", () => { + expect(cron.monthlyOn(28, "9am")).toBe("0 9 28 * *"); + }); + }); +}); diff --git a/src/cron-builder.ts b/src/cron-builder.ts new file mode 100644 index 0000000..84eaa20 --- /dev/null +++ b/src/cron-builder.ts @@ -0,0 +1,133 @@ +// ─── Types ───────────────────────────────────────────────────────────────────── + +export type DayOfWeek = + | "monday" + | "tuesday" + | "wednesday" + | "thursday" + | "friday" + | "saturday" + | "sunday"; + +// ─── Internal helpers ────────────────────────────────────────────────────────── + +const DAY_MAP: Record = { + sunday: 0, + monday: 1, + tuesday: 2, + wednesday: 3, + thursday: 4, + friday: 5, + saturday: 6, +}; + +/** + * Parse a human-readable time string into { hour, minute }. + * + * Accepted formats: + * '9am' → 09:00 + * '9pm' → 21:00 + * '9:30am' → 09:30 + * '9:30pm' → 21:30 + * '09:00' → 09:00 (24-hour) + * '21:30' → 21:30 (24-hour) + */ +function parseTime(time: string): { hour: number; minute: number } { + const ampm = /^(\d{1,2})(?::(\d{2}))?(am|pm)$/i.exec(time.trim()); + if (ampm) { + let hour = parseInt(ampm[1] as string, 10); + const minute = ampm[2] ? parseInt(ampm[2], 10) : 0; + const period = (ampm[3] as string).toLowerCase(); + if (period === "pm" && hour !== 12) hour += 12; + if (period === "am" && hour === 12) hour = 0; + return { hour, minute }; + } + const h24 = /^(\d{1,2}):(\d{2})$/.exec(time.trim()); + if (h24) { + return { hour: parseInt(h24[1] as string, 10), minute: parseInt(h24[2] as string, 10) }; + } + throw new Error(`Cannot parse time: "${time}". ` + `Use "9am", "9:30pm", "14:30", etc.`); +} + +// ─── Public API ──────────────────────────────────────────────────────────────── + +/** + * Fluent cron expression builder — no cron syntax required. + * + * @example + * import { cron } from '@ciscode/scheduler-kit'; + * + * cron.every(5).minutes() // "* /5 * * * *" + * cron.every(2).hours() // "0 * /2 * * *" + * cron.dailyAt('9am') // "0 9 * * *" + * cron.dailyAt('9:30pm') // "30 21 * * *" + * cron.weekdaysAt('9am') // "0 9 * * 1-5" + * cron.weekendsAt('10am') // "0 10 * * 6,0" + * cron.weeklyOn('monday', '9am') // "0 9 * * 1" + * cron.monthlyOn(1, '9am') // "0 9 1 * *" (1st of every month) + * cron.monthlyOn(15, '12pm') // "0 12 15 * *" (15th of every month) + */ +export const cron = { + /** + * Repeat every N minutes or hours. + * @example + * cron.every(5).minutes() // every 5 minutes + * cron.every(2).hours() // every 2 hours + */ + every(n: number) { + return { + minutes: (): string => `*/${n} * * * *`, + hours: (): string => `0 */${n} * * *`, + }; + }, + + /** + * Once a day at the specified time. + * @example + * cron.dailyAt('9am') // every day at 09:00 + * cron.dailyAt('9:30pm') // every day at 21:30 + * cron.dailyAt('00:00') // every day at midnight + */ + dailyAt(time: string): string { + const { hour, minute } = parseTime(time); + return `${minute} ${hour} * * *`; + }, + + /** + * Monday–Friday only at the specified time. + * @example cron.weekdaysAt('9am') + */ + weekdaysAt(time: string): string { + const { hour, minute } = parseTime(time); + return `${minute} ${hour} * * 1-5`; + }, + + /** + * Saturday + Sunday only at the specified time. + * @example cron.weekendsAt('10am') + */ + weekendsAt(time: string): string { + const { hour, minute } = parseTime(time); + return `${minute} ${hour} * * 6,0`; + }, + + /** + * Once a week on a specific day at the specified time. + * @example cron.weeklyOn('monday', '9am') + */ + weeklyOn(day: DayOfWeek, time: string): string { + const { hour, minute } = parseTime(time); + return `${minute} ${hour} * * ${DAY_MAP[day]}`; + }, + + /** + * Once a month on a specific day-of-month at the specified time. + * @example + * cron.monthlyOn(1, '9am') // 1st of every month at 09:00 + * cron.monthlyOn(15, '12pm') // 15th of every month at 12:00 + */ + monthlyOn(dayOfMonth: number, time: string): string { + const { hour, minute } = parseTime(time); + return `${minute} ${hour} ${dayOfMonth} * *`; + }, +} as const; diff --git a/src/cron-expression.spec.ts b/src/cron-expression.spec.ts new file mode 100644 index 0000000..fc6ead9 --- /dev/null +++ b/src/cron-expression.spec.ts @@ -0,0 +1,75 @@ +import { CronExpression } from "./cron-expression"; + +describe("CronExpression", () => { + it("should export EVERY_MINUTE", () => { + expect(CronExpression.EVERY_MINUTE).toBe("* * * * *"); + }); + + it("should export EVERY_5_MINUTES", () => { + expect(CronExpression.EVERY_5_MINUTES).toBe("*/5 * * * *"); + }); + + it("should export EVERY_10_MINUTES", () => { + expect(CronExpression.EVERY_10_MINUTES).toBe("*/10 * * * *"); + }); + + it("should export EVERY_15_MINUTES", () => { + expect(CronExpression.EVERY_15_MINUTES).toBe("*/15 * * * *"); + }); + + it("should export EVERY_30_MINUTES", () => { + expect(CronExpression.EVERY_30_MINUTES).toBe("*/30 * * * *"); + }); + + it("should export EVERY_HOUR", () => { + expect(CronExpression.EVERY_HOUR).toBe("0 * * * *"); + }); + + it("should export EVERY_2_HOURS", () => { + expect(CronExpression.EVERY_2_HOURS).toBe("0 */2 * * *"); + }); + + it("should export EVERY_6_HOURS", () => { + expect(CronExpression.EVERY_6_HOURS).toBe("0 */6 * * *"); + }); + + it("should export EVERY_12_HOURS", () => { + expect(CronExpression.EVERY_12_HOURS).toBe("0 */12 * * *"); + }); + + it("should export EVERY_DAY_AT_MIDNIGHT", () => { + expect(CronExpression.EVERY_DAY_AT_MIDNIGHT).toBe("0 0 * * *"); + }); + + it("should export EVERY_DAY_AT_9AM", () => { + expect(CronExpression.EVERY_DAY_AT_9AM).toBe("0 9 * * *"); + }); + + it("should export EVERY_DAY_AT_NOON", () => { + expect(CronExpression.EVERY_DAY_AT_NOON).toBe("0 12 * * *"); + }); + + it("should export EVERY_DAY_AT_6PM", () => { + expect(CronExpression.EVERY_DAY_AT_6PM).toBe("0 18 * * *"); + }); + + it("should export EVERY_WEEKDAY_9AM", () => { + expect(CronExpression.EVERY_WEEKDAY_9AM).toBe("0 9 * * 1-5"); + }); + + it("should export EVERY_WEEKEND_MIDNIGHT", () => { + expect(CronExpression.EVERY_WEEKEND_MIDNIGHT).toBe("0 0 * * 6,0"); + }); + + it("should export EVERY_MONDAY_9AM", () => { + expect(CronExpression.EVERY_MONDAY_9AM).toBe("0 9 * * 1"); + }); + + it("should export EVERY_SUNDAY_MIDNIGHT", () => { + expect(CronExpression.EVERY_SUNDAY_MIDNIGHT).toBe("0 0 * * 0"); + }); + + it("should export FIRST_OF_MONTH", () => { + expect(CronExpression.FIRST_OF_MONTH).toBe("0 0 1 * *"); + }); +}); diff --git a/src/cron-expression.ts b/src/cron-expression.ts new file mode 100644 index 0000000..3ce5959 --- /dev/null +++ b/src/cron-expression.ts @@ -0,0 +1,34 @@ +/** + * Human-readable cron expression constants. + * + * Use these instead of raw cron strings so every developer on the team + * can read the schedule at a glance — no cron knowledge required. + * + * @example + * import { CronExpression } from '@ciscode/scheduler-kit'; + * + * @Cron(CronExpression.EVERY_DAY_AT_9AM, 'daily-digest') + * async sendDailyDigest() { ... } + */ +export const CronExpression = { + EVERY_MINUTE: "* * * * *", + EVERY_5_MINUTES: "*/5 * * * *", + EVERY_10_MINUTES: "*/10 * * * *", + EVERY_15_MINUTES: "*/15 * * * *", + EVERY_30_MINUTES: "*/30 * * * *", + EVERY_HOUR: "0 * * * *", + EVERY_2_HOURS: "0 */2 * * *", + EVERY_6_HOURS: "0 */6 * * *", + EVERY_12_HOURS: "0 */12 * * *", + EVERY_DAY_AT_MIDNIGHT: "0 0 * * *", + EVERY_DAY_AT_9AM: "0 9 * * *", + EVERY_DAY_AT_NOON: "0 12 * * *", + EVERY_DAY_AT_6PM: "0 18 * * *", + EVERY_WEEKDAY_9AM: "0 9 * * 1-5", // Mon–Fri at 09:00 + EVERY_WEEKEND_MIDNIGHT: "0 0 * * 6,0", // Sat + Sun at 00:00 + EVERY_MONDAY_9AM: "0 9 * * 1", + EVERY_SUNDAY_MIDNIGHT: "0 0 * * 0", + FIRST_OF_MONTH: "0 0 1 * *", // 1st day of every month at midnight +} as const; + +export type CronExpressionKey = keyof typeof CronExpression; diff --git a/src/errors/duplicate-job.error.spec.ts b/src/errors/duplicate-job.error.spec.ts new file mode 100644 index 0000000..773702b --- /dev/null +++ b/src/errors/duplicate-job.error.spec.ts @@ -0,0 +1,23 @@ +import { DuplicateJobError } from "./duplicate-job.error"; + +describe("DuplicateJobError", () => { + it("is an instance of Error", () => { + expect(new DuplicateJobError("job")).toBeInstanceOf(Error); + }); + + it("has name DuplicateJobError", () => { + expect(new DuplicateJobError("job").name).toBe("DuplicateJobError"); + }); + + it("message contains the job name", () => { + expect(new DuplicateJobError("send-report").message).toContain("send-report"); + }); + + it("can be caught with instanceof check", () => { + try { + throw new DuplicateJobError("my-job"); + } catch (e) { + expect(e).toBeInstanceOf(DuplicateJobError); + } + }); +}); diff --git a/src/errors/duplicate-job.error.ts b/src/errors/duplicate-job.error.ts new file mode 100644 index 0000000..088de8a --- /dev/null +++ b/src/errors/duplicate-job.error.ts @@ -0,0 +1,17 @@ +/** + * Thrown when trying to schedule a job whose name is already registered. + * + * @example + * ```typescript + * throw new DuplicateJobError('send-report'); + * // → "Job 'send-report' is already registered. Use reschedule() to change its timing." + * ``` + */ +export class DuplicateJobError extends Error { + constructor(name: string) { + super(`Job '${name}' is already registered. Use reschedule() to change its timing.`); + this.name = "DuplicateJobError"; + // Maintains proper prototype chain in transpiled ES5 targets + Object.setPrototypeOf(this, new.target.prototype); + } +} diff --git a/src/index.ts b/src/index.ts index 3026198..983d161 100644 --- a/src/index.ts +++ b/src/index.ts @@ -3,58 +3,28 @@ import "reflect-metadata"; // ============================================================================ // PUBLIC API EXPORTS // ============================================================================ -// This file defines what consumers of your module can import. -// ONLY export what is necessary for external use. -// Keep entities, repositories, and internal implementation details private. // ============================================================================ -// MODULE +// INTERFACES & TYPES // ============================================================================ -export { ExampleKitModule } from "./example-kit.module"; -export type { ExampleKitOptions, ExampleKitAsyncOptions } from "./example-kit.module"; +export type { + IScheduler, + ScheduledJob, + ScheduleTiming, + CronSchedule, + IntervalSchedule, + TimeoutSchedule, +} from "./interfaces/scheduler.interface"; // ============================================================================ -// SERVICES (Main API) +// ERRORS // ============================================================================ -// Export services that consumers will interact with -export { ExampleService } from "./services/example.service"; +export { DuplicateJobError } from "./errors/duplicate-job.error"; // ============================================================================ -// DTOs (Public Contracts) +// HELPERS // ============================================================================ -// DTOs are the public interface for your API -// Consumers depend on these, so they must be stable -export { CreateExampleDto } from "./dto/create-example.dto"; -export { UpdateExampleDto } from "./dto/update-example.dto"; - -// ============================================================================ -// GUARDS (For Route Protection) -// ============================================================================ -// Export guards so consumers can use them in their apps -export { ExampleGuard } from "./guards/example.guard"; - -// ============================================================================ -// DECORATORS (For Dependency Injection & Metadata) -// ============================================================================ -// Export decorators for use in consumer controllers/services -export { ExampleData, ExampleParam } from "./decorators/example.decorator"; - -// ============================================================================ -// TYPES & INTERFACES (For TypeScript Typing) -// ============================================================================ -// Export types and interfaces for TypeScript consumers -// export type { YourCustomType } from './types'; - -// ============================================================================ -// ❌ NEVER EXPORT (Internal Implementation) -// ============================================================================ -// These should NEVER be exported from a module: -// - Entities (internal domain models) -// - Repositories (infrastructure details) -// -// Example of what NOT to export: -// ❌ export { Example } from './entities/example.entity'; -// ❌ export { ExampleRepository } from './repositories/example.repository'; -// -// Why? These are internal implementation details that can change. -// Consumers should only work with DTOs and Services. +export { CronExpression } from "./cron-expression"; +export type { CronExpressionKey } from "./cron-expression"; +export { cron } from "./cron-builder"; +export type { DayOfWeek } from "./cron-builder"; diff --git a/src/interfaces/scheduler.interface.ts b/src/interfaces/scheduler.interface.ts new file mode 100644 index 0000000..c7813a6 --- /dev/null +++ b/src/interfaces/scheduler.interface.ts @@ -0,0 +1,50 @@ +// ─── Timing Options (Discriminated Union) ───────────────────────────────────── +// Each variant has a unique literal key, so TypeScript enforces that only ONE +// timing type can be set at a time. You cannot pass both `cron` and `interval`. + +export type CronSchedule = { + cron: string; + interval?: never; + timeout?: never; +}; + +export type IntervalSchedule = { + interval: number; + cron?: never; + timeout?: never; +}; + +export type TimeoutSchedule = { + timeout: number; + cron?: never; + interval?: never; +}; + +export type ScheduleTiming = CronSchedule | IntervalSchedule | TimeoutSchedule; + +// ─── ScheduledJob ────────────────────────────────────────────────────────────── +// A full job definition: name + handler + exactly one timing type. + +export type ScheduledJob = { + /** Unique identifier for the job. Used for unschedule / reschedule lookups. */ + name: string; + /** The async function that runs when the timer fires. */ + handler: () => Promise | void; +} & ScheduleTiming; + +// ─── IScheduler ──────────────────────────────────────────────────────────────── +// The public contract that SchedulerService implements. + +export interface IScheduler { + /** Register and start a new job. Throws DuplicateJobError if name already exists. */ + schedule(job: ScheduledJob): void; + + /** Stop and remove a job by name. No-op if the job does not exist. */ + unschedule(name: string): void; + + /** Atomically stop the old schedule and start a new one for the same job name. */ + reschedule(name: string, newTiming: ScheduleTiming): void; + + /** Return a snapshot of all currently registered job names. */ + list(): string[]; +} diff --git a/tsconfig.build.json b/tsconfig.build.json index 22fa5d9..661fe66 100644 --- a/tsconfig.build.json +++ b/tsconfig.build.json @@ -3,8 +3,16 @@ "compilerOptions": { "noEmit": false, "emitDeclarationOnly": false, - "outDir": "dist" + "outDir": "dist", + "module": "CommonJS", + "moduleResolution": "Node" }, - "include": ["src/**/*.ts"], - "exclude": ["test", "**/*.spec.ts", "**/*.test.ts", "dist", "node_modules"] + "include": [ + "src/index.ts", + "src/cron-expression.ts", + "src/cron-builder.ts", + "src/interfaces/scheduler.interface.ts", + "src/errors/duplicate-job.error.ts" + ], + "exclude": ["dist", "node_modules", "**/*.spec.ts", "**/*.test.ts"] }