mirror of
https://github.com/tiennm99/miti99bot.git
synced 2026-04-17 15:20:58 +00:00
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:
45
src/modules/cron-dispatcher.js
Normal file
45
src/modules/cron-dispatcher.js
Normal file
@@ -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<any>) => 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,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
})(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
66
src/modules/validate-cron.js
Normal file
66
src/modules/validate-cron.js
Normal file
@@ -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>|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`);
|
||||||
|
}
|
||||||
|
}
|
||||||
126
tests/modules/cron-dispatcher.test.js
Normal file
126
tests/modules/cron-dispatcher.test.js
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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", () => {
|
describe("getCurrentRegistry / resetRegistry", () => {
|
||||||
it("getCurrentRegistry throws before build", () => {
|
it("getCurrentRegistry throws before build", () => {
|
||||||
expect(() => getCurrentRegistry()).toThrow(/not built/);
|
expect(() => getCurrentRegistry()).toThrow(/not built/);
|
||||||
|
|||||||
59
tests/modules/validate-cron.test.js
Normal file
59
tests/modules/validate-cron.test.js
Normal 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");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user