@monlite/cron 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Emad Jumaah
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,73 @@
1
+ # @monlite/cron
2
+
3
+ **Cron-style scheduling** for [`@monlite/core`](https://www.npmjs.com/package/@monlite/core), backed by SQLite. Persisted schedules (survive restarts), a zero-dependency 5-field cron parser, and atomic firing so multiple processes won't double-run. Part of the [local AI-agent harness](https://github.com/qataruts/monlite#readme).
4
+
5
+ ```bash
6
+ npm install @monlite/core @monlite/cron
7
+ ```
8
+
9
+ ## Quick start
10
+
11
+ ```ts
12
+ import { createDb } from "@monlite/core";
13
+ import { createCron } from "@monlite/cron";
14
+
15
+ const db = createDb("app.db");
16
+ const cron = createCron(db);
17
+
18
+ cron.schedule("cleanup", "0 3 * * *", async () => {
19
+ await purgeOldRows(); // runs every day at 03:00 (local time)
20
+ });
21
+
22
+ cron.on("error", (err, name) => console.warn(name, err));
23
+ ```
24
+
25
+ ## Durable scheduled work (compose with the queue)
26
+
27
+ A cron handler runs in-process, so for durable work, have it **enqueue** a job
28
+ into [`@monlite/queue`](https://www.npmjs.com/package/@monlite/queue) — the
29
+ schedule is persisted and the work is durable & retried:
30
+
31
+ ```ts
32
+ import { createQueue } from "@monlite/queue";
33
+ const queue = createQueue(db);
34
+ queue.process("report", async (job) => generateReport(job.payload));
35
+
36
+ cron.schedule("nightly-report", "0 0 * * *", () => {
37
+ queue.add("report", { day: new Date().toISOString() });
38
+ });
39
+ ```
40
+
41
+ ## Cron syntax
42
+
43
+ Standard 5 fields — `minute hour day-of-month month day-of-week`:
44
+
45
+ ```
46
+ * every value 0 9 * * 1 09:00 every Monday
47
+ */15 every 15 */15 * * * * every 15 minutes
48
+ 1-5 range 0 9-17 * * * hourly, 9am–5pm
49
+ 1,15 list 0 0 1,15 * * 1st and 15th at midnight
50
+ ```
51
+
52
+ Day-of-week is `0–6` (0 = Sunday). Times are **local**. When both day-of-month
53
+ and day-of-week are restricted, either match fires (POSIX behavior).
54
+
55
+ ## API
56
+
57
+ ```ts
58
+ const cron = createCron(db, { checkInterval: 1000 }); // poll cadence (ms)
59
+ cron.schedule(name, cronExpr, handler); // register/update + start
60
+ cron.unschedule(name); // remove
61
+ cron.next(name); // next run (epoch ms) | undefined
62
+ cron.on("error", (err, name) => {});
63
+ cron.stop(); // stop scheduling (schedules persist)
64
+
65
+ // utility:
66
+ import { nextCronRun } from "@monlite/cron";
67
+ nextCronRun("0 9 * * 1"); // → Date of the next Monday 09:00
68
+ ```
69
+
70
+ Firing is **atomic** (a claim on the schedule row), so running the same schedule
71
+ in multiple processes fires each occurrence exactly once.
72
+
73
+ MIT
package/dist/index.cjs ADDED
@@ -0,0 +1,142 @@
1
+ 'use strict';
2
+
3
+ var events = require('events');
4
+
5
+ // src/index.ts
6
+ function parseField(field, min, max) {
7
+ const out = /* @__PURE__ */ new Set();
8
+ for (const part of field.split(",")) {
9
+ const [rangePart, stepPart] = part.split("/");
10
+ const step = stepPart === void 0 ? 1 : parseInt(stepPart, 10);
11
+ let lo;
12
+ let hi;
13
+ if (rangePart === "*") {
14
+ lo = min;
15
+ hi = max;
16
+ } else if (rangePart.includes("-")) {
17
+ const [a, b] = rangePart.split("-");
18
+ lo = parseInt(a, 10);
19
+ hi = parseInt(b, 10);
20
+ } else {
21
+ lo = hi = parseInt(rangePart, 10);
22
+ }
23
+ if (Number.isNaN(lo) || Number.isNaN(hi) || Number.isNaN(step) || step < 1 || lo < min || hi > max || lo > hi) {
24
+ throw new Error(`Invalid cron field "${field}" (expected ${min}-${max})`);
25
+ }
26
+ for (let v = lo; v <= hi; v += step) out.add(v);
27
+ }
28
+ return out;
29
+ }
30
+ function parseCron(expr) {
31
+ const parts = expr.trim().split(/\s+/);
32
+ if (parts.length !== 5) {
33
+ throw new Error(
34
+ `Cron expression must have 5 fields, got ${parts.length}: "${expr}"`
35
+ );
36
+ }
37
+ return {
38
+ minute: parseField(parts[0], 0, 59),
39
+ hour: parseField(parts[1], 0, 23),
40
+ dom: parseField(parts[2], 1, 31),
41
+ month: parseField(parts[3], 1, 12),
42
+ dow: parseField(parts[4], 0, 6),
43
+ domRestricted: parts[2] !== "*",
44
+ dowRestricted: parts[4] !== "*"
45
+ };
46
+ }
47
+ function dayMatches(c, d) {
48
+ const dom = c.dom.has(d.getDate());
49
+ const dow = c.dow.has(d.getDay());
50
+ if (c.domRestricted && c.dowRestricted) return dom || dow;
51
+ return dom && dow;
52
+ }
53
+ function nextCronRun(expr, from = /* @__PURE__ */ new Date()) {
54
+ const c = typeof expr === "string" ? parseCron(expr) : expr;
55
+ const d = new Date(from.getTime());
56
+ d.setSeconds(0, 0);
57
+ d.setMinutes(d.getMinutes() + 1);
58
+ for (let i = 0; i < 366 * 24 * 60 + 60; i++) {
59
+ if (c.minute.has(d.getMinutes()) && c.hour.has(d.getHours()) && c.month.has(d.getMonth() + 1) && dayMatches(c, d)) {
60
+ return d;
61
+ }
62
+ d.setMinutes(d.getMinutes() + 1);
63
+ }
64
+ throw new Error(`Could not compute next run for cron "${expr}"`);
65
+ }
66
+ var ensured = /* @__PURE__ */ new WeakSet();
67
+ var nowMs = () => Date.now();
68
+ var Cron = class extends events.EventEmitter {
69
+ driver;
70
+ checkInterval;
71
+ handlers = /* @__PURE__ */ new Map();
72
+ timer;
73
+ constructor(db, opts = {}) {
74
+ super();
75
+ this.driver = db.driver;
76
+ this.checkInterval = opts.checkInterval ?? 1e3;
77
+ if (!ensured.has(db)) {
78
+ this.driver.exec(
79
+ `CREATE TABLE IF NOT EXISTS _schedules (
80
+ name TEXT PRIMARY KEY, cron TEXT NOT NULL,
81
+ next_run INTEGER NOT NULL, last_run INTEGER
82
+ )`
83
+ );
84
+ ensured.add(db);
85
+ }
86
+ }
87
+ /** Register (or update) a schedule and start the scheduler. */
88
+ schedule(name, expr, handler) {
89
+ const c = parseCron(expr);
90
+ const existing = this.driver.prepare(`SELECT next_run FROM _schedules WHERE name = ?`).get(name);
91
+ const next = existing ? existing.next_run : nextCronRun(c).getTime();
92
+ this.driver.prepare(
93
+ `INSERT INTO _schedules (name, cron, next_run, last_run) VALUES (?, ?, ?, NULL)
94
+ ON CONFLICT(name) DO UPDATE SET cron = excluded.cron`
95
+ ).run(name, expr, next);
96
+ this.handlers.set(name, { c, fn: handler });
97
+ if (!this.timer) {
98
+ this.timer = setInterval(() => this.tick(), this.checkInterval);
99
+ this.timer.unref?.();
100
+ }
101
+ }
102
+ /** Remove a schedule. */
103
+ unschedule(name) {
104
+ this.handlers.delete(name);
105
+ this.driver.prepare(`DELETE FROM _schedules WHERE name = ?`).run(name);
106
+ }
107
+ /** The next scheduled run (epoch ms) for a registered schedule. */
108
+ next(name) {
109
+ const row = this.driver.prepare(`SELECT next_run FROM _schedules WHERE name = ?`).get(name);
110
+ return row?.next_run;
111
+ }
112
+ /** @internal — exposed for tests; runs one scheduling pass. */
113
+ tick() {
114
+ const t = nowMs();
115
+ for (const [name, reg] of this.handlers) {
116
+ const row = this.driver.prepare(`SELECT next_run FROM _schedules WHERE name = ?`).get(name);
117
+ if (!row || row.next_run > t) continue;
118
+ const next = nextCronRun(reg.c, new Date(t)).getTime();
119
+ const claimed = this.driver.prepare(
120
+ `UPDATE _schedules SET last_run = ?, next_run = ? WHERE name = ? AND next_run <= ?`
121
+ ).run(t, next, name, t).changes > 0;
122
+ if (claimed) {
123
+ Promise.resolve().then(() => reg.fn()).catch((err) => this.emit("error", err, name));
124
+ }
125
+ }
126
+ }
127
+ /** Stop the scheduler (schedules remain persisted). */
128
+ stop() {
129
+ if (this.timer) clearInterval(this.timer);
130
+ this.timer = void 0;
131
+ }
132
+ };
133
+ function createCron(db, opts) {
134
+ return new Cron(db, opts);
135
+ }
136
+
137
+ exports.Cron = Cron;
138
+ exports.createCron = createCron;
139
+ exports.nextCronRun = nextCronRun;
140
+ exports.parseCron = parseCron;
141
+ //# sourceMappingURL=index.cjs.map
142
+ //# sourceMappingURL=index.cjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/index.ts"],"names":["EventEmitter"],"mappings":"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","file":"index.cjs","sourcesContent":["import { EventEmitter } from \"node:events\";\nimport type { Monlite } from \"@monlite/core\";\n\nexport interface CronOptions {\n /** How often the scheduler checks for due jobs (ms). Default 1000. */\n checkInterval?: number;\n}\n\nexport type CronHandler = () => void | Promise<void>;\n\nexport interface ParsedCron {\n minute: Set<number>;\n hour: Set<number>;\n dom: Set<number>;\n month: Set<number>;\n dow: Set<number>;\n domRestricted: boolean;\n dowRestricted: boolean;\n}\n\nfunction parseField(field: string, min: number, max: number): Set<number> {\n const out = new Set<number>();\n for (const part of field.split(\",\")) {\n const [rangePart, stepPart] = part.split(\"/\");\n const step = stepPart === undefined ? 1 : parseInt(stepPart, 10);\n let lo: number;\n let hi: number;\n if (rangePart === \"*\") {\n lo = min;\n hi = max;\n } else if (rangePart.includes(\"-\")) {\n const [a, b] = rangePart.split(\"-\");\n lo = parseInt(a, 10);\n hi = parseInt(b, 10);\n } else {\n lo = hi = parseInt(rangePart, 10);\n }\n if (\n Number.isNaN(lo) ||\n Number.isNaN(hi) ||\n Number.isNaN(step) ||\n step < 1 ||\n lo < min ||\n hi > max ||\n lo > hi\n ) {\n throw new Error(`Invalid cron field \"${field}\" (expected ${min}-${max})`);\n }\n for (let v = lo; v <= hi; v += step) out.add(v);\n }\n return out;\n}\n\n/** Parse a standard 5-field cron expression (`min hour dom month dow`). */\nexport function parseCron(expr: string): ParsedCron {\n const parts = expr.trim().split(/\\s+/);\n if (parts.length !== 5) {\n throw new Error(\n `Cron expression must have 5 fields, got ${parts.length}: \"${expr}\"`,\n );\n }\n return {\n minute: parseField(parts[0], 0, 59),\n hour: parseField(parts[1], 0, 23),\n dom: parseField(parts[2], 1, 31),\n month: parseField(parts[3], 1, 12),\n dow: parseField(parts[4], 0, 6),\n domRestricted: parts[2] !== \"*\",\n dowRestricted: parts[4] !== \"*\",\n };\n}\n\nfunction dayMatches(c: ParsedCron, d: Date): boolean {\n const dom = c.dom.has(d.getDate());\n const dow = c.dow.has(d.getDay()); // 0 = Sunday\n // POSIX: when both day-of-month and day-of-week are restricted, either matches.\n if (c.domRestricted && c.dowRestricted) return dom || dow;\n return dom && dow;\n}\n\n/** The next time (strictly after `from`, local time) a cron expression fires. */\nexport function nextCronRun(\n expr: string | ParsedCron,\n from: Date = new Date(),\n): Date {\n const c = typeof expr === \"string\" ? parseCron(expr) : expr;\n const d = new Date(from.getTime());\n d.setSeconds(0, 0);\n d.setMinutes(d.getMinutes() + 1);\n for (let i = 0; i < 366 * 24 * 60 + 60; i++) {\n if (\n c.minute.has(d.getMinutes()) &&\n c.hour.has(d.getHours()) &&\n c.month.has(d.getMonth() + 1) &&\n dayMatches(c, d)\n ) {\n return d;\n }\n d.setMinutes(d.getMinutes() + 1);\n }\n throw new Error(`Could not compute next run for cron \"${expr}\"`);\n}\n\nconst ensured = new WeakSet<object>();\nconst nowMs = () => Date.now();\n\n/**\n * A persisted cron scheduler. Schedules survive restarts (next-run is stored),\n * and firing is atomic so multiple processes won't double-run an occurrence.\n * Compose with a queue for durable work: `cron.schedule(n, expr, () => queue.add(...))`.\n * Emits `\"error\"` (err, name) if a handler throws.\n */\nexport class Cron extends EventEmitter {\n private readonly driver: Monlite[\"driver\"];\n private readonly checkInterval: number;\n private readonly handlers = new Map<\n string,\n { c: ParsedCron; fn: CronHandler }\n >();\n private timer: ReturnType<typeof setInterval> | undefined;\n\n constructor(db: Monlite, opts: CronOptions = {}) {\n super();\n this.driver = db.driver;\n this.checkInterval = opts.checkInterval ?? 1000;\n if (!ensured.has(db)) {\n this.driver.exec(\n `CREATE TABLE IF NOT EXISTS _schedules (\n name TEXT PRIMARY KEY, cron TEXT NOT NULL,\n next_run INTEGER NOT NULL, last_run INTEGER\n )`,\n );\n ensured.add(db);\n }\n }\n\n /** Register (or update) a schedule and start the scheduler. */\n schedule(name: string, expr: string, handler: CronHandler): void {\n const c = parseCron(expr);\n const existing = this.driver\n .prepare(`SELECT next_run FROM _schedules WHERE name = ?`)\n .get(name) as { next_run: number } | undefined;\n const next = existing ? existing.next_run : nextCronRun(c).getTime();\n this.driver\n .prepare(\n `INSERT INTO _schedules (name, cron, next_run, last_run) VALUES (?, ?, ?, NULL)\n ON CONFLICT(name) DO UPDATE SET cron = excluded.cron`,\n )\n .run(name, expr, next);\n this.handlers.set(name, { c, fn: handler });\n if (!this.timer) {\n this.timer = setInterval(() => this.tick(), this.checkInterval);\n this.timer.unref?.();\n }\n }\n\n /** Remove a schedule. */\n unschedule(name: string): void {\n this.handlers.delete(name);\n this.driver.prepare(`DELETE FROM _schedules WHERE name = ?`).run(name);\n }\n\n /** The next scheduled run (epoch ms) for a registered schedule. */\n next(name: string): number | undefined {\n const row = this.driver\n .prepare(`SELECT next_run FROM _schedules WHERE name = ?`)\n .get(name) as { next_run: number } | undefined;\n return row?.next_run;\n }\n\n /** @internal — exposed for tests; runs one scheduling pass. */\n tick(): void {\n const t = nowMs();\n for (const [name, reg] of this.handlers) {\n const row = this.driver\n .prepare(`SELECT next_run FROM _schedules WHERE name = ?`)\n .get(name) as { next_run: number } | undefined;\n if (!row || row.next_run > t) continue;\n const next = nextCronRun(reg.c, new Date(t)).getTime();\n // Atomic claim: only the process that flips next_run gets to fire.\n const claimed =\n this.driver\n .prepare(\n `UPDATE _schedules SET last_run = ?, next_run = ? WHERE name = ? AND next_run <= ?`,\n )\n .run(t, next, name, t).changes > 0;\n if (claimed) {\n Promise.resolve()\n .then(() => reg.fn())\n .catch((err) => this.emit(\"error\", err, name));\n }\n }\n }\n\n /** Stop the scheduler (schedules remain persisted). */\n stop(): void {\n if (this.timer) clearInterval(this.timer);\n this.timer = undefined;\n }\n}\n\n/** Create a cron scheduler over a monlite database. */\nexport function createCron(db: Monlite, opts?: CronOptions): Cron {\n return new Cron(db, opts);\n}\n"]}
@@ -0,0 +1,48 @@
1
+ import { EventEmitter } from 'node:events';
2
+ import { Monlite } from '@monlite/core';
3
+
4
+ interface CronOptions {
5
+ /** How often the scheduler checks for due jobs (ms). Default 1000. */
6
+ checkInterval?: number;
7
+ }
8
+ type CronHandler = () => void | Promise<void>;
9
+ interface ParsedCron {
10
+ minute: Set<number>;
11
+ hour: Set<number>;
12
+ dom: Set<number>;
13
+ month: Set<number>;
14
+ dow: Set<number>;
15
+ domRestricted: boolean;
16
+ dowRestricted: boolean;
17
+ }
18
+ /** Parse a standard 5-field cron expression (`min hour dom month dow`). */
19
+ declare function parseCron(expr: string): ParsedCron;
20
+ /** The next time (strictly after `from`, local time) a cron expression fires. */
21
+ declare function nextCronRun(expr: string | ParsedCron, from?: Date): Date;
22
+ /**
23
+ * A persisted cron scheduler. Schedules survive restarts (next-run is stored),
24
+ * and firing is atomic so multiple processes won't double-run an occurrence.
25
+ * Compose with a queue for durable work: `cron.schedule(n, expr, () => queue.add(...))`.
26
+ * Emits `"error"` (err, name) if a handler throws.
27
+ */
28
+ declare class Cron extends EventEmitter {
29
+ private readonly driver;
30
+ private readonly checkInterval;
31
+ private readonly handlers;
32
+ private timer;
33
+ constructor(db: Monlite, opts?: CronOptions);
34
+ /** Register (or update) a schedule and start the scheduler. */
35
+ schedule(name: string, expr: string, handler: CronHandler): void;
36
+ /** Remove a schedule. */
37
+ unschedule(name: string): void;
38
+ /** The next scheduled run (epoch ms) for a registered schedule. */
39
+ next(name: string): number | undefined;
40
+ /** @internal — exposed for tests; runs one scheduling pass. */
41
+ tick(): void;
42
+ /** Stop the scheduler (schedules remain persisted). */
43
+ stop(): void;
44
+ }
45
+ /** Create a cron scheduler over a monlite database. */
46
+ declare function createCron(db: Monlite, opts?: CronOptions): Cron;
47
+
48
+ export { Cron, type CronHandler, type CronOptions, type ParsedCron, createCron, nextCronRun, parseCron };
@@ -0,0 +1,48 @@
1
+ import { EventEmitter } from 'node:events';
2
+ import { Monlite } from '@monlite/core';
3
+
4
+ interface CronOptions {
5
+ /** How often the scheduler checks for due jobs (ms). Default 1000. */
6
+ checkInterval?: number;
7
+ }
8
+ type CronHandler = () => void | Promise<void>;
9
+ interface ParsedCron {
10
+ minute: Set<number>;
11
+ hour: Set<number>;
12
+ dom: Set<number>;
13
+ month: Set<number>;
14
+ dow: Set<number>;
15
+ domRestricted: boolean;
16
+ dowRestricted: boolean;
17
+ }
18
+ /** Parse a standard 5-field cron expression (`min hour dom month dow`). */
19
+ declare function parseCron(expr: string): ParsedCron;
20
+ /** The next time (strictly after `from`, local time) a cron expression fires. */
21
+ declare function nextCronRun(expr: string | ParsedCron, from?: Date): Date;
22
+ /**
23
+ * A persisted cron scheduler. Schedules survive restarts (next-run is stored),
24
+ * and firing is atomic so multiple processes won't double-run an occurrence.
25
+ * Compose with a queue for durable work: `cron.schedule(n, expr, () => queue.add(...))`.
26
+ * Emits `"error"` (err, name) if a handler throws.
27
+ */
28
+ declare class Cron extends EventEmitter {
29
+ private readonly driver;
30
+ private readonly checkInterval;
31
+ private readonly handlers;
32
+ private timer;
33
+ constructor(db: Monlite, opts?: CronOptions);
34
+ /** Register (or update) a schedule and start the scheduler. */
35
+ schedule(name: string, expr: string, handler: CronHandler): void;
36
+ /** Remove a schedule. */
37
+ unschedule(name: string): void;
38
+ /** The next scheduled run (epoch ms) for a registered schedule. */
39
+ next(name: string): number | undefined;
40
+ /** @internal — exposed for tests; runs one scheduling pass. */
41
+ tick(): void;
42
+ /** Stop the scheduler (schedules remain persisted). */
43
+ stop(): void;
44
+ }
45
+ /** Create a cron scheduler over a monlite database. */
46
+ declare function createCron(db: Monlite, opts?: CronOptions): Cron;
47
+
48
+ export { Cron, type CronHandler, type CronOptions, type ParsedCron, createCron, nextCronRun, parseCron };
package/dist/index.js ADDED
@@ -0,0 +1,137 @@
1
+ import { EventEmitter } from 'events';
2
+
3
+ // src/index.ts
4
+ function parseField(field, min, max) {
5
+ const out = /* @__PURE__ */ new Set();
6
+ for (const part of field.split(",")) {
7
+ const [rangePart, stepPart] = part.split("/");
8
+ const step = stepPart === void 0 ? 1 : parseInt(stepPart, 10);
9
+ let lo;
10
+ let hi;
11
+ if (rangePart === "*") {
12
+ lo = min;
13
+ hi = max;
14
+ } else if (rangePart.includes("-")) {
15
+ const [a, b] = rangePart.split("-");
16
+ lo = parseInt(a, 10);
17
+ hi = parseInt(b, 10);
18
+ } else {
19
+ lo = hi = parseInt(rangePart, 10);
20
+ }
21
+ if (Number.isNaN(lo) || Number.isNaN(hi) || Number.isNaN(step) || step < 1 || lo < min || hi > max || lo > hi) {
22
+ throw new Error(`Invalid cron field "${field}" (expected ${min}-${max})`);
23
+ }
24
+ for (let v = lo; v <= hi; v += step) out.add(v);
25
+ }
26
+ return out;
27
+ }
28
+ function parseCron(expr) {
29
+ const parts = expr.trim().split(/\s+/);
30
+ if (parts.length !== 5) {
31
+ throw new Error(
32
+ `Cron expression must have 5 fields, got ${parts.length}: "${expr}"`
33
+ );
34
+ }
35
+ return {
36
+ minute: parseField(parts[0], 0, 59),
37
+ hour: parseField(parts[1], 0, 23),
38
+ dom: parseField(parts[2], 1, 31),
39
+ month: parseField(parts[3], 1, 12),
40
+ dow: parseField(parts[4], 0, 6),
41
+ domRestricted: parts[2] !== "*",
42
+ dowRestricted: parts[4] !== "*"
43
+ };
44
+ }
45
+ function dayMatches(c, d) {
46
+ const dom = c.dom.has(d.getDate());
47
+ const dow = c.dow.has(d.getDay());
48
+ if (c.domRestricted && c.dowRestricted) return dom || dow;
49
+ return dom && dow;
50
+ }
51
+ function nextCronRun(expr, from = /* @__PURE__ */ new Date()) {
52
+ const c = typeof expr === "string" ? parseCron(expr) : expr;
53
+ const d = new Date(from.getTime());
54
+ d.setSeconds(0, 0);
55
+ d.setMinutes(d.getMinutes() + 1);
56
+ for (let i = 0; i < 366 * 24 * 60 + 60; i++) {
57
+ if (c.minute.has(d.getMinutes()) && c.hour.has(d.getHours()) && c.month.has(d.getMonth() + 1) && dayMatches(c, d)) {
58
+ return d;
59
+ }
60
+ d.setMinutes(d.getMinutes() + 1);
61
+ }
62
+ throw new Error(`Could not compute next run for cron "${expr}"`);
63
+ }
64
+ var ensured = /* @__PURE__ */ new WeakSet();
65
+ var nowMs = () => Date.now();
66
+ var Cron = class extends EventEmitter {
67
+ driver;
68
+ checkInterval;
69
+ handlers = /* @__PURE__ */ new Map();
70
+ timer;
71
+ constructor(db, opts = {}) {
72
+ super();
73
+ this.driver = db.driver;
74
+ this.checkInterval = opts.checkInterval ?? 1e3;
75
+ if (!ensured.has(db)) {
76
+ this.driver.exec(
77
+ `CREATE TABLE IF NOT EXISTS _schedules (
78
+ name TEXT PRIMARY KEY, cron TEXT NOT NULL,
79
+ next_run INTEGER NOT NULL, last_run INTEGER
80
+ )`
81
+ );
82
+ ensured.add(db);
83
+ }
84
+ }
85
+ /** Register (or update) a schedule and start the scheduler. */
86
+ schedule(name, expr, handler) {
87
+ const c = parseCron(expr);
88
+ const existing = this.driver.prepare(`SELECT next_run FROM _schedules WHERE name = ?`).get(name);
89
+ const next = existing ? existing.next_run : nextCronRun(c).getTime();
90
+ this.driver.prepare(
91
+ `INSERT INTO _schedules (name, cron, next_run, last_run) VALUES (?, ?, ?, NULL)
92
+ ON CONFLICT(name) DO UPDATE SET cron = excluded.cron`
93
+ ).run(name, expr, next);
94
+ this.handlers.set(name, { c, fn: handler });
95
+ if (!this.timer) {
96
+ this.timer = setInterval(() => this.tick(), this.checkInterval);
97
+ this.timer.unref?.();
98
+ }
99
+ }
100
+ /** Remove a schedule. */
101
+ unschedule(name) {
102
+ this.handlers.delete(name);
103
+ this.driver.prepare(`DELETE FROM _schedules WHERE name = ?`).run(name);
104
+ }
105
+ /** The next scheduled run (epoch ms) for a registered schedule. */
106
+ next(name) {
107
+ const row = this.driver.prepare(`SELECT next_run FROM _schedules WHERE name = ?`).get(name);
108
+ return row?.next_run;
109
+ }
110
+ /** @internal — exposed for tests; runs one scheduling pass. */
111
+ tick() {
112
+ const t = nowMs();
113
+ for (const [name, reg] of this.handlers) {
114
+ const row = this.driver.prepare(`SELECT next_run FROM _schedules WHERE name = ?`).get(name);
115
+ if (!row || row.next_run > t) continue;
116
+ const next = nextCronRun(reg.c, new Date(t)).getTime();
117
+ const claimed = this.driver.prepare(
118
+ `UPDATE _schedules SET last_run = ?, next_run = ? WHERE name = ? AND next_run <= ?`
119
+ ).run(t, next, name, t).changes > 0;
120
+ if (claimed) {
121
+ Promise.resolve().then(() => reg.fn()).catch((err) => this.emit("error", err, name));
122
+ }
123
+ }
124
+ }
125
+ /** Stop the scheduler (schedules remain persisted). */
126
+ stop() {
127
+ if (this.timer) clearInterval(this.timer);
128
+ this.timer = void 0;
129
+ }
130
+ };
131
+ function createCron(db, opts) {
132
+ return new Cron(db, opts);
133
+ }
134
+
135
+ export { Cron, createCron, nextCronRun, parseCron };
136
+ //# sourceMappingURL=index.js.map
137
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/index.ts"],"names":[],"mappings":"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","file":"index.js","sourcesContent":["import { EventEmitter } from \"node:events\";\nimport type { Monlite } from \"@monlite/core\";\n\nexport interface CronOptions {\n /** How often the scheduler checks for due jobs (ms). Default 1000. */\n checkInterval?: number;\n}\n\nexport type CronHandler = () => void | Promise<void>;\n\nexport interface ParsedCron {\n minute: Set<number>;\n hour: Set<number>;\n dom: Set<number>;\n month: Set<number>;\n dow: Set<number>;\n domRestricted: boolean;\n dowRestricted: boolean;\n}\n\nfunction parseField(field: string, min: number, max: number): Set<number> {\n const out = new Set<number>();\n for (const part of field.split(\",\")) {\n const [rangePart, stepPart] = part.split(\"/\");\n const step = stepPart === undefined ? 1 : parseInt(stepPart, 10);\n let lo: number;\n let hi: number;\n if (rangePart === \"*\") {\n lo = min;\n hi = max;\n } else if (rangePart.includes(\"-\")) {\n const [a, b] = rangePart.split(\"-\");\n lo = parseInt(a, 10);\n hi = parseInt(b, 10);\n } else {\n lo = hi = parseInt(rangePart, 10);\n }\n if (\n Number.isNaN(lo) ||\n Number.isNaN(hi) ||\n Number.isNaN(step) ||\n step < 1 ||\n lo < min ||\n hi > max ||\n lo > hi\n ) {\n throw new Error(`Invalid cron field \"${field}\" (expected ${min}-${max})`);\n }\n for (let v = lo; v <= hi; v += step) out.add(v);\n }\n return out;\n}\n\n/** Parse a standard 5-field cron expression (`min hour dom month dow`). */\nexport function parseCron(expr: string): ParsedCron {\n const parts = expr.trim().split(/\\s+/);\n if (parts.length !== 5) {\n throw new Error(\n `Cron expression must have 5 fields, got ${parts.length}: \"${expr}\"`,\n );\n }\n return {\n minute: parseField(parts[0], 0, 59),\n hour: parseField(parts[1], 0, 23),\n dom: parseField(parts[2], 1, 31),\n month: parseField(parts[3], 1, 12),\n dow: parseField(parts[4], 0, 6),\n domRestricted: parts[2] !== \"*\",\n dowRestricted: parts[4] !== \"*\",\n };\n}\n\nfunction dayMatches(c: ParsedCron, d: Date): boolean {\n const dom = c.dom.has(d.getDate());\n const dow = c.dow.has(d.getDay()); // 0 = Sunday\n // POSIX: when both day-of-month and day-of-week are restricted, either matches.\n if (c.domRestricted && c.dowRestricted) return dom || dow;\n return dom && dow;\n}\n\n/** The next time (strictly after `from`, local time) a cron expression fires. */\nexport function nextCronRun(\n expr: string | ParsedCron,\n from: Date = new Date(),\n): Date {\n const c = typeof expr === \"string\" ? parseCron(expr) : expr;\n const d = new Date(from.getTime());\n d.setSeconds(0, 0);\n d.setMinutes(d.getMinutes() + 1);\n for (let i = 0; i < 366 * 24 * 60 + 60; i++) {\n if (\n c.minute.has(d.getMinutes()) &&\n c.hour.has(d.getHours()) &&\n c.month.has(d.getMonth() + 1) &&\n dayMatches(c, d)\n ) {\n return d;\n }\n d.setMinutes(d.getMinutes() + 1);\n }\n throw new Error(`Could not compute next run for cron \"${expr}\"`);\n}\n\nconst ensured = new WeakSet<object>();\nconst nowMs = () => Date.now();\n\n/**\n * A persisted cron scheduler. Schedules survive restarts (next-run is stored),\n * and firing is atomic so multiple processes won't double-run an occurrence.\n * Compose with a queue for durable work: `cron.schedule(n, expr, () => queue.add(...))`.\n * Emits `\"error\"` (err, name) if a handler throws.\n */\nexport class Cron extends EventEmitter {\n private readonly driver: Monlite[\"driver\"];\n private readonly checkInterval: number;\n private readonly handlers = new Map<\n string,\n { c: ParsedCron; fn: CronHandler }\n >();\n private timer: ReturnType<typeof setInterval> | undefined;\n\n constructor(db: Monlite, opts: CronOptions = {}) {\n super();\n this.driver = db.driver;\n this.checkInterval = opts.checkInterval ?? 1000;\n if (!ensured.has(db)) {\n this.driver.exec(\n `CREATE TABLE IF NOT EXISTS _schedules (\n name TEXT PRIMARY KEY, cron TEXT NOT NULL,\n next_run INTEGER NOT NULL, last_run INTEGER\n )`,\n );\n ensured.add(db);\n }\n }\n\n /** Register (or update) a schedule and start the scheduler. */\n schedule(name: string, expr: string, handler: CronHandler): void {\n const c = parseCron(expr);\n const existing = this.driver\n .prepare(`SELECT next_run FROM _schedules WHERE name = ?`)\n .get(name) as { next_run: number } | undefined;\n const next = existing ? existing.next_run : nextCronRun(c).getTime();\n this.driver\n .prepare(\n `INSERT INTO _schedules (name, cron, next_run, last_run) VALUES (?, ?, ?, NULL)\n ON CONFLICT(name) DO UPDATE SET cron = excluded.cron`,\n )\n .run(name, expr, next);\n this.handlers.set(name, { c, fn: handler });\n if (!this.timer) {\n this.timer = setInterval(() => this.tick(), this.checkInterval);\n this.timer.unref?.();\n }\n }\n\n /** Remove a schedule. */\n unschedule(name: string): void {\n this.handlers.delete(name);\n this.driver.prepare(`DELETE FROM _schedules WHERE name = ?`).run(name);\n }\n\n /** The next scheduled run (epoch ms) for a registered schedule. */\n next(name: string): number | undefined {\n const row = this.driver\n .prepare(`SELECT next_run FROM _schedules WHERE name = ?`)\n .get(name) as { next_run: number } | undefined;\n return row?.next_run;\n }\n\n /** @internal — exposed for tests; runs one scheduling pass. */\n tick(): void {\n const t = nowMs();\n for (const [name, reg] of this.handlers) {\n const row = this.driver\n .prepare(`SELECT next_run FROM _schedules WHERE name = ?`)\n .get(name) as { next_run: number } | undefined;\n if (!row || row.next_run > t) continue;\n const next = nextCronRun(reg.c, new Date(t)).getTime();\n // Atomic claim: only the process that flips next_run gets to fire.\n const claimed =\n this.driver\n .prepare(\n `UPDATE _schedules SET last_run = ?, next_run = ? WHERE name = ? AND next_run <= ?`,\n )\n .run(t, next, name, t).changes > 0;\n if (claimed) {\n Promise.resolve()\n .then(() => reg.fn())\n .catch((err) => this.emit(\"error\", err, name));\n }\n }\n }\n\n /** Stop the scheduler (schedules remain persisted). */\n stop(): void {\n if (this.timer) clearInterval(this.timer);\n this.timer = undefined;\n }\n}\n\n/** Create a cron scheduler over a monlite database. */\nexport function createCron(db: Monlite, opts?: CronOptions): Cron {\n return new Cron(db, opts);\n}\n"]}
package/package.json ADDED
@@ -0,0 +1,66 @@
1
+ {
2
+ "name": "@monlite/cron",
3
+ "version": "0.1.0",
4
+ "description": "Cron-style job scheduling for @monlite/core: persisted schedules, 5-field cron, multi-process safe.",
5
+ "type": "module",
6
+ "main": "./dist/index.cjs",
7
+ "module": "./dist/index.js",
8
+ "types": "./dist/index.d.ts",
9
+ "exports": {
10
+ ".": {
11
+ "import": {
12
+ "types": "./dist/index.d.ts",
13
+ "default": "./dist/index.js"
14
+ },
15
+ "require": {
16
+ "types": "./dist/index.d.cts",
17
+ "default": "./dist/index.cjs"
18
+ }
19
+ }
20
+ },
21
+ "files": [
22
+ "dist"
23
+ ],
24
+ "sideEffects": false,
25
+ "keywords": [
26
+ "monlite",
27
+ "cron",
28
+ "scheduler",
29
+ "schedule",
30
+ "jobs",
31
+ "tasks",
32
+ "sqlite"
33
+ ],
34
+ "license": "MIT",
35
+ "author": "Emad Jumaah <emadjumaah@gmail.com>",
36
+ "repository": {
37
+ "type": "git",
38
+ "url": "git+https://github.com/qataruts/monlite.git",
39
+ "directory": "packages/kv"
40
+ },
41
+ "homepage": "https://github.com/qataruts/monlite/tree/main/packages/kv#readme",
42
+ "bugs": {
43
+ "url": "https://github.com/qataruts/monlite/issues"
44
+ },
45
+ "publishConfig": {
46
+ "access": "public"
47
+ },
48
+ "engines": {
49
+ "node": ">=18"
50
+ },
51
+ "dependencies": {
52
+ "@monlite/core": "^1.0.0"
53
+ },
54
+ "devDependencies": {
55
+ "@types/node": "^22.10.0",
56
+ "better-sqlite3": "^11.8.0",
57
+ "tsup": "^8.3.5",
58
+ "typescript": "^5.7.2",
59
+ "vitest": "^2.1.8"
60
+ },
61
+ "scripts": {
62
+ "build": "tsup",
63
+ "test": "vitest run",
64
+ "typecheck": "tsc --noEmit"
65
+ }
66
+ }