From ed9ed93c91c65c982024fd319ea48b948d98f848 Mon Sep 17 00:00:00 2001 From: Giovani Guizzo Date: Sun, 3 May 2026 10:14:41 -0300 Subject: [PATCH 1/6] feat: add scheduleOptions support to JobBuilder for node-cron integration Co-authored-by: Copilot --- .yarnrc.yml | 7 +- package.json | 2 +- packages/engine/src/engine.ts | 3 + packages/engine/src/job/constants.ts | 1 + packages/engine/src/job/job-builder.test.ts | 72 +++++++++++++++++++++ packages/engine/src/job/job-builder.ts | 29 ++++++++- yarn.lock | 2 +- 7 files changed, 111 insertions(+), 5 deletions(-) diff --git a/.yarnrc.yml b/.yarnrc.yml index 8b757b29..9104d775 100644 --- a/.yarnrc.yml +++ b/.yarnrc.yml @@ -1 +1,6 @@ -nodeLinker: node-modules \ No newline at end of file +approvedGitRepositories: + - "**" + +enableScripts: true + +nodeLinker: node-modules diff --git a/package.json b/package.json index b4fd7b07..2e4de9da 100644 --- a/package.json +++ b/package.json @@ -86,5 +86,5 @@ "vitepress": "^1.6.4", "vitest": "^4.0.18" }, - "packageManager": "yarn@4.13.0" + "packageManager": "yarn@4.14.1" } diff --git a/packages/engine/src/engine.ts b/packages/engine/src/engine.ts index bebfd120..dd5ca6c1 100644 --- a/packages/engine/src/engine.ts +++ b/packages/engine/src/engine.ts @@ -168,6 +168,9 @@ export class Engine { availableAt: config?.jobDefaults?.availableAt, timeout: config?.jobDefaults?.timeout ?? JOB_BUILDER_FALLBACK.timeout!, uniqueness: config?.jobDefaults?.uniqueness ?? JOB_BUILDER_FALLBACK.uniqueness!, + backoffStrategy: config?.jobDefaults?.backoffStrategy ?? JOB_BUILDER_FALLBACK.backoffStrategy!, + retryDelay: config?.jobDefaults?.retryDelay ?? JOB_BUILDER_FALLBACK.retryDelay!, + scheduleOptions: config?.jobDefaults?.scheduleOptions ?? JOB_BUILDER_FALLBACK.scheduleOptions!, }, queueDefaults: { concurrency: config?.queueDefaults?.concurrency ?? QUEUE_FALLBACK.concurrency, diff --git a/packages/engine/src/job/constants.ts b/packages/engine/src/job/constants.ts index 09fd2f7f..01181c8a 100644 --- a/packages/engine/src/job/constants.ts +++ b/packages/engine/src/job/constants.ts @@ -25,4 +25,5 @@ export const JOB_BUILDER_FALLBACK: JobBuilderDefaults & { constructorArgs: unkno constructorArgs: [], retryDelay: 1000, backoffStrategy: JOB_FALLBACK.backoff_strategy, + scheduleOptions: { noOverlap: true }, }; diff --git a/packages/engine/src/job/job-builder.test.ts b/packages/engine/src/job/job-builder.test.ts index 37158717..3528565e 100644 --- a/packages/engine/src/job/job-builder.test.ts +++ b/packages/engine/src/job/job-builder.test.ts @@ -456,6 +456,78 @@ describe("JobBuilder", () => { await expect(() => jobBuilder.schedule("invalid-cron")).rejects.toThrow("Invalid cron expression invalid-cron"); }); + + describe("scheduleOptions", () => { + it("passes default scheduleOptions { noOverlap: true } to node-cron when no options set", async () => { + const createNewJobMock = vi.fn().mockResolvedValue({} as JobData); + const backendMock = { createNewJob: createNewJobMock } as unknown as Backend; + jobBuilder = new JobBuilder(backendMock, DummyJob); + + await jobBuilder.schedule("* * * * *"); + + const [, , options] = scheduleMock.mock.calls[0] as [string, unknown, unknown]; + expect(options).toEqual({ noOverlap: true }); + }); + + it("passes custom timezone via scheduleOptions to node-cron", async () => { + const createNewJobMock = vi.fn().mockResolvedValue({} as JobData); + const backendMock = { createNewJob: createNewJobMock } as unknown as Backend; + jobBuilder = new JobBuilder(backendMock, DummyJob); + + await jobBuilder.scheduleOptions({ timezone: "Australia/Brisbane" }).schedule("0 9 * * *"); + + const [, , options] = scheduleMock.mock.calls[0] as [string, unknown, unknown]; + expect(options).toEqual({ timezone: "Australia/Brisbane" }); + }); + + it("allows overriding noOverlap to false via scheduleOptions", async () => { + const createNewJobMock = vi.fn().mockResolvedValue({} as JobData); + const backendMock = { createNewJob: createNewJobMock } as unknown as Backend; + jobBuilder = new JobBuilder(backendMock, DummyJob); + + await jobBuilder.scheduleOptions({ noOverlap: false }).schedule("* * * * *"); + + const [, , options] = scheduleMock.mock.calls[0] as [string, unknown, unknown]; + expect(options).toEqual({ noOverlap: false }); + }); + + it("allows combining timezone and noOverlap via scheduleOptions", async () => { + const createNewJobMock = vi.fn().mockResolvedValue({} as JobData); + const backendMock = { createNewJob: createNewJobMock } as unknown as Backend; + jobBuilder = new JobBuilder(backendMock, DummyJob); + + await jobBuilder.scheduleOptions({ timezone: "Australia/Brisbane", noOverlap: true }).schedule("0 9 * * *"); + + const [, , options] = scheduleMock.mock.calls[0] as [string, unknown, unknown]; + expect(options).toEqual({ timezone: "Australia/Brisbane", noOverlap: true }); + }); + + it("uses scheduleOptions from constructor defaults", async () => { + const createNewJobMock = vi.fn().mockResolvedValue({} as JobData); + const backendMock = { createNewJob: createNewJobMock } as unknown as Backend; + jobBuilder = new JobBuilder(backendMock, DummyJob, { + scheduleOptions: { timezone: "Europe/Rome", noOverlap: false }, + }); + + await jobBuilder.schedule("* * * * *"); + + const [, , options] = scheduleMock.mock.calls[0] as [string, unknown, unknown]; + expect(options).toEqual({ timezone: "Europe/Rome", noOverlap: false }); + }); + + it("scheduleOptions() method overrides constructor defaults", async () => { + const createNewJobMock = vi.fn().mockResolvedValue({} as JobData); + const backendMock = { createNewJob: createNewJobMock } as unknown as Backend; + jobBuilder = new JobBuilder(backendMock, DummyJob, { + scheduleOptions: { timezone: "Europe/Rome" }, + }); + + await jobBuilder.scheduleOptions({ timezone: "Australia/Brisbane" }).schedule("* * * * *"); + + const [, , options] = scheduleMock.mock.calls[0] as [string, unknown, unknown]; + expect(options).toEqual({ timezone: "Australia/Brisbane" }); + }); + }); }); describe("manualJobResolution", () => { diff --git a/packages/engine/src/job/job-builder.ts b/packages/engine/src/job/job-builder.ts index f5c621a0..d2653d6b 100644 --- a/packages/engine/src/job/job-builder.ts +++ b/packages/engine/src/job/job-builder.ts @@ -14,7 +14,7 @@ import { UniquenessConfig, UniquenessFactory, } from "@sidequest/core"; -import nodeCron, { ScheduledTask } from "node-cron"; +import nodeCron, { ScheduledTask, TaskOptions } from "node-cron"; import { inspect } from "node:util"; import { MANUAL_SCRIPT_TAG } from "../shared-runner"; import { JOB_BUILDER_FALLBACK } from "./constants"; @@ -62,6 +62,8 @@ export interface JobBuilderDefaults { retryDelay?: number; /** Default backoff strategy for jobs built with the JobBuilder */ backoffStrategy?: BackoffStrategy; + /** Default node-cron schedule options (e.g. timezone) for jobs built with the JobBuilder */ + scheduleOptions?: TaskOptions; } /** @@ -77,6 +79,7 @@ export class JobBuilder { protected jobAvailableAt?: Date; protected jobRetryDelay?: number; protected jobBackoffStrategy?: BackoffStrategy; + protected jobScheduleOptions?: TaskOptions; /** * Creates a new JobBuilder for the given job class. @@ -96,6 +99,7 @@ export class JobBuilder { this.with(...(JOB_BUILDER_FALLBACK.constructorArgs as unknown as ConstructorParameters)); this.retryDelay(this.defaults?.retryDelay ?? JOB_BUILDER_FALLBACK.retryDelay!); this.backoffStrategy(this.defaults?.backoffStrategy ?? JOB_BUILDER_FALLBACK.backoffStrategy!); + this.scheduleOptions(this.defaults?.scheduleOptions ?? JOB_BUILDER_FALLBACK.scheduleOptions!); } /** @@ -209,6 +213,27 @@ export class JobBuilder { return this; } + /** + * Sets node-cron schedule options used by {@link schedule}. + * + * @remarks + * `noOverlap` defaults to `true` if not explicitly set here or in the builder defaults. + * + * @param options node-cron `TaskOptions` (e.g. `timezone`, `noOverlap`). + * @returns This builder instance. + * + * @example + * ```typescript + * Sidequest.build(MyJob) + * .scheduleOptions({ timezone: "Australia/Brisbane" }) + * .schedule("0 9 * * *"); + * ``` + */ + scheduleOptions(options: TaskOptions): this { + this.jobScheduleOptions = options; + return this; + } + protected async build(...args: Parameters["run"]>): Promise { const job = new this.JobClass(...this.constructorArgs!); @@ -313,7 +338,7 @@ export class JobBuilder { ); return this.backend.createNewJob(jobData); }, - { noOverlap: true }, + this.jobScheduleOptions, ); // Register the scheduled task with the ScheduledJobRegistry for proper later cleanup diff --git a/yarn.lock b/yarn.lock index 3b6f9e81..135c9983 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2,7 +2,7 @@ # Manual changes might be lost - proceed with caution! __metadata: - version: 8 + version: 9 cacheKey: 10c0 "@actions/core@npm:^2.0.0": From 4990bfb85ab51b6a3594c591bfb556beffbfa2bb Mon Sep 17 00:00:00 2001 From: Giovani Guizzo Date: Sun, 3 May 2026 10:20:28 -0300 Subject: [PATCH 2/6] feat: add timezone support and scheduleOptions to enhance job scheduling flexibility Co-authored-by: Copilot --- packages/docs/engine/enqueue.md | 40 +++++++++++++++++++++++++++++++++ packages/docs/jobs/recurring.md | 26 ++++++++++++++++++++- 2 files changed, 65 insertions(+), 1 deletion(-) diff --git a/packages/docs/engine/enqueue.md b/packages/docs/engine/enqueue.md index c325d969..c0f3d771 100644 --- a/packages/docs/engine/enqueue.md +++ b/packages/docs/engine/enqueue.md @@ -327,6 +327,46 @@ await Sidequest.build(EmailJob).enqueue("user@example.com", "Hello", "Message bo **Default**: `[]` (no run method arguments) +### `.scheduleOptions(options: TaskOptions)` + +Configures [node-cron](https://www.npmjs.com/package/node-cron) options used when calling `.schedule()`. The most common use case is setting a `timezone` so recurring jobs fire relative to a specific region rather than the server's local time. + +```typescript +// Fire at 9 AM Brisbane time regardless of server timezone +await Sidequest.build(DailyReportJob).scheduleOptions({ timezone: "Australia/Brisbane" }).schedule("0 9 * * *"); + +// Multiple options +await Sidequest.build(MyJob) + .scheduleOptions({ timezone: "America/New_York", noOverlap: false }) + .schedule("*/5 * * * *"); +``` + +**Default:** `{ noOverlap: true }` + +**Available options** (from node-cron's `TaskOptions`): + +| Option | Type | Description | +| ---------------- | --------- | --------------------------------------------------------------------------- | +| `timezone` | `string` | IANA timezone name (e.g. `"Europe/Berlin"`, `"America/New_York"`). | +| `noOverlap` | `boolean` | Skip a tick if the previous execution is still running. Defaults to `true`. | +| `name` | `string` | Optional label for the task in node-cron's registry. | +| `maxExecutions` | `number` | Stop the task after this many executions. | +| `maxRandomDelay` | `number` | Add a random delay (ms) to each execution. | + +::: tip Setting a default via configuration +You can set a default `scheduleOptions` for all jobs by passing it through `jobDefaults` in `Sidequest.configure()` or `Sidequest.start()`: + +```typescript +await Sidequest.start({ + jobDefaults: { + scheduleOptions: { timezone: "Australia/Brisbane" }, + }, +}); +``` + +The per-call `.scheduleOptions()` method always overrides the default. +::: + ### `.schedule(cronExpression: string, ...args?: unknown[])` For recurring jobs, you can use the `.schedule()` method instead of `.enqueue()`: diff --git a/packages/docs/jobs/recurring.md b/packages/docs/jobs/recurring.md index dbd688ee..b10f5a1b 100644 --- a/packages/docs/jobs/recurring.md +++ b/packages/docs/jobs/recurring.md @@ -62,7 +62,31 @@ Sidequest.build(MyJob).schedule("*/5 * * * * *", "foo"); // Every 5 seconds with #### Notes - **In-memory only:** Scheduled tasks are NOT persisted in the database. You must re-register schedules on every app startup. -- **No overlap:** Sidequest uses `noOverlap: true` by default—if a previous run is still in progress, a new job will not be enqueued for that tick. +- **No overlap:** Sidequest uses `noOverlap: true` by default - if a previous run is still in progress, a new job will not be enqueued for that tick. You can override this via `.scheduleOptions({ noOverlap: false })`. + +## Timezone Support + +By default, cron expressions are evaluated in the server's local time. Use `.scheduleOptions()` to specify an [IANA timezone](https://en.wikipedia.org/wiki/List_of_tz_database_time_zones) so jobs fire on local business hours regardless of where the server is running. + +```ts +// Fire at 9 AM every weekday in Brisbane +Sidequest.build(MyJob).scheduleOptions({ timezone: "Australia/Brisbane" }).schedule("0 9 * * 1-5"); + +// Pass run-method arguments alongside timezone +Sidequest.build(ReportJob) + .scheduleOptions({ timezone: "America/New_York" }) + .schedule("0 8 * * *", { region: "us-east" }); +``` + +`.scheduleOptions()` accepts any option supported by node-cron's `TaskOptions`: + +| Option | Type | Description | +| ---------------- | --------- | -------------------------------------------------------------------------- | +| `timezone` | `string` | IANA timezone name (e.g. `"Europe/Berlin"`). | +| `noOverlap` | `boolean` | Skip a tick if the previous run is still in progress. **Default: `true`**. | +| `name` | `string` | Optional label for the task in node-cron's registry. | +| `maxExecutions` | `number` | Stop the task after N executions. | +| `maxRandomDelay` | `number` | Add a random delay (ms) to each execution. | ## Limitations and Recommendations From a366e80b4c12646de7ca2656db6d2ee5392a5d15 Mon Sep 17 00:00:00 2001 From: Giovani Guizzo Date: Sun, 3 May 2026 10:33:31 -0300 Subject: [PATCH 3/6] chore: remove approvedGitRepositories configuration from .yarnrc.yml --- .yarnrc.yml | 3 --- 1 file changed, 3 deletions(-) diff --git a/.yarnrc.yml b/.yarnrc.yml index 9104d775..ff249820 100644 --- a/.yarnrc.yml +++ b/.yarnrc.yml @@ -1,6 +1,3 @@ -approvedGitRepositories: - - "**" - enableScripts: true nodeLinker: node-modules From 11755bb70718ee8ecc745906e04ffb1ded432e0a Mon Sep 17 00:00:00 2001 From: Giovani Guizzo Date: Sun, 3 May 2026 10:52:12 -0300 Subject: [PATCH 4/6] feat: add exponential backoff strategy to retry job in integration tests --- tests/integration/shared-test-suite.js | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/tests/integration/shared-test-suite.js b/tests/integration/shared-test-suite.js index 80efc09c..a3a15a7f 100644 --- a/tests/integration/shared-test-suite.js +++ b/tests/integration/shared-test-suite.js @@ -194,7 +194,10 @@ export function createIntegrationTestSuite(Sidequest, jobs, moduleType = "ESM") }); const startTime = Date.now(); - const jobData = await Sidequest.build(RetryJob).maxAttempts(3).enqueue("retry-with-delay"); + const jobData = await Sidequest.build(RetryJob) + .maxAttempts(3) + .backoffStrategy("exponential") + .enqueue("retry-with-delay"); // Wait for job to complete (should take some time due to retry delays) await vi.waitUntil(async () => { From dbcdfb9a5663f49e0b75c2d7993094273df1bb0f Mon Sep 17 00:00:00 2001 From: Giovani Guizzo Date: Sun, 3 May 2026 11:02:26 -0300 Subject: [PATCH 5/6] feat: add retryDelay option to exponential backoff strategy in integration tests --- tests/integration/shared-test-suite.js | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/integration/shared-test-suite.js b/tests/integration/shared-test-suite.js index a3a15a7f..a6cdd583 100644 --- a/tests/integration/shared-test-suite.js +++ b/tests/integration/shared-test-suite.js @@ -197,6 +197,7 @@ export function createIntegrationTestSuite(Sidequest, jobs, moduleType = "ESM") const jobData = await Sidequest.build(RetryJob) .maxAttempts(3) .backoffStrategy("exponential") + .retryDelay(500) .enqueue("retry-with-delay"); // Wait for job to complete (should take some time due to retry delays) From fec7ded83470b9e68bfb8a4285308035b1cbfb65 Mon Sep 17 00:00:00 2001 From: Giovani Guizzo Date: Sun, 3 May 2026 11:10:46 -0300 Subject: [PATCH 6/6] feat: enhance waitUntil function with configurable interval and timeout --- tests/integration/shared-test-suite.js | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/tests/integration/shared-test-suite.js b/tests/integration/shared-test-suite.js index a6cdd583..125b6ae6 100644 --- a/tests/integration/shared-test-suite.js +++ b/tests/integration/shared-test-suite.js @@ -611,10 +611,16 @@ export function createIntegrationTestSuite(Sidequest, jobs, moduleType = "ESM") await Sidequest.build(SuccessJob).schedule("*/2 * * * * *", "job-2"); let currentJobs; - await vi.waitUntil(async () => { - currentJobs = await Sidequest.job.list(); - return currentJobs.length >= 3 && currentJobs.every((job) => job.state === "completed"); - }, 5000); + await vi.waitUntil( + async () => { + currentJobs = await Sidequest.job.list(); + return currentJobs.length >= 3 && currentJobs.every((job) => job.state === "completed"); + }, + { + interval: 100, + timeout: 5000, + }, + ); const job1Executions = currentJobs.filter((job) => job.args[0] === "job-1"); const job2Executions = currentJobs.filter((job) => job.args[0] === "job-2");