feat: add Cron Triggers support to module framework

- modules may declare crons: [{ schedule, name, handler }]
- handler signature (event, { db, sql, env }) matches init context
- scheduled() export in src/index.js dispatches to matching handlers with fan-out and per-handler error isolation
- registry validates cron entries and collects into registry.crons
- wrangler.toml [triggers] crons must still be populated manually by module author
This commit is contained in:
2026-04-15 13:22:17 +07:00
parent 83c6892d6e
commit 8235c9602e
5 changed files with 362 additions and 0 deletions

View File

@@ -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();
});
});

View File

@@ -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/);

View File

@@ -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");
}
});
});