diff --git a/src/modules/cron-dispatcher.js b/src/modules/cron-dispatcher.js new file mode 100644 index 0000000..01cc13c --- /dev/null +++ b/src/modules/cron-dispatcher.js @@ -0,0 +1,45 @@ +/** + * @file cron-dispatcher — dispatches a Cloudflare scheduled event to all + * matching module cron handlers. + * + * Design: + * - Iterates registry.crons, filters by event.cron === entry.schedule. + * - Wraps each handler invocation in try/catch so one failure cannot block + * others (equivalent to Promise.allSettled fan-out via ctx.waitUntil). + * - ctx.waitUntil is fire-and-forget from Workers' perspective; we wrap in + * an async IIFE so errors are caught and logged rather than silently lost. + */ + +import { createSqlStore } from "../db/create-sql-store.js"; +import { createStore } from "../db/create-store.js"; + +/** + * @param {any} event — Cloudflare ScheduledEvent (has .cron string). + * @param {any} env + * @param {{ waitUntil: (p: Promise) => void }} ctx + * @param {import("./registry.js").Registry} registry + */ +export function dispatchScheduled(event, env, ctx, registry) { + const matching = registry.crons.filter((entry) => entry.schedule === event.cron); + + for (const entry of matching) { + const handlerCtx = { + db: createStore(entry.module.name, env), + sql: createSqlStore(entry.module.name, env), + env, + }; + + ctx.waitUntil( + (async () => { + try { + await entry.handler(event, handlerCtx); + } catch (err) { + console.error( + `[cron] handler "${entry.name}" (module "${entry.module.name}", schedule "${entry.schedule}") failed:`, + err, + ); + } + })(), + ); + } +} diff --git a/src/modules/validate-cron.js b/src/modules/validate-cron.js new file mode 100644 index 0000000..88031d5 --- /dev/null +++ b/src/modules/validate-cron.js @@ -0,0 +1,66 @@ +/** + * @file validate-cron — validates module-registered cron entries. + * + * Cron entry contract: + * - name: ^[a-z0-9_-]{1,32}$ — unique within the module (checked by registry) + * - schedule: non-empty string matching a cron-ish pattern + * (5 or 6 fields separated by spaces, e.g. "0 1 * * *") + * - handler: function + * + * All errors include module name + cron name for debuggability. + */ + +export const CRON_NAME_RE = /^[a-z0-9_-]{1,32}$/; + +/** + * Very loose cron expression check: 5 or 6 space-separated tokens. + * Cloudflare Workers validates the real expression at deploy time; + * we just catch obvious mistakes (empty string, random garbage). + */ +export const CRON_SCHEDULE_RE = /^\S+(\s+\S+){4,5}$/; + +/** + * @typedef {object} ModuleCron + * @property {string} name — unique identifier within the module. + * @property {string} schedule — cron expression, e.g. "0 1 * * *". + * @property {(event: any, ctx: { db: any, sql: any, env: any }) => Promise|void} handler + */ + +/** + * Throws on any contract violation. Called once per cron entry at registry build. + * + * @param {any} cron + * @param {string} moduleName — for error messages. + */ +export function validateCron(cron, moduleName) { + const prefix = `module "${moduleName}" cron`; + + if (!cron || typeof cron !== "object") { + throw new Error(`${prefix}: cron entry is not an object`); + } + + // name + if (typeof cron.name !== "string") { + throw new Error(`${prefix}: name must be a string`); + } + if (!CRON_NAME_RE.test(cron.name)) { + throw new Error( + `${prefix} "${cron.name}": name must match ${CRON_NAME_RE} (lowercase letters, digits, underscore, hyphen; 1–32 chars)`, + ); + } + + // schedule + if (typeof cron.schedule !== "string" || cron.schedule.trim().length === 0) { + throw new Error(`${prefix} "${cron.name}": schedule must be a non-empty string`); + } + if (!CRON_SCHEDULE_RE.test(cron.schedule.trim())) { + throw new Error( + `${prefix} "${cron.name}": schedule must be a valid cron expression (5 or 6 space-separated fields), got "${cron.schedule}"`, + ); + } + + // handler + if (typeof cron.handler !== "function") { + throw new Error(`${prefix} "${cron.name}": handler must be a function`); + } +} diff --git a/tests/modules/cron-dispatcher.test.js b/tests/modules/cron-dispatcher.test.js new file mode 100644 index 0000000..a9bbe60 --- /dev/null +++ b/tests/modules/cron-dispatcher.test.js @@ -0,0 +1,126 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { dispatchScheduled } from "../../src/modules/cron-dispatcher.js"; +import { makeFakeKv } from "../fakes/fake-kv-namespace.js"; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function makeFakeRegistry(entries) { + return { crons: entries }; +} + +function makeModule(name) { + return { name }; +} + +function makeCronEntry(moduleName, schedule, name, handler) { + return { module: makeModule(moduleName), schedule, name, handler }; +} + +function makeFakeCtx() { + const promises = []; + return { + ctx: { waitUntil: (p) => promises.push(p) }, + flush: () => Promise.all(promises), + }; +} + +function makeFakeEnv() { + return { KV: makeFakeKv(), DB: null, MODULES: "test" }; +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe("dispatchScheduled", () => { + it("calls handler for matching schedule", async () => { + const called = []; + const handler = vi.fn(async () => called.push("ran")); + const reg = makeFakeRegistry([makeCronEntry("mod", "0 1 * * *", "nightly", handler)]); + const { ctx, flush } = makeFakeCtx(); + + dispatchScheduled({ cron: "0 1 * * *" }, makeFakeEnv(), ctx, reg); + await flush(); + + expect(handler).toHaveBeenCalledOnce(); + expect(called).toEqual(["ran"]); + }); + + it("does NOT call handler when schedule does not match", async () => { + const handler = vi.fn(); + const reg = makeFakeRegistry([makeCronEntry("mod", "0 2 * * *", "other", handler)]); + const { ctx, flush } = makeFakeCtx(); + + dispatchScheduled({ cron: "0 1 * * *" }, makeFakeEnv(), ctx, reg); + await flush(); + + expect(handler).not.toHaveBeenCalled(); + }); + + it("fan-out: two modules sharing same schedule both fire", async () => { + const callLog = []; + const handlerA = async () => callLog.push("a"); + const handlerB = async () => callLog.push("b"); + const reg = makeFakeRegistry([ + makeCronEntry("mod-a", "*/5 * * * *", "tick-a", handlerA), + makeCronEntry("mod-b", "*/5 * * * *", "tick-b", handlerB), + ]); + const { ctx, flush } = makeFakeCtx(); + + dispatchScheduled({ cron: "*/5 * * * *" }, makeFakeEnv(), ctx, reg); + await flush(); + + expect(callLog.sort()).toEqual(["a", "b"]); + }); + + it("error isolation: one handler throwing does not prevent others", async () => { + const callLog = []; + const failing = async () => { + throw new Error("boom"); + }; + const surviving = async () => callLog.push("survived"); + const reg = makeFakeRegistry([ + makeCronEntry("mod-a", "0 0 * * *", "fail", failing), + makeCronEntry("mod-b", "0 0 * * *", "ok", surviving), + ]); + const { ctx, flush } = makeFakeCtx(); + const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + + dispatchScheduled({ cron: "0 0 * * *" }, makeFakeEnv(), ctx, reg); + await flush(); + + expect(callLog).toEqual(["survived"]); + expect(consoleSpy).toHaveBeenCalledOnce(); + expect(consoleSpy.mock.calls[0][0]).toMatch(/cron.*fail.*mod-a/); + consoleSpy.mockRestore(); + }); + + it("passes event and { db, sql, env } to handler", async () => { + let received; + const handler = async (event, handlerCtx) => { + received = { event, handlerCtx }; + }; + const env = makeFakeEnv(); + const reg = makeFakeRegistry([makeCronEntry("mod", "0 3 * * *", "ctx-check", handler)]); + const { ctx, flush } = makeFakeCtx(); + const fakeEvent = { cron: "0 3 * * *", scheduledTime: 123 }; + + dispatchScheduled(fakeEvent, env, ctx, reg); + await flush(); + + expect(received.event).toBe(fakeEvent); + expect(typeof received.handlerCtx.db).toBe("object"); + expect(typeof received.handlerCtx.sql).toBe("object"); + expect(received.handlerCtx.env).toBe(env); + }); + + it("no-op when registry has no cron entries", async () => { + const reg = makeFakeRegistry([]); + const { ctx, flush } = makeFakeCtx(); + // Should not throw. + expect(() => dispatchScheduled({ cron: "0 1 * * *" }, makeFakeEnv(), ctx, reg)).not.toThrow(); + await flush(); + }); +}); diff --git a/tests/modules/registry.test.js b/tests/modules/registry.test.js index eda64a5..0f931b6 100644 --- a/tests/modules/registry.test.js +++ b/tests/modules/registry.test.js @@ -129,6 +129,72 @@ describe("registry", () => { }); }); + describe("cron collection", () => { + const makeCron = (name, schedule = "0 1 * * *") => ({ + name, + schedule, + handler: async () => {}, + }); + + it("registry.crons is empty when no modules declare crons", async () => { + const map = makeFakeImportMap({ a: makeModule("a", [makeCommand("foo", "public")]) }); + const reg = await buildRegistry(makeEnv("a"), map); + expect(reg.crons).toEqual([]); + }); + + it("collects crons from modules that declare them", async () => { + const modWithCron = { + ...makeModule("a", [makeCommand("foo", "public")]), + crons: [makeCron("nightly")], + }; + const map = makeFakeImportMap({ a: modWithCron }); + const reg = await buildRegistry(makeEnv("a"), map); + expect(reg.crons).toHaveLength(1); + expect(reg.crons[0].name).toBe("nightly"); + expect(reg.crons[0].schedule).toBe("0 1 * * *"); + expect(reg.crons[0].module.name).toBe("a"); + }); + + it("fan-out: two modules with same schedule both appear in registry.crons", async () => { + const modA = { + ...makeModule("a", [makeCommand("ca", "public")]), + crons: [makeCron("tick", "*/5 * * * *")], + }; + const modB = { + ...makeModule("b", [makeCommand("cb", "public")]), + crons: [makeCron("tick", "*/5 * * * *")], + }; + const map = makeFakeImportMap({ a: modA, b: modB }); + const reg = await buildRegistry(makeEnv("a,b"), map); + expect(reg.crons).toHaveLength(2); + expect(reg.crons.map((c) => c.module.name).sort()).toEqual(["a", "b"]); + }); + + it("throws on duplicate cron name within the same module", async () => { + const mod = { + ...makeModule("a", [makeCommand("foo", "public")]), + crons: [makeCron("dup"), makeCron("dup")], + }; + const map = makeFakeImportMap({ a: mod }); + await expect(buildRegistry(makeEnv("a"), map)).rejects.toThrow(/duplicate cron name "dup"/); + }); + + it("throws when crons is not an array", async () => { + const mod = { ...makeModule("a", [makeCommand("foo", "public")]), crons: "bad" }; + const map = makeFakeImportMap({ a: mod }); + await expect(buildRegistry(makeEnv("a"), map)).rejects.toThrow(/crons must be an array/); + }); + + it("throws when a cron entry fails validation", async () => { + const mod = { + ...makeModule("a", [makeCommand("foo", "public")]), + crons: [{ name: "bad!", schedule: "0 1 * * *", handler: async () => {} }], + }; + const map = makeFakeImportMap({ a: mod }); + await expect(buildRegistry(makeEnv("a"), map)).rejects.toThrow(/name must match/); + }); + }); + describe("getCurrentRegistry / resetRegistry", () => { it("getCurrentRegistry throws before build", () => { expect(() => getCurrentRegistry()).toThrow(/not built/); diff --git a/tests/modules/validate-cron.test.js b/tests/modules/validate-cron.test.js new file mode 100644 index 0000000..a5e0fff --- /dev/null +++ b/tests/modules/validate-cron.test.js @@ -0,0 +1,59 @@ +import { describe, expect, it } from "vitest"; +import { validateCron } from "../../src/modules/validate-cron.js"; + +const noop = async () => {}; +const base = (overrides = {}) => ({ + name: "nightly", + schedule: "0 1 * * *", + handler: noop, + ...overrides, +}); + +describe("validateCron", () => { + it("accepts a valid cron entry", () => { + expect(() => validateCron(base(), "mod")).not.toThrow(); + }); + + it("accepts 6-field schedule (with seconds)", () => { + expect(() => validateCron(base({ schedule: "0 0 1 * * *" }), "mod")).not.toThrow(); + }); + + it("accepts names with hyphens and underscores", () => { + expect(() => validateCron(base({ name: "my-cron_job" }), "mod")).not.toThrow(); + }); + + it("rejects non-object entry", () => { + expect(() => validateCron(null, "mod")).toThrow(/not an object/); + expect(() => validateCron("string", "mod")).toThrow(/not an object/); + }); + + it("rejects name that fails pattern", () => { + expect(() => validateCron(base({ name: "Bad Name!" }), "mod")).toThrow(/name must match/); + expect(() => validateCron(base({ name: "" }), "mod")).toThrow(/name must match/); + expect(() => validateCron(base({ name: "a".repeat(33) }), "mod")).toThrow(/name must match/); + }); + + it("rejects empty schedule", () => { + expect(() => validateCron(base({ schedule: "" }), "mod")).toThrow(/non-empty/); + expect(() => validateCron(base({ schedule: " " }), "mod")).toThrow(/non-empty/); + }); + + it("rejects schedule with wrong field count", () => { + expect(() => validateCron(base({ schedule: "* * * *" }), "mod")).toThrow(/cron expression/); + expect(() => validateCron(base({ schedule: "not-a-cron" }), "mod")).toThrow(/cron expression/); + }); + + it("rejects non-function handler", () => { + expect(() => validateCron(base({ handler: null }), "mod")).toThrow(/handler/); + expect(() => validateCron(base({ handler: "fn" }), "mod")).toThrow(/handler/); + }); + + it("error messages include module name and cron name", () => { + try { + validateCron(base({ handler: null }), "trading"); + } catch (err) { + expect(err.message).toContain("trading"); + expect(err.message).toContain("nightly"); + } + }); +});