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

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