Browse the docs

Build

Schedules

Run handlers on a cron schedule. Syntax, identity, transactions, catch-up after downtime, testing, and inspection.

A schedule runs a handler on a timer: a morning digest, a sync every 15 minutes, a nightly cleanup. Declare it in the capsule. fuast runs it on the hosted app with no external scheduler. For work that an event starts (a reminder one day after a request), use jobs instead.

import { capsule, schedule } from "fuast/server";

export default capsule({
  // schema, queries, mutations...
  schedules: {
    cleanup: schedule("0 3 * * *", async (ctx) => {
      const cutoff = Date.now() - 30 * 86_400_000;
      const old = await ctx.db.events.withIndex("by_creation", (q) => q.lt("createdAt", cutoff)).take(500);
      for (const row of old) await ctx.db.events.delete(row.id);
      ctx.log.info("cleanup", { deleted: old.length });
    })
  }
});

schedule(cron, handler) takes a cron expression and a handler (ctx) => void | Promise<void>. The schedule's name is its key in schedules.

Cron syntax

Five fields, evaluated in UTC at minute resolution:

minute  hour  day-of-month  month  day-of-week
0-59    0-23  1-31          1-12   0-6 (0 = Sunday)

Each field accepts *, a number, a list (1,15), a range (1-5), and a step (*/15, 0-30/10).

Expression Runs
*/15 * * * * Every 15 minutes
0 9 * * * 09:00 UTC daily
0 9 * * 1-5 09:00 UTC on weekdays
30 17 * * 5 17:30 UTC on Fridays
0 0 1 * * Midnight UTC on the first of the month

When both day-of-month and day-of-week are restricted, a day matches if either matches, as in standard cron. 0 9 1 * 1 runs on the 1st and on every Monday.

An invalid expression fails fuast check and the deploy.

What a schedule can do

A schedule runs like a mutation:

  • It reads and writes ctx.db in one transaction. A throw rolls back every write.
  • It can fetch, send email, use ctx.storage, read and write team tables, and list ctx.workspace.members().
  • It has the same time limits (5 s of CPU, 10 s wall clock including fetch) and database limits as any handler.
  • Its writes update every subscribed client, like a mutation.

It runs as the system, not as a person: ctx.auth.userId is null, isSignedIn and isGuest are false, and displayName is "system". requireIdentity() and requireSignedIn() throw. Do not call shared helpers that expect a caller. Pass ids explicitly instead.

Schedules do not count against the daily request or mutation quotas.

Timing and catch-up

  • Each schedule's next run time is stored, so a restart does not skip or repeat a run.
  • After downtime, a schedule that missed runs executes once to catch up, then returns to its normal times. Missed runs are never replayed one by one.
  • One run of a schedule never overlaps another run of the same schedule.
  • Runs start within a few seconds of the minute. Do not depend on sub-minute precision.
  • In an unclaimed preview, and in a claimed app that is still restricted, schedules are registered but do not run. They start at their next time once the owner trusts the app (fuast trust).

Each run is logged with source: "system" and the schedule's name, including its error if it threw. A failed run is not retried early. The next scheduled run happens as usual.

Work in batches

A run has 5 s of CPU, 10 s of wall-clock time, and the normal read and write budgets. For big jobs, do one bounded batch per run and keep a cursor in a table:

cursors: table({ name: string(), after: number() }).index("by_name", ["name"])

The schedule-driven ingest example pulls one page of an external API per run and keeps daily counts.

Test a schedule

Run any schedule now, without waiting for its time, as the system identity (its next run time does not change):

fuast run schedule digest --port 3000   # local fuast dev
fuast run schedule digest my-app        # deployed app (owner or workspace admin)

The dashboard Schedules tab has the same Run now button.

You can also put the work in a helper that takes ctx, call it from the schedule, and call it from a mutation while you develop:

import { capsule, mutation, schedule, type ServerContext } from "fuast/server";

async function sendDigest(ctx: ServerContext) {
  // ...
}

export default capsule({
  schedules: {
    digest: schedule("0 9 * * 1-5", (ctx) => sendDigest(ctx))
  },
  mutations: {
    runDigestNow: mutation(async (ctx) => {
      ctx.auth.requireSignedIn();
      await sendDigest(ctx);
    })
  }
});

Remember that the mutation runs as you, while the schedule runs as the system. Keep the helper independent of ctx.auth, or guard the test mutation so only an admin can run it.

fuast dev runs schedules on their real times while it is running. Short expressions like * * * * * are handy while testing. Change them back before deploying. FUAST_DEV_CLOCK_OFFSET_MS shifts the clock fuast dev evaluates cron expressions against (for example, so an automated test reaches the next minute boundary in a few seconds).

Inspect schedules

fuast inspect --port 3000       # local: each schedule with its cron and next run
fuast inspect my-app            # deployed app, by slug
fuast logs my-app --follow      # watch runs and errors

The dashboard Schedules tab shows each schedule's cron, last run, result, and next run. Schedules are re-read from the capsule on every deploy: adding, removing, or changing one takes effect with the deploy.