Cron Jobs and Scheduled Tasks
Run a daily report on a cron schedule inside a Rivet Actor, then add one-shot and fixed-interval jobs that survive sleep, restarts, and deploys.
Rivet Actor schedules are durable timers that live with the Actor. They survive sleep, restarts, upgrades, deploys, and crashes, so you do not need a separate cron service or a job queue to run work on time.
This guide adds a daily report to a reminders app. A reminderActor stores reminders that fire once, and a cron job on the same Actor sums them up every morning.
What we’ll build:
- A
reminderActorper key that stores reminders and the last daily report in its SQLite database. - A
daily-reportcron job, installed when the Actor is created, that runs every day at 9:00 Los Angeles time. - A
sendDailyReportaction that the job calls to build the report, save it, and broadcast it to connected clients. - One-shot reminders scheduled with
c.schedule.afterandc.schedule.at, and cancelled withc.schedule.cancel.
Install
npm add rivetkit @rivetkit/react
Create the reminder Actor
The Actor’s SQLite database holds the reminders and the bookkeeping the daily report needs:
import { actor, event, type ScheduledFireInfo, setup } from "rivetkit";
import { db, type RawAccess } from "rivetkit/db";
interface Reminder {
id: string;
scheduleId: string;
message: string;
scheduledAt: number;
completedAt?: number;
}
interface DailyReport {
from: number;
to: number;
completed: number;
pending: number;
}
const reminderActor = actor({
db: db({
onMigrate: async (db) => {
await db.execute(`
CREATE TABLE IF NOT EXISTS reminders (
seq INTEGER PRIMARY KEY AUTOINCREMENT,
id TEXT NOT NULL UNIQUE,
schedule_id TEXT NOT NULL,
message TEXT NOT NULL,
scheduled_at INTEGER NOT NULL,
completed_at INTEGER
)
`);
await db.execute(`
CREATE TABLE IF NOT EXISTS daily_report (
id INTEGER PRIMARY KEY CHECK (id = 1),
last_report_at INTEGER NOT NULL,
last_report TEXT
)
`);
await db.execute(
"INSERT OR IGNORE INTO daily_report (id, last_report_at) VALUES (1, 0)",
);
},
}),
events: {
reminderTriggered: event<Reminder>(),
dailyReport: event<DailyReport>(),
},
// Install the daily report job when the Actor is created.
onCreate: async (c) => {
await c.cron.set({
name: "daily-report",
expression: "0 9 * * *",
timezone: "America/Los_Angeles",
action: "sendDailyReport",
});
},
actions: {
// Called by the daily-report cron job every day at 9:00.
sendDailyReport: async (c, fire: ScheduledFireInfo) => {
const { lastReportAt: from, lastReport } = await loadDailyReport(c.db);
const to = fire.scheduledAt;
// Skip a run whose window was already reported.
if (to <= from) return lastReport;
const [counts] = (await c.db.execute(
`SELECT
COUNT(CASE WHEN completed_at >= ? AND completed_at < ? THEN 1 END) AS completed,
COUNT(CASE WHEN completed_at IS NULL THEN 1 END) AS pending
FROM reminders`,
from,
to,
)) as { completed: number; pending: number }[];
const report: DailyReport = {
from,
to,
completed: counts.completed,
pending: counts.pending,
};
await c.db.execute(
"UPDATE daily_report SET last_report_at = ?, last_report = ? WHERE id = 1",
to,
JSON.stringify(report),
);
c.broadcast("dailyReport", report);
return report;
},
// Return the last report, the next run time, and recent runs.
getDailyReport: async (c) => {
const job = await c.cron.get("daily-report");
const history = await c.cron.history("daily-report", { limit: 7 });
return {
lastReport: (await loadDailyReport(c.db)).lastReport,
nextRunAt: job?.nextRunAt,
history,
};
},
// Schedule a reminder with a delay in milliseconds
scheduleReminder: async (c, message: string, delayMs: number) => {
const id = `reminder-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`;
const scheduleId = await c.schedule.after(
delayMs,
"triggerReminder",
id,
);
const reminder: Reminder = {
id,
scheduleId,
message,
scheduledAt: Date.now() + delayMs,
};
await insertReminder(c.db, reminder);
return reminder;
},
// Schedule a reminder at a specific timestamp
scheduleReminderAt: async (c, message: string, timestamp: number) => {
const id = `reminder-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`;
const scheduleId = await c.schedule.at(
timestamp,
"triggerReminder",
id,
);
const reminder: Reminder = {
id,
scheduleId,
message,
scheduledAt: timestamp,
};
await insertReminder(c.db, reminder);
return reminder;
},
// Trigger a scheduled reminder
triggerReminder: async (c, reminderId: string) => {
const reminder = await findReminder(c.db, reminderId);
if (!reminder) {
console.warn(`reminder not found: ${reminderId}`);
return;
}
// Mark as completed
reminder.completedAt = Date.now();
await c.db.execute(
"UPDATE reminders SET completed_at = ? WHERE id = ?",
reminder.completedAt,
reminder.id,
);
// Broadcast event
c.broadcast("reminderTriggered", reminder);
console.log(`reminder triggered: ${reminder.message}`);
},
// Get all reminders
getReminders: async (c): Promise<Reminder[]> => {
const rows = (await c.db.execute(
`SELECT ${REMINDER_COLUMNS} FROM reminders ORDER BY seq`,
)) as ReminderRow[];
return rows.map(toReminder);
},
// Cancel a pending scheduled reminder.
cancelReminder: async (c, reminderId: string) => {
const reminder = await findReminder(c.db, reminderId);
if (!reminder) return { success: false };
const cancelled = await c.schedule.cancel(reminder.scheduleId);
if (cancelled) {
await c.db.execute("DELETE FROM reminders WHERE id = ?", reminderId);
}
return { success: cancelled };
},
// Get statistics about reminders
getStats: async (c) => {
const [counts] = (await c.db.execute(
"SELECT COUNT(*) AS total, COUNT(completed_at) AS completed FROM reminders",
)) as { total: number; completed: number }[];
const { total, completed } = counts;
const pending = total - completed;
return {
total,
completed,
pending,
};
},
},
});
type ReminderRow = {
id: string;
schedule_id: string;
message: string;
scheduled_at: number;
completed_at: number | null;
};
const REMINDER_COLUMNS = "id, schedule_id, message, scheduled_at, completed_at";
function toReminder(row: ReminderRow): Reminder {
return {
id: row.id,
scheduleId: row.schedule_id,
message: row.message,
scheduledAt: row.scheduled_at,
completedAt: row.completed_at ?? undefined,
};
}
async function insertReminder(db: RawAccess, reminder: Reminder) {
await db.execute(
"INSERT INTO reminders (id, schedule_id, message, scheduled_at) VALUES (?, ?, ?, ?)",
reminder.id,
reminder.scheduleId,
reminder.message,
reminder.scheduledAt,
);
}
async function findReminder(
db: RawAccess,
reminderId: string,
): Promise<Reminder | undefined> {
const rows = (await db.execute(
`SELECT ${REMINDER_COLUMNS} FROM reminders WHERE id = ?`,
reminderId,
)) as ReminderRow[];
return rows[0] ? toReminder(rows[0]) : undefined;
}
/** Reads the end of the last reported window and the last report, if any. */
async function loadDailyReport(
db: RawAccess,
): Promise<{ lastReportAt: number; lastReport?: DailyReport }> {
const [row] = (await db.execute(
"SELECT last_report_at, last_report FROM daily_report WHERE id = 1",
)) as { last_report_at: number; last_report: string | null }[];
return {
lastReportAt: row.last_report_at,
lastReport: row.last_report
? (JSON.parse(row.last_report) as DailyReport)
: undefined,
};
}
export const registry = setup({
use: { reminderActor },
});
export type Registry = typeof registry;
export type { DailyReport, Reminder };
registry.start();
onMigratecreates the tables before any other lifecycle hook runs. Thereminderstable belongs to the one-shot reminders you add in the last step. Thedaily_reportrow holdslast_report_atandlast_reportfor the daily report. Every write is saved before the action returns, so it survives sleep and restarts.last_report_atstarts at0, so the first report covers everything that happened before it.eventsdeclaresreminderTriggeredanddailyReport, the two typed events the Actor broadcasts.- Every schedule belongs to one Actor, so the key decides the scope of the job. The example uses a single key,
["main"], which gives one global job. Use one key per user or per resource for reminders, trials, billing periods, and other per-entity jobs.
Install the daily job
onCreate installs the cron job the first time the Actor is created:
import { actor, event, type ScheduledFireInfo, setup } from "rivetkit";
import { db, type RawAccess } from "rivetkit/db";
interface Reminder {
id: string;
scheduleId: string;
message: string;
scheduledAt: number;
completedAt?: number;
}
interface DailyReport {
from: number;
to: number;
completed: number;
pending: number;
}
const reminderActor = actor({
db: db({
onMigrate: async (db) => {
await db.execute(`
CREATE TABLE IF NOT EXISTS reminders (
seq INTEGER PRIMARY KEY AUTOINCREMENT,
id TEXT NOT NULL UNIQUE,
schedule_id TEXT NOT NULL,
message TEXT NOT NULL,
scheduled_at INTEGER NOT NULL,
completed_at INTEGER
)
`);
await db.execute(`
CREATE TABLE IF NOT EXISTS daily_report (
id INTEGER PRIMARY KEY CHECK (id = 1),
last_report_at INTEGER NOT NULL,
last_report TEXT
)
`);
await db.execute(
"INSERT OR IGNORE INTO daily_report (id, last_report_at) VALUES (1, 0)",
);
},
}),
events: {
reminderTriggered: event<Reminder>(),
dailyReport: event<DailyReport>(),
},
// Install the daily report job when the Actor is created.
onCreate: async (c) => {
await c.cron.set({
name: "daily-report",
expression: "0 9 * * *",
timezone: "America/Los_Angeles",
action: "sendDailyReport",
});
},
actions: {
// Called by the daily-report cron job every day at 9:00.
sendDailyReport: async (c, fire: ScheduledFireInfo) => {
const { lastReportAt: from, lastReport } = await loadDailyReport(c.db);
const to = fire.scheduledAt;
// Skip a run whose window was already reported.
if (to <= from) return lastReport;
const [counts] = (await c.db.execute(
`SELECT
COUNT(CASE WHEN completed_at >= ? AND completed_at < ? THEN 1 END) AS completed,
COUNT(CASE WHEN completed_at IS NULL THEN 1 END) AS pending
FROM reminders`,
from,
to,
)) as { completed: number; pending: number }[];
const report: DailyReport = {
from,
to,
completed: counts.completed,
pending: counts.pending,
};
await c.db.execute(
"UPDATE daily_report SET last_report_at = ?, last_report = ? WHERE id = 1",
to,
JSON.stringify(report),
);
c.broadcast("dailyReport", report);
return report;
},
// Return the last report, the next run time, and recent runs.
getDailyReport: async (c) => {
const job = await c.cron.get("daily-report");
const history = await c.cron.history("daily-report", { limit: 7 });
return {
lastReport: (await loadDailyReport(c.db)).lastReport,
nextRunAt: job?.nextRunAt,
history,
};
},
// Schedule a reminder with a delay in milliseconds
scheduleReminder: async (c, message: string, delayMs: number) => {
const id = `reminder-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`;
const scheduleId = await c.schedule.after(
delayMs,
"triggerReminder",
id,
);
const reminder: Reminder = {
id,
scheduleId,
message,
scheduledAt: Date.now() + delayMs,
};
await insertReminder(c.db, reminder);
return reminder;
},
// Schedule a reminder at a specific timestamp
scheduleReminderAt: async (c, message: string, timestamp: number) => {
const id = `reminder-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`;
const scheduleId = await c.schedule.at(
timestamp,
"triggerReminder",
id,
);
const reminder: Reminder = {
id,
scheduleId,
message,
scheduledAt: timestamp,
};
await insertReminder(c.db, reminder);
return reminder;
},
// Trigger a scheduled reminder
triggerReminder: async (c, reminderId: string) => {
const reminder = await findReminder(c.db, reminderId);
if (!reminder) {
console.warn(`reminder not found: ${reminderId}`);
return;
}
// Mark as completed
reminder.completedAt = Date.now();
await c.db.execute(
"UPDATE reminders SET completed_at = ? WHERE id = ?",
reminder.completedAt,
reminder.id,
);
// Broadcast event
c.broadcast("reminderTriggered", reminder);
console.log(`reminder triggered: ${reminder.message}`);
},
// Get all reminders
getReminders: async (c): Promise<Reminder[]> => {
const rows = (await c.db.execute(
`SELECT ${REMINDER_COLUMNS} FROM reminders ORDER BY seq`,
)) as ReminderRow[];
return rows.map(toReminder);
},
// Cancel a pending scheduled reminder.
cancelReminder: async (c, reminderId: string) => {
const reminder = await findReminder(c.db, reminderId);
if (!reminder) return { success: false };
const cancelled = await c.schedule.cancel(reminder.scheduleId);
if (cancelled) {
await c.db.execute("DELETE FROM reminders WHERE id = ?", reminderId);
}
return { success: cancelled };
},
// Get statistics about reminders
getStats: async (c) => {
const [counts] = (await c.db.execute(
"SELECT COUNT(*) AS total, COUNT(completed_at) AS completed FROM reminders",
)) as { total: number; completed: number }[];
const { total, completed } = counts;
const pending = total - completed;
return {
total,
completed,
pending,
};
},
},
});
type ReminderRow = {
id: string;
schedule_id: string;
message: string;
scheduled_at: number;
completed_at: number | null;
};
const REMINDER_COLUMNS = "id, schedule_id, message, scheduled_at, completed_at";
function toReminder(row: ReminderRow): Reminder {
return {
id: row.id,
scheduleId: row.schedule_id,
message: row.message,
scheduledAt: row.scheduled_at,
completedAt: row.completed_at ?? undefined,
};
}
async function insertReminder(db: RawAccess, reminder: Reminder) {
await db.execute(
"INSERT INTO reminders (id, schedule_id, message, scheduled_at) VALUES (?, ?, ?, ?)",
reminder.id,
reminder.scheduleId,
reminder.message,
reminder.scheduledAt,
);
}
async function findReminder(
db: RawAccess,
reminderId: string,
): Promise<Reminder | undefined> {
const rows = (await db.execute(
`SELECT ${REMINDER_COLUMNS} FROM reminders WHERE id = ?`,
reminderId,
)) as ReminderRow[];
return rows[0] ? toReminder(rows[0]) : undefined;
}
/** Reads the end of the last reported window and the last report, if any. */
async function loadDailyReport(
db: RawAccess,
): Promise<{ lastReportAt: number; lastReport?: DailyReport }> {
const [row] = (await db.execute(
"SELECT last_report_at, last_report FROM daily_report WHERE id = 1",
)) as { last_report_at: number; last_report: string | null }[];
return {
lastReportAt: row.last_report_at,
lastReport: row.last_report
? (JSON.parse(row.last_report) as DailyReport)
: undefined,
};
}
export const registry = setup({
use: { reminderActor },
});
export type Registry = typeof registry;
export type { DailyReport, Reminder };
registry.start();
c.cron.setstores a named recurring job on the Actor.0 9 * * *is a standard five-field cron expression for 9:00 every day.timezonetakes an IANA name and defaults to UTC. The job keeps firing at 9:00 local time across daylight saving changes.actionnames an action on the same Actor. The name is fixed in your code, never taken from a client.nameis unique per Actor. Callingcron.setagain with the same name updates the job instead of adding a second one, so there is no need to re-arm a one-shot schedule after every run.- The Actor does not stay awake between runs. It can sleep as usual, and Rivet wakes it when the job is due.
onCreate runs once per Actor, so a new expression in your code only reaches Actors created after the deploy. To update Actors that already exist, call the same cron.set from onWake, which runs on every start. Calling it with an unchanged expression keeps the next run time as it is.
Build the report
The cron job calls sendDailyReport, an ordinary action:
import { actor, event, type ScheduledFireInfo, setup } from "rivetkit";
import { db, type RawAccess } from "rivetkit/db";
interface Reminder {
id: string;
scheduleId: string;
message: string;
scheduledAt: number;
completedAt?: number;
}
interface DailyReport {
from: number;
to: number;
completed: number;
pending: number;
}
const reminderActor = actor({
db: db({
onMigrate: async (db) => {
await db.execute(`
CREATE TABLE IF NOT EXISTS reminders (
seq INTEGER PRIMARY KEY AUTOINCREMENT,
id TEXT NOT NULL UNIQUE,
schedule_id TEXT NOT NULL,
message TEXT NOT NULL,
scheduled_at INTEGER NOT NULL,
completed_at INTEGER
)
`);
await db.execute(`
CREATE TABLE IF NOT EXISTS daily_report (
id INTEGER PRIMARY KEY CHECK (id = 1),
last_report_at INTEGER NOT NULL,
last_report TEXT
)
`);
await db.execute(
"INSERT OR IGNORE INTO daily_report (id, last_report_at) VALUES (1, 0)",
);
},
}),
events: {
reminderTriggered: event<Reminder>(),
dailyReport: event<DailyReport>(),
},
// Install the daily report job when the Actor is created.
onCreate: async (c) => {
await c.cron.set({
name: "daily-report",
expression: "0 9 * * *",
timezone: "America/Los_Angeles",
action: "sendDailyReport",
});
},
actions: {
// Called by the daily-report cron job every day at 9:00.
sendDailyReport: async (c, fire: ScheduledFireInfo) => {
const { lastReportAt: from, lastReport } = await loadDailyReport(c.db);
const to = fire.scheduledAt;
// Skip a run whose window was already reported.
if (to <= from) return lastReport;
const [counts] = (await c.db.execute(
`SELECT
COUNT(CASE WHEN completed_at >= ? AND completed_at < ? THEN 1 END) AS completed,
COUNT(CASE WHEN completed_at IS NULL THEN 1 END) AS pending
FROM reminders`,
from,
to,
)) as { completed: number; pending: number }[];
const report: DailyReport = {
from,
to,
completed: counts.completed,
pending: counts.pending,
};
await c.db.execute(
"UPDATE daily_report SET last_report_at = ?, last_report = ? WHERE id = 1",
to,
JSON.stringify(report),
);
c.broadcast("dailyReport", report);
return report;
},
// Return the last report, the next run time, and recent runs.
getDailyReport: async (c) => {
const job = await c.cron.get("daily-report");
const history = await c.cron.history("daily-report", { limit: 7 });
return {
lastReport: (await loadDailyReport(c.db)).lastReport,
nextRunAt: job?.nextRunAt,
history,
};
},
// Schedule a reminder with a delay in milliseconds
scheduleReminder: async (c, message: string, delayMs: number) => {
const id = `reminder-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`;
const scheduleId = await c.schedule.after(
delayMs,
"triggerReminder",
id,
);
const reminder: Reminder = {
id,
scheduleId,
message,
scheduledAt: Date.now() + delayMs,
};
await insertReminder(c.db, reminder);
return reminder;
},
// Schedule a reminder at a specific timestamp
scheduleReminderAt: async (c, message: string, timestamp: number) => {
const id = `reminder-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`;
const scheduleId = await c.schedule.at(
timestamp,
"triggerReminder",
id,
);
const reminder: Reminder = {
id,
scheduleId,
message,
scheduledAt: timestamp,
};
await insertReminder(c.db, reminder);
return reminder;
},
// Trigger a scheduled reminder
triggerReminder: async (c, reminderId: string) => {
const reminder = await findReminder(c.db, reminderId);
if (!reminder) {
console.warn(`reminder not found: ${reminderId}`);
return;
}
// Mark as completed
reminder.completedAt = Date.now();
await c.db.execute(
"UPDATE reminders SET completed_at = ? WHERE id = ?",
reminder.completedAt,
reminder.id,
);
// Broadcast event
c.broadcast("reminderTriggered", reminder);
console.log(`reminder triggered: ${reminder.message}`);
},
// Get all reminders
getReminders: async (c): Promise<Reminder[]> => {
const rows = (await c.db.execute(
`SELECT ${REMINDER_COLUMNS} FROM reminders ORDER BY seq`,
)) as ReminderRow[];
return rows.map(toReminder);
},
// Cancel a pending scheduled reminder.
cancelReminder: async (c, reminderId: string) => {
const reminder = await findReminder(c.db, reminderId);
if (!reminder) return { success: false };
const cancelled = await c.schedule.cancel(reminder.scheduleId);
if (cancelled) {
await c.db.execute("DELETE FROM reminders WHERE id = ?", reminderId);
}
return { success: cancelled };
},
// Get statistics about reminders
getStats: async (c) => {
const [counts] = (await c.db.execute(
"SELECT COUNT(*) AS total, COUNT(completed_at) AS completed FROM reminders",
)) as { total: number; completed: number }[];
const { total, completed } = counts;
const pending = total - completed;
return {
total,
completed,
pending,
};
},
},
});
type ReminderRow = {
id: string;
schedule_id: string;
message: string;
scheduled_at: number;
completed_at: number | null;
};
const REMINDER_COLUMNS = "id, schedule_id, message, scheduled_at, completed_at";
function toReminder(row: ReminderRow): Reminder {
return {
id: row.id,
scheduleId: row.schedule_id,
message: row.message,
scheduledAt: row.scheduled_at,
completedAt: row.completed_at ?? undefined,
};
}
async function insertReminder(db: RawAccess, reminder: Reminder) {
await db.execute(
"INSERT INTO reminders (id, schedule_id, message, scheduled_at) VALUES (?, ?, ?, ?)",
reminder.id,
reminder.scheduleId,
reminder.message,
reminder.scheduledAt,
);
}
async function findReminder(
db: RawAccess,
reminderId: string,
): Promise<Reminder | undefined> {
const rows = (await db.execute(
`SELECT ${REMINDER_COLUMNS} FROM reminders WHERE id = ?`,
reminderId,
)) as ReminderRow[];
return rows[0] ? toReminder(rows[0]) : undefined;
}
/** Reads the end of the last reported window and the last report, if any. */
async function loadDailyReport(
db: RawAccess,
): Promise<{ lastReportAt: number; lastReport?: DailyReport }> {
const [row] = (await db.execute(
"SELECT last_report_at, last_report FROM daily_report WHERE id = 1",
)) as { last_report_at: number; last_report: string | null }[];
return {
lastReportAt: row.last_report_at,
lastReport: row.last_report
? (JSON.parse(row.last_report) as DailyReport)
: undefined,
};
}
export const registry = setup({
use: { reminderActor },
});
export type Registry = typeof registry;
export type { DailyReport, Reminder };
registry.start();
- When a schedule calls an action, it appends a
ScheduledFireInfoobject after the action’s own arguments. It carrieskind,id,name,scheduledAt, andfiredAt. - The report covers the window from
last_report_attofire.scheduledAt. Using the planned time instead ofDate.now()keeps every window exactly one day long, even when a run starts a little late. - If the window was already reported, the action returns the saved report and changes nothing. Running it twice for the same occurrence is harmless, so a manual retry is safe.
- It saves the report to the
daily_reportrow and broadcastsdailyReport, so connected clients see it right away. - A run that is still going when the next one is due makes the next one skip. Failed runs are not retried; the job continues at its next normal time. See Execution behavior.
sendDailyReport is a public action, so any client that can reach the Actor can call it with any arguments. In production, check who connects with onBeforeConnect (see Authentication) and keep scheduled actions idempotent like this one.
Renaming or removing sendDailyReport deletes the daily-report job the next time it is due, because a recurring job is removed when its action no longer exists. Rename the action and the action field of cron.set in the same deploy, and call cron.set from onWake so existing Actors pick up the new name.
Check on the job
getDailyReport returns the last report, the job’s next run time, and its recent runs:
import { actor, event, type ScheduledFireInfo, setup } from "rivetkit";
import { db, type RawAccess } from "rivetkit/db";
interface Reminder {
id: string;
scheduleId: string;
message: string;
scheduledAt: number;
completedAt?: number;
}
interface DailyReport {
from: number;
to: number;
completed: number;
pending: number;
}
const reminderActor = actor({
db: db({
onMigrate: async (db) => {
await db.execute(`
CREATE TABLE IF NOT EXISTS reminders (
seq INTEGER PRIMARY KEY AUTOINCREMENT,
id TEXT NOT NULL UNIQUE,
schedule_id TEXT NOT NULL,
message TEXT NOT NULL,
scheduled_at INTEGER NOT NULL,
completed_at INTEGER
)
`);
await db.execute(`
CREATE TABLE IF NOT EXISTS daily_report (
id INTEGER PRIMARY KEY CHECK (id = 1),
last_report_at INTEGER NOT NULL,
last_report TEXT
)
`);
await db.execute(
"INSERT OR IGNORE INTO daily_report (id, last_report_at) VALUES (1, 0)",
);
},
}),
events: {
reminderTriggered: event<Reminder>(),
dailyReport: event<DailyReport>(),
},
// Install the daily report job when the Actor is created.
onCreate: async (c) => {
await c.cron.set({
name: "daily-report",
expression: "0 9 * * *",
timezone: "America/Los_Angeles",
action: "sendDailyReport",
});
},
actions: {
// Called by the daily-report cron job every day at 9:00.
sendDailyReport: async (c, fire: ScheduledFireInfo) => {
const { lastReportAt: from, lastReport } = await loadDailyReport(c.db);
const to = fire.scheduledAt;
// Skip a run whose window was already reported.
if (to <= from) return lastReport;
const [counts] = (await c.db.execute(
`SELECT
COUNT(CASE WHEN completed_at >= ? AND completed_at < ? THEN 1 END) AS completed,
COUNT(CASE WHEN completed_at IS NULL THEN 1 END) AS pending
FROM reminders`,
from,
to,
)) as { completed: number; pending: number }[];
const report: DailyReport = {
from,
to,
completed: counts.completed,
pending: counts.pending,
};
await c.db.execute(
"UPDATE daily_report SET last_report_at = ?, last_report = ? WHERE id = 1",
to,
JSON.stringify(report),
);
c.broadcast("dailyReport", report);
return report;
},
// Return the last report, the next run time, and recent runs.
getDailyReport: async (c) => {
const job = await c.cron.get("daily-report");
const history = await c.cron.history("daily-report", { limit: 7 });
return {
lastReport: (await loadDailyReport(c.db)).lastReport,
nextRunAt: job?.nextRunAt,
history,
};
},
// Schedule a reminder with a delay in milliseconds
scheduleReminder: async (c, message: string, delayMs: number) => {
const id = `reminder-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`;
const scheduleId = await c.schedule.after(
delayMs,
"triggerReminder",
id,
);
const reminder: Reminder = {
id,
scheduleId,
message,
scheduledAt: Date.now() + delayMs,
};
await insertReminder(c.db, reminder);
return reminder;
},
// Schedule a reminder at a specific timestamp
scheduleReminderAt: async (c, message: string, timestamp: number) => {
const id = `reminder-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`;
const scheduleId = await c.schedule.at(
timestamp,
"triggerReminder",
id,
);
const reminder: Reminder = {
id,
scheduleId,
message,
scheduledAt: timestamp,
};
await insertReminder(c.db, reminder);
return reminder;
},
// Trigger a scheduled reminder
triggerReminder: async (c, reminderId: string) => {
const reminder = await findReminder(c.db, reminderId);
if (!reminder) {
console.warn(`reminder not found: ${reminderId}`);
return;
}
// Mark as completed
reminder.completedAt = Date.now();
await c.db.execute(
"UPDATE reminders SET completed_at = ? WHERE id = ?",
reminder.completedAt,
reminder.id,
);
// Broadcast event
c.broadcast("reminderTriggered", reminder);
console.log(`reminder triggered: ${reminder.message}`);
},
// Get all reminders
getReminders: async (c): Promise<Reminder[]> => {
const rows = (await c.db.execute(
`SELECT ${REMINDER_COLUMNS} FROM reminders ORDER BY seq`,
)) as ReminderRow[];
return rows.map(toReminder);
},
// Cancel a pending scheduled reminder.
cancelReminder: async (c, reminderId: string) => {
const reminder = await findReminder(c.db, reminderId);
if (!reminder) return { success: false };
const cancelled = await c.schedule.cancel(reminder.scheduleId);
if (cancelled) {
await c.db.execute("DELETE FROM reminders WHERE id = ?", reminderId);
}
return { success: cancelled };
},
// Get statistics about reminders
getStats: async (c) => {
const [counts] = (await c.db.execute(
"SELECT COUNT(*) AS total, COUNT(completed_at) AS completed FROM reminders",
)) as { total: number; completed: number }[];
const { total, completed } = counts;
const pending = total - completed;
return {
total,
completed,
pending,
};
},
},
});
type ReminderRow = {
id: string;
schedule_id: string;
message: string;
scheduled_at: number;
completed_at: number | null;
};
const REMINDER_COLUMNS = "id, schedule_id, message, scheduled_at, completed_at";
function toReminder(row: ReminderRow): Reminder {
return {
id: row.id,
scheduleId: row.schedule_id,
message: row.message,
scheduledAt: row.scheduled_at,
completedAt: row.completed_at ?? undefined,
};
}
async function insertReminder(db: RawAccess, reminder: Reminder) {
await db.execute(
"INSERT INTO reminders (id, schedule_id, message, scheduled_at) VALUES (?, ?, ?, ?)",
reminder.id,
reminder.scheduleId,
reminder.message,
reminder.scheduledAt,
);
}
async function findReminder(
db: RawAccess,
reminderId: string,
): Promise<Reminder | undefined> {
const rows = (await db.execute(
`SELECT ${REMINDER_COLUMNS} FROM reminders WHERE id = ?`,
reminderId,
)) as ReminderRow[];
return rows[0] ? toReminder(rows[0]) : undefined;
}
/** Reads the end of the last reported window and the last report, if any. */
async function loadDailyReport(
db: RawAccess,
): Promise<{ lastReportAt: number; lastReport?: DailyReport }> {
const [row] = (await db.execute(
"SELECT last_report_at, last_report FROM daily_report WHERE id = 1",
)) as { last_report_at: number; last_report: string | null }[];
return {
lastReportAt: row.last_report_at,
lastReport: row.last_report
? (JSON.parse(row.last_report) as DailyReport)
: undefined,
};
}
export const registry = setup({
use: { reminderActor },
});
export type Registry = typeof registry;
export type { DailyReport, Reminder };
registry.start();
c.cron.getreturns the job’s settings andnextRunAt, the Unix time in milliseconds of the next run.c.cron.historyreturns recent runs, newest first. Each entry has aresultofrunning,ok,error, orskipped, plus the error when a run failed. Since failed runs are not retried, this is where you find them and build your own retry if you need one.- A job keeps 100 history entries by default. Pass
maxHistorytocron.setto keep up to 1,000, or0to turn history off.
Add one-shot jobs, intervals, and cancellation
The reminders are one-shot schedules. Each one calls triggerReminder once and keeps the schedule ID so it can be cancelled:
import { actor, event, type ScheduledFireInfo, setup } from "rivetkit";
import { db, type RawAccess } from "rivetkit/db";
interface Reminder {
id: string;
scheduleId: string;
message: string;
scheduledAt: number;
completedAt?: number;
}
interface DailyReport {
from: number;
to: number;
completed: number;
pending: number;
}
const reminderActor = actor({
db: db({
onMigrate: async (db) => {
await db.execute(`
CREATE TABLE IF NOT EXISTS reminders (
seq INTEGER PRIMARY KEY AUTOINCREMENT,
id TEXT NOT NULL UNIQUE,
schedule_id TEXT NOT NULL,
message TEXT NOT NULL,
scheduled_at INTEGER NOT NULL,
completed_at INTEGER
)
`);
await db.execute(`
CREATE TABLE IF NOT EXISTS daily_report (
id INTEGER PRIMARY KEY CHECK (id = 1),
last_report_at INTEGER NOT NULL,
last_report TEXT
)
`);
await db.execute(
"INSERT OR IGNORE INTO daily_report (id, last_report_at) VALUES (1, 0)",
);
},
}),
events: {
reminderTriggered: event<Reminder>(),
dailyReport: event<DailyReport>(),
},
// Install the daily report job when the Actor is created.
onCreate: async (c) => {
await c.cron.set({
name: "daily-report",
expression: "0 9 * * *",
timezone: "America/Los_Angeles",
action: "sendDailyReport",
});
},
actions: {
// Called by the daily-report cron job every day at 9:00.
sendDailyReport: async (c, fire: ScheduledFireInfo) => {
const { lastReportAt: from, lastReport } = await loadDailyReport(c.db);
const to = fire.scheduledAt;
// Skip a run whose window was already reported.
if (to <= from) return lastReport;
const [counts] = (await c.db.execute(
`SELECT
COUNT(CASE WHEN completed_at >= ? AND completed_at < ? THEN 1 END) AS completed,
COUNT(CASE WHEN completed_at IS NULL THEN 1 END) AS pending
FROM reminders`,
from,
to,
)) as { completed: number; pending: number }[];
const report: DailyReport = {
from,
to,
completed: counts.completed,
pending: counts.pending,
};
await c.db.execute(
"UPDATE daily_report SET last_report_at = ?, last_report = ? WHERE id = 1",
to,
JSON.stringify(report),
);
c.broadcast("dailyReport", report);
return report;
},
// Return the last report, the next run time, and recent runs.
getDailyReport: async (c) => {
const job = await c.cron.get("daily-report");
const history = await c.cron.history("daily-report", { limit: 7 });
return {
lastReport: (await loadDailyReport(c.db)).lastReport,
nextRunAt: job?.nextRunAt,
history,
};
},
// Schedule a reminder with a delay in milliseconds
scheduleReminder: async (c, message: string, delayMs: number) => {
const id = `reminder-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`;
const scheduleId = await c.schedule.after(
delayMs,
"triggerReminder",
id,
);
const reminder: Reminder = {
id,
scheduleId,
message,
scheduledAt: Date.now() + delayMs,
};
await insertReminder(c.db, reminder);
return reminder;
},
// Schedule a reminder at a specific timestamp
scheduleReminderAt: async (c, message: string, timestamp: number) => {
const id = `reminder-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`;
const scheduleId = await c.schedule.at(
timestamp,
"triggerReminder",
id,
);
const reminder: Reminder = {
id,
scheduleId,
message,
scheduledAt: timestamp,
};
await insertReminder(c.db, reminder);
return reminder;
},
// Trigger a scheduled reminder
triggerReminder: async (c, reminderId: string) => {
const reminder = await findReminder(c.db, reminderId);
if (!reminder) {
console.warn(`reminder not found: ${reminderId}`);
return;
}
// Mark as completed
reminder.completedAt = Date.now();
await c.db.execute(
"UPDATE reminders SET completed_at = ? WHERE id = ?",
reminder.completedAt,
reminder.id,
);
// Broadcast event
c.broadcast("reminderTriggered", reminder);
console.log(`reminder triggered: ${reminder.message}`);
},
// Get all reminders
getReminders: async (c): Promise<Reminder[]> => {
const rows = (await c.db.execute(
`SELECT ${REMINDER_COLUMNS} FROM reminders ORDER BY seq`,
)) as ReminderRow[];
return rows.map(toReminder);
},
// Cancel a pending scheduled reminder.
cancelReminder: async (c, reminderId: string) => {
const reminder = await findReminder(c.db, reminderId);
if (!reminder) return { success: false };
const cancelled = await c.schedule.cancel(reminder.scheduleId);
if (cancelled) {
await c.db.execute("DELETE FROM reminders WHERE id = ?", reminderId);
}
return { success: cancelled };
},
// Get statistics about reminders
getStats: async (c) => {
const [counts] = (await c.db.execute(
"SELECT COUNT(*) AS total, COUNT(completed_at) AS completed FROM reminders",
)) as { total: number; completed: number }[];
const { total, completed } = counts;
const pending = total - completed;
return {
total,
completed,
pending,
};
},
},
});
type ReminderRow = {
id: string;
schedule_id: string;
message: string;
scheduled_at: number;
completed_at: number | null;
};
const REMINDER_COLUMNS = "id, schedule_id, message, scheduled_at, completed_at";
function toReminder(row: ReminderRow): Reminder {
return {
id: row.id,
scheduleId: row.schedule_id,
message: row.message,
scheduledAt: row.scheduled_at,
completedAt: row.completed_at ?? undefined,
};
}
async function insertReminder(db: RawAccess, reminder: Reminder) {
await db.execute(
"INSERT INTO reminders (id, schedule_id, message, scheduled_at) VALUES (?, ?, ?, ?)",
reminder.id,
reminder.scheduleId,
reminder.message,
reminder.scheduledAt,
);
}
async function findReminder(
db: RawAccess,
reminderId: string,
): Promise<Reminder | undefined> {
const rows = (await db.execute(
`SELECT ${REMINDER_COLUMNS} FROM reminders WHERE id = ?`,
reminderId,
)) as ReminderRow[];
return rows[0] ? toReminder(rows[0]) : undefined;
}
/** Reads the end of the last reported window and the last report, if any. */
async function loadDailyReport(
db: RawAccess,
): Promise<{ lastReportAt: number; lastReport?: DailyReport }> {
const [row] = (await db.execute(
"SELECT last_report_at, last_report FROM daily_report WHERE id = 1",
)) as { last_report_at: number; last_report: string | null }[];
return {
lastReportAt: row.last_report_at,
lastReport: row.last_report
? (JSON.parse(row.last_report) as DailyReport)
: undefined,
};
}
export const registry = setup({
use: { reminderActor },
});
export type Registry = typeof registry;
export type { DailyReport, Reminder };
registry.start();
scheduleReminderusesc.schedule.afterwith a delay in milliseconds.scheduleReminderAtusesc.schedule.atwith a Unix timestamp in milliseconds.- Both return a schedule ID, which the reminder stores as
scheduleId.cancelReminderpasses it toc.schedule.cancel, which returnsfalseif the reminder already fired. - Like the cron job, the target action name
triggerReminderis fixed in code, and the reminder ID is passed as its argument.
Pick the API by how often the work runs:
| API | Use it for |
|---|---|
c.schedule.after(delayMs, action, ...args) | Run once after a delay. Returns an ID for c.schedule.cancel. |
c.schedule.at(timestamp, action, ...args) | Run once at a Unix timestamp in milliseconds. Returns an ID for c.schedule.cancel. |
c.cron.set({ name, expression, action }) | Run on a calendar schedule, in a timezone. |
c.cron.every({ name, interval, action }) | Run every interval milliseconds, at least 5,000. Use it for frequent work such as presence sweeps or cache refreshes. Runs stay on their planned times, so a slow run does not push the next one later. |
c.cron.delete(name) | Stop a recurring job. Recurring jobs are managed by name, and calling cron.set or cron.every again with the same name replaces the job. |
See Schedule & Cron for c.schedule.list, c.cron.list, and the full set of options.
Try it
git clone https://github.com/rivet-dev/rivet.git
cd rivet/examples/scheduling
pnpm install
pnpm dev
Open http://localhost:5173. Schedule a reminder 5 seconds out and watch it move to completed when it fires. Schedule another and cancel it before it fires. The daily report runs at 9:00 Los Angeles time; getDailyReport shows its nextRunAt and, after the first run, the report and its history entry.
Next steps
- Schedule & Cron: every schedule and cron API, run history, and execution behavior.
- Lifecycle: when
onCreateandonWakerun, and how Actors sleep between scheduled runs. - Workflows: durable multi-step work for jobs that must finish even when a step fails.