Change Data at a Set Time
events_disabled.Some changes are due at a set time instead of right away. For example, a snoozed task comes back to the list, a poll closes, or a shared link stops working. On other platforms you'd store the time in a database and run a job that checks it every minute.
In Puter, you publish a function once, and create a one-time schedule that
runs it at that time with puter.events.onSchedule()
and at. Puter runs it in your app's events worker, a
serverless function that belongs to your app, even while your app is closed.
When all that's due is a notification, Remind Users Later sends one at a set time with no handler or permission. To delete a key at a set time, give it an expiry time, as Store Temporary Data shows.
The examples add snoozing to a task list app. The user hides a task until a time they pick. At that time the task comes back to the list, and the user gets a notification about it.
Publish the Handler
The function a schedule runs is called a handler. Publish it with
puter.events.handlers.publish(). This is a developer
step, like a deploy, and it only works for the account that owns the app. Run
it from a deploy script or your browser console:
const app = await puter.apps.get('task-list'); // your app's name
async function unsnooze ({ ctx, user }) {
const key = `task:${ctx.taskId}`;
const task = await user.kv.get(key);
if (!task) return; // deleted while it was snoozed
await user.notifications.notify({
title: 'Back on your list',
text: task.title,
target: `/tasks/${ctx.taskId}`,
});
await user.kv.update(key, { snoozedUntil: null });
}
await puter.events.handlers.publish('unsnooze', unsnooze, { appUid: app.uid });
The handler gets ctx, the context the schedule was created with, and
user, a puter object signed in as the user the schedule belongs to. It
reads the task from the user's key-value store, sends a notification
with user.notifications.notify(), and marks the
task as no longer snoozed with user.kv.update().
Run Code on a Schedule
explains what else a handler can use.
Ask for Permission
A schedule runs code while the user isn't there, so it needs the user's
events:background permission. Ask from your app with
puter.perms.request():
const allowed = await puter.perms.request('backgroundEvents');
It resolves to true or false, and shows no prompt when the permission is
already granted. Without it, onSchedule() fails with
events_background_consent_required. Schedules also need a Puter account,
so for a guest account onSchedule() fails with
events_durable_requires_account.
If the user revokes the permission later, every schedule your app holds for
them ends, and their snoozed tasks stay snoozed. When the app opens, it can
bring back any task whose snoozedUntil has passed.
Snooze a Task
To snooze a task, create a schedule in your app with
puter.events.onSchedule(). The at option is the time
it runs, once:
async function snooze (taskId, until) {
const schedule = await puter.events.onSchedule({
at: until,
handlerName: 'unsnooze',
context: { taskId },
});
await puter.kv.update(`task:${taskId}`, {
snoozedUntil: schedule.startAt,
snoozeId: schedule.scheduleId,
});
}
if (allowed) await snooze('42', new Date(Date.now() + 3 * 60 * 60 * 1000)); // three hours from now
The at option takes a Date, an ISO-8601 string with a time zone, or
milliseconds since the epoch, from 1 second to 366 days ahead. Puter rounds it
up to the next whole second, and schedule.startAt is the time the schedule
runs, in milliseconds since the epoch. A run usually starts within a minute of
it.
The handler gets context as ctx. The context is read once, when the
schedule is created, can't be changed later, and can be at most 4 KB. So it
holds only the task's id, and the handler reads the task when it runs. A task
renamed while it's snoozed comes back with its new name.
The task keeps the schedule's id in snoozeId. The next sections use it to
skip repeated runs and to cancel or move the snooze.
A user can have a limited number of one-time schedules waiting for your app.
Past it, onSchedule() rejects with
events_schedule_limit. See
Rate Limits and Quotas.
Skip a Run Already Done
Each run happens at least once. A run that times out, or fails with a network
error, is retried, and a retry can repeat work the first attempt already did.
Every attempt at a run has the same event.id, <scheduleId>:<scheduledFor>.
A one-time schedule has a single run, so event.scheduleId identifies the run
as well. The handler skips a run whose schedule isn't the task's current
snoozeId, and clears snoozeId when it brings the task back:
async function unsnooze ({ event, ctx, user }) {
const key = `task:${ctx.taskId}`;
const task = await user.kv.get(key);
if (!task || task.snoozeId !== event.scheduleId) return;
await user.notifications.notify({
title: 'Back on your list',
text: task.title,
target: `/tasks/${ctx.taskId}`,
group: `task:${ctx.taskId}`,
});
await user.kv.update(key, { snoozedUntil: null, snoozeId: null });
}
A few details:
- A retry of a run that finished finds
snoozeIdcleared, and does nothing. - The handler sends the notification before it changes the task. If the run
fails between the two, the retry sends the notification again. A later
notification with the same
groupreplaces the earlier one, so the user sees it once. - Two attempts that overlap, such as a run still going past its 30 seconds
when its retry starts, can both pass the check. Both write the same values,
and the
groupcovers the notification. - The same check skips a run for a snooze the user has since cancelled or moved, as the next sections show.
Publish the handler again after the change. A deploy script runs in a new
process each time, so its SDK hasn't seen what's published. Call
handlers.list() before publishing changed code, or
publish() rejects with events_handler_conflict:
await puter.events.handlers.list({ appUid: app.uid });
await puter.events.handlers.publish('unsnooze', unsnooze, { appUid: app.uid });
Bring a Task Back Early
When the user brings a task back before its time, clear the snooze and end
the schedule with puter.events.removeSchedule():
async function endSchedule (scheduleId) {
try {
await puter.events.removeSchedule(scheduleId);
} catch (error) {
// It already ran, or was already removed
if (error.code !== 'events_schedule_not_found') throw error;
}
}
async function unsnoozeNow (taskId) {
const key = `task:${taskId}`;
const task = await puter.kv.get(key);
if (!task?.snoozeId) return;
await puter.kv.update(key, { snoozedUntil: null, snoozeId: null });
await endSchedule(task.snoozeId);
}
A one-time schedule ends once its run is done. Removing a schedule that has
ended, or one that was already removed, rejects with
events_schedule_not_found, so endSchedule() treats that error as done.
The code clears the task before it removes the schedule. Removing a schedule
doesn't stop a run that already started, and that run finds snoozeId
cleared and does nothing. A removal that fails, for example with a network
error, leaves a schedule that runs once and changes nothing. Removing it still
matters, since a schedule counts toward the user's limit until it runs or is
removed.
Change the Snooze Time
A schedule can't be edited. To move a snooze, create a new schedule and
remove the old one. This version of snooze() does both, so it works for a
new snooze and for moving one:
async function snooze (taskId, until) {
const key = `task:${taskId}`;
const previous = await puter.kv.get(key);
const schedule = await puter.events.onSchedule({
at: until,
handlerName: 'unsnooze',
context: { taskId },
});
await puter.kv.update(key, {
snoozedUntil: schedule.startAt,
snoozeId: schedule.scheduleId,
});
if (previous?.snoozeId) await endSchedule(previous.snoozeId);
}
The task points at the new schedule before the old one is removed. If the old
one runs anyway, it finds a different snoozeId and does nothing.
Repeat a Few Times, Then Stop
Some changes repeat a set number of times. For example, a task such as "Take
your medicine" comes back every day for 5 days. Give
onSchedule() an every interval and maxRuns, and
the schedule ends on its own after its last run.
Each run of a repeating schedule has its own event.id, so the handler keeps
the id of the last run it handled and skips a retry of that run. Publish it as
the app's developer, like unsnooze:
async function reopenTask ({ event, ctx, user }) {
const key = `task:${ctx.taskId}`;
const task = await user.kv.get(key);
if (!task || task.repeatId !== event.scheduleId || task.lastRunId === event.id) return;
await user.notifications.notify({
title: 'Back on your list',
text: task.title,
target: `/tasks/${ctx.taskId}`,
group: `task:${ctx.taskId}`,
});
await user.kv.update(key, { done: false, lastRunId: event.id });
}
await puter.events.handlers.publish('reopenTask', reopenTask, { appUid: app.uid });
In the app, create the schedule and keep its id with the task, as snooze()
does:
async function repeatTask (taskId, every, times) {
const schedule = await puter.events.onSchedule({
every,
maxRuns: times,
handlerName: 'reopenTask',
context: { taskId },
});
await puter.kv.update(`task:${taskId}`, { repeatId: schedule.scheduleId });
}
async function stopRepeating (taskId) {
const key = `task:${taskId}`;
const task = await puter.kv.get(key);
if (!task?.repeatId) return;
await puter.kv.update(key, { repeatId: null });
await endSchedule(task.repeatId);
}
if (allowed) await repeatTask('43', '1d', 5); // once a day, 5 times
The first run is one interval after the call, so this reopens the task
tomorrow at this time and on the 4 days after that. To pick the first run,
pass startAt. The maxRuns option counts the times the schedule was due,
so a run skipped while the schedule was suspended still counts. To stop at a
time instead, pass expiresAt, the last time the schedule may run. With both,
whichever comes first ends the schedule.
An every interval is a fixed length of time, so a daily run moves by an hour
in local time when the clocks change. To run at the same local time each day,
use cron and tz, as
Run Code on a Schedule
does. The maxRuns option works with cron too.
A user can hold fewer repeating schedules than one-time ones for your app.
Past the limit, onSchedule() rejects with
events_schedule_limit. See
Rate Limits and Quotas.
Full Code
The recipe has two programs. The publish script runs as the app's developer, from a deploy script or your browser console:
const app = await puter.apps.get('task-list'); // your app's name
async function unsnooze ({ event, ctx, user }) {
const key = `task:${ctx.taskId}`;
const task = await user.kv.get(key);
if (!task || task.snoozeId !== event.scheduleId) return;
await user.notifications.notify({
title: 'Back on your list',
text: task.title,
target: `/tasks/${ctx.taskId}`,
group: `task:${ctx.taskId}`,
});
await user.kv.update(key, { snoozedUntil: null, snoozeId: null });
}
async function reopenTask ({ event, ctx, user }) {
const key = `task:${ctx.taskId}`;
const task = await user.kv.get(key);
if (!task || task.repeatId !== event.scheduleId || task.lastRunId === event.id) return;
await user.notifications.notify({
title: 'Back on your list',
text: task.title,
target: `/tasks/${ctx.taskId}`,
group: `task:${ctx.taskId}`,
});
await user.kv.update(key, { done: false, lastRunId: event.id });
}
await puter.events.handlers.list({ appUid: app.uid });
await puter.events.handlers.publish('unsnooze', unsnooze, { appUid: app.uid });
await puter.events.handlers.publish('reopenTask', reopenTask, { appUid: app.uid });
The app code runs in your app, signed in as the user:
async function endSchedule (scheduleId) {
try {
await puter.events.removeSchedule(scheduleId);
} catch (error) {
// It already ran, or was already removed
if (error.code !== 'events_schedule_not_found') throw error;
}
}
async function snooze (taskId, until) {
const key = `task:${taskId}`;
const previous = await puter.kv.get(key);
const schedule = await puter.events.onSchedule({
at: until,
handlerName: 'unsnooze',
context: { taskId },
});
await puter.kv.update(key, {
snoozedUntil: schedule.startAt,
snoozeId: schedule.scheduleId,
});
if (previous?.snoozeId) await endSchedule(previous.snoozeId);
}
async function unsnoozeNow (taskId) {
const key = `task:${taskId}`;
const task = await puter.kv.get(key);
if (!task?.snoozeId) return;
await puter.kv.update(key, { snoozedUntil: null, snoozeId: null });
await endSchedule(task.snoozeId);
}
async function repeatTask (taskId, every, times) {
const schedule = await puter.events.onSchedule({
every,
maxRuns: times,
handlerName: 'reopenTask',
context: { taskId },
});
await puter.kv.update(`task:${taskId}`, { repeatId: schedule.scheduleId });
}
async function stopRepeating (taskId) {
const key = `task:${taskId}`;
const task = await puter.kv.get(key);
if (!task?.repeatId) return;
await puter.kv.update(key, { repeatId: null });
await endSchedule(task.repeatId);
}
const allowed = await puter.perms.request('backgroundEvents');
if (allowed) {
await snooze('42', new Date(Date.now() + 3 * 60 * 60 * 1000)); // three hours from now
await repeatTask('43', '1d', 5); // once a day, 5 times
}