Skip to content

9. On a schedule

schedule: cron runs a function with nobody calling — ctx.admin.db always, no user, no input. cleanup sweeps expired shares.

Some work shouldn’t wait for a person: the morning digest, the nightly sweep. One line in function.yaml and the function runs itself.

schedule

# digest/function.yaml — the same function as chapter 8, plus:
schedule: "@daily"

Cron expressions work too ("0 7 * * *", UTC unless you add a timezone). A scheduled run has no caller: ctx.user is null, ctx.input is null, ctx.db acts as an anonymous caller — and ctx.admin.db is always available, recorded as func:shelf/main/digest. Review a scheduled function as if it declared admin: true (Cron).

That’s also why digest’s input schema accepts null and the code defaults since_days — the scheduled run sends nothing.

Add that line to digest/function.yaml and rebuild. To watch the clock without waiting a day, set schedule: "* * * * *" for a minute: docker compose logs backd shows scheduled job queued and the digest’s own function log line at the next minute mark — then put @daily back. Backd then runs it by itself in the background process (--with-worker is in the tutorial’s compose file); there is nothing else to start.

cleanup

Expired share links are litter; notifications read a month ago are stale. Nobody should click “clean” — the clock does it:

examples/config/shelf/main/_functions/cleanup/function.yaml

# Deletes expired share links and read notifications older than a month.
#
# `internal: true`: nobody can call it over HTTP — the schedule, and admin
# invokes run by hand, are its only triggers. Both give it ctx.admin.db:
# a scheduled run has no caller and always gets it; `admin: true` is for
# HTTP callers, so it isn't set here.
#
#   backd functions invoke --function shelf/main/cleanup
internal: true
mode: async
schedule: "0 3 * * *"   # every day at 03:00 UTC
timeout: 10m

internal: true + schedule: — no HTTP route, no caller, runs at 03:00 UTC. Note admin: isn’t set: it exists for HTTP callers, and this function has none. (The same file is why mode: async + timeout: show up together — sweeping many documents takes longer than a request should.)

examples/config/shelf/main/_functions/cleanup/index.ts

// cleanup: sweep what time invalidates — expired share links, and
// notifications already read and a month old. A scheduled run has no
// caller and no input; ctx.admin.db is always there.
import type { Context } from "../lib/types.ts";

const KEEP_READ_NOTIFICATIONS = 30 * 86_400_000; // 30 days

export default async function handler(ctx: Context) {
  const db = ctx.admin.db("main");
  const now = new Date().toISOString();
  const staleBefore = new Date(Date.now() - KEEP_READ_NOTIFICATIONS).toISOString();

  let shares = 0;
  // $ne: null because a null expires_at (a link that never dies) must not
  // match "expires before now".
  for await (const s of db.collection("shares").iterate({ where: { expires_at: { $ne: null, $lt: now } } })) {
    await db.collection("shares").delete(s.id);
    shares++;
  }

  let notifications = 0;
  for await (
    const n of db.collection("notifications").iterate({
      where: { read_at: { $ne: null }, "_meta.created_at": { $lt: staleBefore } },
    })
  ) {
    await db.collection("notifications").delete(n.id);
    notifications++;
  }

  console.log(`cleanup: removed ${shares} expired shares, ${notifications} old notifications`);
  return { shares, notifications };
}

fetch the chapter's files (optional — the listings above are the same files)

for f in main/_functions/cleanup/function.yaml main/_functions/cleanup/index.ts main/_functions/cleanup/index.test.ts; do
  mkdir -p "config/shelf/$(dirname "$f")"
  curl -fsSL "https://fernandezvara.github.io/backd/tutorial/config/shelf/$f" -o "config/shelf/$f"
done

Running by hand

You don’t wait for the clock to know it works. The admin API runs any function by hand, internal ones included, as its schedule would — with the operator’s $OP session from chapter 4 (the CLI form, backd functions invoke --function shelf/main/cleanup, does the same from a machine with a stored login):

curl -s -X POST http://localhost:8080/v1/shelf/_admin/functions/main/cleanup/invoke \
  -H "Authorization: Bearer $OP" -H 'Content-Type: application/json' -d '{}'
# 202 {"id":"…","status":"queued", …}  — a job; poll GET /v1/shelf/main/_jobs/<id> (same $OP) until "status":"done"

The app does the same from the Admin view: backd.admin.invokeFunction('main/cleanup'), and its result (or job) under Run by hand. That’s the second invoke path in this tutorial: db.fn for functions with a route and an invoke rule; admin.invokeFunction for everything else — invoke rules and rate_limit don’t apply, so guard it to admins in the UI.

You should see

  • The invoke call above answers with a job; expired shares and month-old read notifications are gone.
  • Run by hand in the app runs either function and shows the job or result — cleanup included, despite having no HTTP route.
  • docker compose logs backd | grep "function log" the morning after shows the scheduled digest ran itself.
  • deno test covers cleanup’s keep/delete split.

Next: chapter 10 — email for real: templates, a delivery function, and the digest going out by mail.

Edit this page · Last updated 08 Oct 2026