# Schedules

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](https://fuast.com/docs/jobs.md) instead.

```ts
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:

```txt
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](https://fuast.com/docs/reference.md#deploy-without-an-account), and in a claimed app that is still [restricted](https://fuast.com/docs/security.md#restricted-apps), 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:

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

The [schedule-driven ingest example](https://fuast.com/docs/database.md#dashboard-counts) 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):

```sh
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:

```ts
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

```sh
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.
