UNPKG

@cloudflare/actors

Version:

An easier way to build with Cloudflare Durable Objects

248 lines 9.24 kB
import { parseCronExpression } from "cron-schedule"; import { nanoid } from "nanoid"; function getNextCronTime(cron) { const interval = parseCronExpression(cron); return interval.getNextDate(); } export class Alarms { constructor(ctx, parent) { this.alarm = async (alarmInfo) => { const now = Math.floor(Date.now() / 1000); // Get all schedules that should be executed now const result = this.sql ` SELECT * FROM _actor_alarms WHERE time <= ${now} `; for (const row of result || []) { const callback = this.parent[row.callback]; if (!callback) { console.error(`callback ${row.callback} not found`); continue; } // await agentContext.run( // { agent: this, connection: undefined, request: undefined }, // async () => { try { await callback.bind(this.parent)(JSON.parse(row.payload), row); } catch (e) { console.error(`error executing callback "${row.callback}"`, e); } // } // ); if (row.type === "cron") { // Update next execution time for cron schedules const nextExecutionTime = getNextCronTime(row.cron); const nextTimestamp = Math.floor(nextExecutionTime.getTime() / 1000); this.sql ` UPDATE _actor_alarms SET time = ${nextTimestamp} WHERE id = ${row.id} `; } else { // Delete one-time schedules after execution this.sql ` DELETE FROM _actor_alarms WHERE id = ${row.id} `; } } // Schedule the next alarm await this._scheduleNextAlarm(); }; this.storage = ctx?.storage; this.parent = parent; void ctx?.blockConcurrencyWhile(async () => { return this._tryCatch(async () => { // Create alarms table if it doesn't exist ctx?.storage.sql.exec(` CREATE TABLE IF NOT EXISTS _actor_alarms ( id TEXT PRIMARY KEY NOT NULL DEFAULT (randomblob(9)), callback TEXT, payload TEXT, type TEXT NOT NULL CHECK(type IN ('scheduled', 'delayed', 'cron')), time INTEGER, delayInSeconds INTEGER, cron TEXT, created_at INTEGER DEFAULT (unixepoch()) ) `); // Execute any pending alarms and schedule the next alarm await this.alarm(); }); }); } /** * Schedule a task to be executed in the future * @template T Type of the payload data * @param when When to execute the task (Date, seconds delay, or cron expression) * @param callback Name of the method to call * @param payload Data to pass to the callback * @returns Schedule object representing the scheduled task */ async schedule(when, callback, payload) { const id = nanoid(9); if (typeof callback !== "string") { throw new Error("Callback must be a string"); } if (typeof this.parent[callback] !== "function") { throw new Error(`this.parent.${callback} is not a function`); } if (when instanceof Date) { const timestamp = Math.floor(when.getTime() / 1000); this.sql ` INSERT OR REPLACE INTO _actor_alarms (id, callback, payload, type, time) VALUES (${id}, ${callback}, ${JSON.stringify(payload)}, 'scheduled', ${timestamp}) `; await this._scheduleNextAlarm(); return { id, callback: callback, payload: payload, time: timestamp, type: "scheduled", }; } if (typeof when === "number") { const time = new Date(Date.now() + when * 1000); const timestamp = Math.floor(time.getTime() / 1000); this.sql ` INSERT OR REPLACE INTO _actor_alarms (id, callback, payload, type, delayInSeconds, time) VALUES (${id}, ${callback}, ${JSON.stringify(payload)}, 'delayed', ${when}, ${timestamp}) `; await this._scheduleNextAlarm(); return { id, callback: callback, payload: payload, delayInSeconds: when, time: timestamp, type: "delayed", }; } if (typeof when === "string") { const nextExecutionTime = getNextCronTime(when); const timestamp = Math.floor(nextExecutionTime.getTime() / 1000); this.sql ` INSERT OR REPLACE INTO _actor_alarms (id, callback, payload, type, cron, time) VALUES (${id}, ${callback}, ${JSON.stringify(payload)}, 'cron', ${when}, ${timestamp}) `; await this._scheduleNextAlarm(); return { id, callback: callback, payload: payload, cron: when, time: timestamp, type: "cron", }; } throw new Error("Invalid schedule type"); } /** * Get a scheduled task by ID * @template T Type of the payload data * @param id ID of the scheduled task * @returns The Schedule object or undefined if not found */ async getSchedule(id) { const result = this.sql ` SELECT * FROM _actor_alarms WHERE id = ${id} `; if (!result) { console.error(`schedule ${id} not found`); return undefined; } return { ...result[0], payload: JSON.parse(result[0].payload) }; } /** * Get scheduled tasks matching the given criteria * @template T Type of the payload data * @param criteria Criteria to filter schedules * @returns Array of matching Schedule objects */ getSchedules(criteria = {}) { let query = "SELECT * FROM _actor_alarms WHERE 1=1"; const params = []; if (criteria.id) { query += " AND id = ?"; params.push(criteria.id); } if (criteria.type) { query += " AND type = ?"; params.push(criteria.type); } if (criteria.timeRange) { query += " AND time >= ? AND time <= ?"; const start = criteria.timeRange.start || new Date(0); const end = criteria.timeRange.end || new Date(999999999999999); params.push(Math.floor(start.getTime() / 1000), Math.floor(end.getTime() / 1000)); } if (!this.storage?.sql) { // Or throw an error, depending on desired behavior return []; } const result = this.storage.sql .exec(query, ...params) .toArray() .map((row) => ({ ...row, payload: JSON.parse(row.payload), })); return result; } /** * Cancel a scheduled task * @param id ID of the task to cancel * @returns true if the task was cancelled, false otherwise */ async cancelSchedule(id) { this.sql `DELETE FROM _actor_alarms WHERE id = ${id}`; await this._scheduleNextAlarm(); return true; } async _scheduleNextAlarm() { // Find the next schedule that needs to be executed const result = this.sql ` SELECT time FROM _actor_alarms WHERE time > ${Math.floor(Date.now() / 1000)} ORDER BY time ASC LIMIT 1 `; if (!result || !this.storage) return; if (result.length > 0 && "time" in result[0]) { const nextTime = result[0].time * 1000; await this.storage.setAlarm(nextTime); } } /** * Execute SQL queries against the Agent's database * @template T Type of the returned rows * @param strings SQL query template strings * @param values Values to be inserted into the query * @returns Array of query results */ sql(strings, ...values) { let query = ""; try { // Construct the SQL query with placeholders query = strings.reduce((acc, str, i) => acc + str + (i < values.length ? "?" : ""), ""); if (!this.storage) { throw new Error("Storage not initialized"); } // Execute the SQL query with the provided values return [...this.storage.sql.exec(query, ...values)]; } catch (e) { console.error(`failed to execute sql query: ${query}`, e); throw e; } } async _tryCatch(fn) { try { return await fn(); } catch (e) { throw e; } } } //# sourceMappingURL=index.js.map