diff --git a/README.md b/README.md index 7bd589d..67a0b0c 100644 --- a/README.md +++ b/README.md @@ -115,6 +115,7 @@ The job will be sceduled to run at next matching time unless you supply option ` job.nextRun( /*optional*/ startFromDate ); // Get a Date object representing the next run. job.nextRuns(10, /*optional*/ startFromDate ); // Get an array of Dates, containing the next n runs. job.previousRuns(10, /*optional*/ referenceDate ); // Get an array of Dates, containing previous n scheduled runs. +job.enumerate( /*optional*/ startFromDate ); // Get a stateful CronIterator for use in for...of / destructuring. job.msToNext( /*optional*/ startFromDate ); // Get the milliseconds left until the next execution. job.currentRun(); // Get a Date object showing when the current (or last) run was started. job.previousRun( ); // Get a Date object showing when the previous job was started. diff --git a/docs/src/usage/basics.md b/docs/src/usage/basics.md index 159cf93..6bee51e 100644 --- a/docs/src/usage/basics.md +++ b/docs/src/usage/basics.md @@ -37,6 +37,7 @@ Check the status of the job using the following methods: job.nextRun( /*optional*/ startFromDate ); // Get a Date object representing the next run. job.nextRuns(10, /*optional*/ startFromDate ); // Get an array of Dates, containing the next n runs. job.previousRuns(10, /*optional*/ referenceDate ); // Get an array of Dates, containing previous n scheduled runs. +job.enumerate( /*optional*/ startFromDate ); // Get a stateful CronIterator for use in for...of / destructuring. job.msToNext( /*optional*/ startFromDate ); // Get the milliseconds left until the next execution. job.currentRun(); // Get a Date object showing when the current (or last) run was started. job.previousRun( ); // Get a Date object showing when the previous job was started. diff --git a/docs/src/usage/examples.md b/docs/src/usage/examples.md index 07bf5a8..4106036 100644 --- a/docs/src/usage/examples.md +++ b/docs/src/usage/examples.md @@ -47,6 +47,45 @@ previousRuns.forEach((run, i) => { // 5. 2024-01-11T00:00:00.000Z ``` +### Stateful iterator (enumerate) + +`enumerate()` returns a `CronIterator` that implements the ECMAScript Iterator and +Iterable protocols. Use it with `for...of` loops, destructuring, or any other construct +that consumes iterables. + +```ts +// for...of — collect the next 3 daily runs starting from a reference date +const job = new Cron("0 0 0 * * *"); +const start = new Date("2024-06-01T00:00:00"); + +let count = 0; +for (const date of job.enumerate(start)) { + console.log(date.toISOString()); + // Stop after printing 3 dates + if (++count >= 3) break; +} + +// Destructuring — capture the next two occurrences +const [next1, next2] = job.enumerate(start); +console.log("Next: ", next1.toISOString()); +console.log("After that: ", next2.toISOString()); + +// peek() — look at the upcoming date without advancing +const iter = job.enumerate(start); +console.log("Upcoming:", iter.peek()?.toISOString()); // does not advance +console.log("First: ", iter.next().value?.toISOString()); // same date + +// reset() — restart from a new point +iter.reset(new Date("2024-07-01T00:00:00")); +console.log("After reset:", iter.next().value?.toISOString()); // 2024-07-02 + +// Finite schedule — iterator signals done automatically when stopAt is reached +const finite = new Cron("0 0 0 * * *", { stopAt: "2024-06-03T00:00:00" }); +for (const date of finite.enumerate(start)) { + console.log(date.toISOString()); // prints 2024-06-02 only (stopAt is exclusive) +} +``` + ### Get run-once date ```ts diff --git a/src/croner.ts b/src/croner.ts index 66fd836..4ede8e5 100644 --- a/src/croner.ts +++ b/src/croner.ts @@ -260,6 +260,19 @@ class Cron { return this._enumerateRuns(n, reference || undefined, "previous"); } + /** + * Create a stateful iterator for sequential date traversal of this schedule. + * + * Returns a {@link CronIterator} that implements both the ECMAScript Iterator and + * Iterable protocols, enabling `for...of` loops and destructuring assignment. + * + * @param startAt - Optional. The date to start iterating from. Defaults to current time. + * @returns A new CronIterator instance + */ + public enumerate(startAt?: Date | string | null): CronIterator { + return new CronIterator(this, startAt); + } + /** * Internal helper to enumerate runs in either direction. * @@ -731,4 +744,96 @@ class Cron { } } -export { Cron, CronDate, type CronOptions, CronPattern, scheduledJobs }; +/** + * Stateful iterator for sequential date traversal of a Cron schedule. + * + * Implements both the ECMAScript Iterator and Iterable protocols, which means it can be + * used directly in `for...of` loops and with destructuring assignment. + * + * Obtain an instance via {@link Cron#enumerate}. + */ +class CronIterator implements Iterator, Iterable { + private cron: Cron; + private cursor: Date | string | undefined; + private done: boolean; + + /** + * @param cron - The Cron instance to iterate over + * @param startAt - Optional starting date for iteration. Defaults to current time if omitted. + */ + constructor(cron: Cron, startAt?: Date | string | null) { + this.cron = cron; + this.cursor = CronIterator._normalizeCursor(startAt); + this.done = false; + } + + /** + * Normalizes a cursor argument: Date objects are cloned to prevent external mutation; + * strings are preserved as-is so that nextRun() can parse them using the Cron instance's + * configured timezone (via CronDate/fromTZISO), matching the behaviour of nextRun() and nextRuns(). + * @private + */ + private static _normalizeCursor( + d?: Date | string | null, + ): Date | string | undefined { + if (d === undefined || d === null) return undefined; + return d instanceof Date ? new Date(d.getTime()) : d; + } + + /** + * Returns the next scheduled date and advances the cursor. + * Implements the ECMAScript Iterator protocol. + * + * @returns `{ value: Date, done: false }` for the next occurrence, + * or `{ value: undefined, done: true }` when the schedule is exhausted. + */ + public next(): IteratorResult { + if (this.done) { + return { value: undefined, done: true }; + } + const nextDate = this.cron.nextRun(this.cursor ?? null); + if (nextDate === null) { + this.done = true; + return { value: undefined, done: true }; + } + // The cursor must track the un-offset schedule time so that subsequent + // nextRun() calls find the correct next occurrence. nextRun() applies + // dayOffset on the way out, so we reverse it here before storing. + const dayOffset = this.cron.options.dayOffset; + const offsetDays = dayOffset ?? 0; + const unoffsetTime = nextDate.getTime() - offsetDays * 24 * 60 * 60 * 1000; + this.cursor = new Date(unoffsetTime); + return { value: nextDate, done: false }; + } + + /** + * Returns the next scheduled date without advancing the cursor. + * + * @returns The next scheduled date, or null if the schedule is exhausted. + */ + public peek(): Date | null { + if (this.done) { + return null; + } + return this.cron.nextRun(this.cursor ?? null); + } + + /** + * Resets the cursor, optionally to a new starting date. + * + * @param newStartAt - New starting date. Defaults to current time if omitted. + */ + public reset(newStartAt?: Date | string | null): void { + this.cursor = CronIterator._normalizeCursor(newStartAt); + this.done = false; + } + + /** + * Implements the ECMAScript Iterable protocol, enabling `for...of` and destructuring. + */ + [Symbol.iterator](): CronIterator { + return this; + } +} + +export { Cron, CronDate, CronIterator, type CronOptions, CronPattern, scheduledJobs }; diff --git a/test/iterator.test.ts b/test/iterator.test.ts new file mode 100644 index 0000000..e313634 --- /dev/null +++ b/test/iterator.test.ts @@ -0,0 +1,234 @@ +import { assertEquals } from "@std/assert"; +import { test } from "@cross/test"; +import { Cron, CronIterator } from "../src/croner.ts"; + +// enumerate() factory + +test("enumerate() returns a CronIterator instance", function () { + const job = new Cron("* * * * * *"); + assertEquals(job.enumerate() instanceof CronIterator, true); +}); + +test("enumerate() does not mutate the parent Cron instance", function () { + const job = new Cron("0 0 0 * * *"); + const before = job.nextRun(); + job.enumerate(); + assertEquals(job.nextRun()?.getTime(), before?.getTime()); +}); + +// Iterator protocol — next() + +test("enumerate() next() returns dates in ascending order", function () { + const start = new Date("2024-01-01T00:00:00.000Z"); + const iter = new Cron("0 * * * * *").enumerate(start); + const a = iter.next(); + const b = iter.next(); + assertEquals(a.done, false); + assertEquals(b.done, false); + assertEquals(a.value!.toISOString(), "2024-01-01T00:01:00.000Z"); + assertEquals(b.value!.toISOString(), "2024-01-01T00:02:00.000Z"); +}); + +test("enumerate() next() advances cursor by exactly one minute for minute pattern", function () { + const start = new Date("2024-01-01T00:00:00.000Z"); + const iter = new Cron("0 * * * * *").enumerate(start); + const a = iter.next(); + const b = iter.next(); + assertEquals(b.value!.getTime() - a.value!.getTime(), 60 * 1000); +}); + +test("enumerate() next() returns done:true after schedule is exhausted via stopAt", function () { + const start = new Date("2024-01-01T00:00:00.000Z"); + const stop = new Date("2024-01-01T00:02:30.000Z"); // allows :01 and :02 only + const iter = new Cron("0 * * * * *", { stopAt: stop }).enumerate(start); + const a = iter.next(); + const b = iter.next(); + const c = iter.next(); + assertEquals(a.done, false); + assertEquals(a.value!.toISOString(), "2024-01-01T00:01:00.000Z"); + assertEquals(b.done, false); + assertEquals(b.value!.toISOString(), "2024-01-01T00:02:00.000Z"); + assertEquals(c.done, true); + assertEquals(c.value, undefined); +}); + +test("enumerate() next() after done always returns done:true", function () { + const start = new Date("2024-01-01T00:00:00.000Z"); + const stop = new Date("2024-01-01T00:00:30.000Z"); + const iter = new Cron("0 * * * * *", { stopAt: stop }).enumerate(start); + let result = iter.next(); + while (!result.done) result = iter.next(); + assertEquals(iter.next().done, true); + assertEquals(iter.next().done, true); +}); + +// Iterable protocol — [Symbol.iterator] + +test("enumerate() [Symbol.iterator]() returns the iterator itself", function () { + const iter = new Cron("* * * * * *").enumerate(); + assertEquals(iter[Symbol.iterator](), iter); +}); + +test("enumerate() for...of collects exactly the expected dates", function () { + const start = new Date("2024-01-01T00:00:00.000Z"); + const stop = new Date("2024-01-01T00:03:00.000Z"); // :01 and :02 pass; :03 == stopAt is excluded + const collected: string[] = []; + for (const date of new Cron("0 * * * * *", { stopAt: stop }).enumerate(start)) { + collected.push(date.toISOString()); + } + assertEquals(collected.length, 2); + assertEquals(collected[0], "2024-01-01T00:01:00.000Z"); + assertEquals(collected[1], "2024-01-01T00:02:00.000Z"); +}); + +test("enumerate() destructuring captures first two occurrences", function () { + const start = new Date("2024-01-01T00:00:00.000Z"); + const [a, b] = new Cron("0 * * * * *").enumerate(start); + assertEquals(a.toISOString(), "2024-01-01T00:01:00.000Z"); + assertEquals(b.toISOString(), "2024-01-01T00:02:00.000Z"); +}); + +// peek() + +test("enumerate() peek() returns the next date without advancing the cursor", function () { + const start = new Date("2024-01-01T00:00:00.000Z"); + const iter = new Cron("0 * * * * *").enumerate(start); + const peeked = iter.peek(); + const nexted = iter.next(); + assertEquals(peeked?.toISOString(), nexted.value?.toISOString()); + assertEquals(peeked?.toISOString(), "2024-01-01T00:01:00.000Z"); +}); + +test("enumerate() peek() called twice returns the same value", function () { + const start = new Date("2024-01-01T00:00:00.000Z"); + const iter = new Cron("0 * * * * *").enumerate(start); + assertEquals(iter.peek()?.toISOString(), iter.peek()?.toISOString()); +}); + +test("enumerate() peek() returns null when iterator is done", function () { + const start = new Date("2024-01-01T00:00:00.000Z"); + const stop = new Date("2024-01-01T00:00:30.000Z"); + const iter = new Cron("0 * * * * *", { stopAt: stop }).enumerate(start); + let result = iter.next(); + while (!result.done) result = iter.next(); + assertEquals(iter.peek(), null); +}); + +// reset() + +test("enumerate() reset() allows re-iteration from the same start", function () { + const start = new Date("2024-01-01T00:00:00.000Z"); + const iter = new Cron("0 * * * * *").enumerate(start); + assertEquals(iter.next().value!.toISOString(), "2024-01-01T00:01:00.000Z"); + assertEquals(iter.next().value!.toISOString(), "2024-01-01T00:02:00.000Z"); + iter.reset(start); + assertEquals(iter.next().value!.toISOString(), "2024-01-01T00:01:00.000Z"); + assertEquals(iter.next().value!.toISOString(), "2024-01-01T00:02:00.000Z"); +}); + +test("enumerate() reset() clears the done flag", function () { + const fireAt = new Date("2024-06-01T12:00:00.000Z"); + const before = new Date("2024-01-01T00:00:00.000Z"); + const iter = new Cron(fireAt).enumerate(before); + // First call returns the one-off date, second is done + const a = iter.next(); + const b = iter.next(); + assertEquals(a.done, false); + assertEquals(b.done, true); + // Reset before the fire date — done flag must be cleared + iter.reset(before); + const c = iter.next(); + assertEquals(c.done, false); + assertEquals(c.value!.toISOString(), fireAt.toISOString()); +}); + +test("enumerate() reset(newDate) moves iteration to new start point", function () { + const start1 = new Date("2024-01-01T00:00:00.000Z"); + const start2 = new Date("2024-01-01T01:00:00.000Z"); + const iter = new Cron("0 0 * * * *").enumerate(start1); + assertEquals(iter.next().value!.toISOString(), "2024-01-01T01:00:00.000Z"); + iter.reset(start2); + assertEquals(iter.next().value!.toISOString(), "2024-01-01T02:00:00.000Z"); +}); + +// Consistency with nextRuns() + +test("enumerate() produces the same dates as nextRuns()", function () { + const start = new Date("2024-01-01T00:00:00.000Z"); + const job = new Cron("0 0 * * * *"); + const n = 5; + const expected = job.nextRuns(n, start); + const iter = job.enumerate(start); + const actual: string[] = []; + for (let i = 0; i < n; i++) { + const { value, done } = iter.next(); + if (done) break; + actual.push(value.toISOString()); + } + assertEquals(actual.length, expected.length); + for (let i = 0; i < actual.length; i++) { + assertEquals(actual[i], expected[i].toISOString()); + } +}); + +test("enumerate() with dayOffset produces the same dates as nextRuns()", function () { + const start = new Date("2024-01-01T00:00:00.000Z"); + const job = new Cron("0 0 * * * *", { dayOffset: 1 }); // shift every occurrence by +1 day + const n = 5; + const expected = job.nextRuns(n, start); + const iter = job.enumerate(start); + const actual: string[] = []; + for (let i = 0; i < n; i++) { + const { value, done } = iter.next(); + if (done) break; + actual.push(value.toISOString()); + } + assertEquals(actual.length, expected.length); + for (let i = 0; i < actual.length; i++) { + assertEquals(actual[i], expected[i].toISOString()); + } +}); + +test("enumerate() with timezone: string startAt is parsed in the Cron timezone", function () { + // "2024-01-01T12:00:00" without a Z/offset is interpreted in Europe/Stockholm (UTC+1 in January) + // meaning it resolves to 2024-01-01T11:00:00.000Z. + // enumerate() must match nextRuns() — both should start after 11:00 UTC. + const startStr = "2024-01-01T12:00:00"; + const job = new Cron("0 0 * * * *", { timezone: "Europe/Stockholm" }); + const n = 5; + const expected = job.nextRuns(n, startStr); + const iter = job.enumerate(startStr); + const actual: string[] = []; + for (let i = 0; i < n; i++) { + const { value, done } = iter.next(); + if (done) break; + actual.push(value.toISOString()); + } + assertEquals(actual.length, expected.length); + for (let i = 0; i < actual.length; i++) { + assertEquals(actual[i], expected[i].toISOString()); + } + // First occurrence is 13:00 Stockholm = 12:00 UTC + assertEquals(actual[0], "2024-01-01T12:00:00.000Z"); +}); + +// startAt as ISO 8601 string + +test("enumerate() accepts an ISO 8601 string as startAt", function () { + const iter = new Cron("0 0 * * * *").enumerate("2024-06-01T00:00:00"); + const result = iter.next(); + assertEquals(result.done, false); + assertEquals(result.value instanceof Date, true); +}); + +// Once-only job + +test("enumerate() on a once-only job returns one date then done", function () { + const fireAt = new Date(Date.now() + 86400 * 1000); // tomorrow + const iter = new Cron(fireAt).enumerate(); + const a = iter.next(); + const b = iter.next(); + assertEquals(a.done, false); + assertEquals(a.value instanceof Date, true); + assertEquals(b.done, true); +});