> ## Documentation Index
> Fetch the complete documentation index at: https://docs.pylonsync.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Scheduling

> Run recurring cron jobs and one-shot deferred functions without extra infrastructure.

Pylon runs background work with the same functions you already write.
It needs no separate worker process, queue service, or cron daemon.
There are two shapes: recurring (cron) and one-shot (deferred).

## Recurring: `cron(...)`

Declare a cron in your manifest. It fires a function every time the schedule matches (the scheduler checks once a minute).

```ts app.ts theme={null}
import { buildManifest, cron, discoverAppRoutes } from "@pylonsync/sdk";

export default buildManifest({
  name: "myapp",
  version: "0.1.0",
  entities: [/* ... */],
  routes: await discoverAppRoutes(),
  crons: [
    cron("0 * * * *", "hourlyRollup"),      // every hour, on the hour
    cron("*/5 * * * *", "pollInbox"),       // every 5 minutes
    cron("0 9 * * 1", "weeklyDigest"),      // Mondays at 09:00
  ],
});
```

The second argument is the name of a function in `functions/`: a normal `query`, `mutation`, or `action`:

```ts functions/hourlyRollup.ts theme={null}
import { mutation } from "@pylonsync/functions";

export default mutation({
  internal: true,          // not reachable over HTTP — only the scheduler runs it
  args: {},
  async handler(ctx) {
    // Server-side ctx.db.* is trusted — write your entities directly.
    const events = await ctx.db.query("Event");
    await ctx.db.insert("HourlyRollup", { count: events.length, at: new Date().toISOString() });
  },
});
```

<Note>
  **Make cron functions `internal: true`.** A cron function is also a regular function, so without `internal: true`, it is reachable at `/api/fn/<name>`. Marking it internal means only the scheduler (and `ctx.runMutation`) can invoke it.
</Note>

**Auth.** A cron has no caller, so it runs with anonymous auth, the same as Pylon's own built-in maintenance jobs. You usually do not need to do anything about that. A function's own `ctx.db.*` calls run server-side and are not subject to entity policies (those gate client sync, not trusted server code), so a maintenance cron reads and writes its entities directly.

Elevate only in two cases, and always with an audit `reason`:

```ts theme={null}
// 1. The cron chains an internal:true function via the scheduler, or
// 2. You run with PYLON_STRICT_FN_POLICIES=1 (function db ops policy-checked).
ctx.auth.elevate({ admin: true, reason: "nightly rollup" });
await ctx.scheduler.runAfter(0, "rebuildSearchIndex", {});
```

`reason` is mandatory. Every elevation is logged, so an operator can audit who elevated and why. Pylon never grants admin to a cron implicitly.

**Schedule format.** Standard 5-field cron: `minute hour day-of-month month day-of-week`. A schedule that does not parse, or a `function` name that is not registered, is logged loudly at boot and skipped. A typo fails visibly in `pylon dev` instead of silently never running.

**Durability.** Cron jobs are backed by a persistent job store. A fire that is in flight survives a restart, and the schedule resumes after a redeploy. Postgres stores the queue in the application database. SQLite stores it in a local sidecar database.

<Warning>
  **Execution is at least once.** A worker can finish an external side effect and stop before it records completion. Another worker can then run the job again. Make every scheduled function idempotent.
</Warning>

On Postgres, all replicas use one shared queue. Workers use row locks and short leases to claim jobs. Another replica can recover a job after its lease expires. One elected cron scheduler adds each matching cron to the shared queue once per tick. During a rolling release, each replica claims only functions that exist in that release.

SQLite jobs stay on one machine. Do not run one SQLite database on multiple replicas.

## One-shot: `ctx.scheduler`

To run something later (not on a repeating schedule), schedule it from inside a mutation or action:

```ts theme={null}
// run a function after a delay
await ctx.scheduler.runAfter(60_000, "sendReminder", { todoId });   // 60s later

// run at a specific time
await ctx.scheduler.runAt(dueDate.getTime(), "expireHold", { holdId });

// cancel a pending run
await ctx.scheduler.cancel(scheduleId);
```

The scheduled function runs with the auth identity of the caller that scheduled it. Pylon enforces this identity at schedule time. The `args` you pass are delivered verbatim. Like crons, these scheduled calls are durable across restarts.

When a Postgres mutation calls `runAfter` or `runAt`, Pylon commits the scheduled job in the same database transaction as the mutation writes. A rollback removes both the writes and the job. Scheduling from an action cannot share a transaction with its external work.

<Tip>
  Before `cron(...)` existed, the common pattern for recurring work was a self-perpetuating function: one that re-armed itself with `ctx.scheduler.runAfter(...)` at the end. That still works, but `cron(...)` is clearer and cannot silently stop if a re-arm is missed.
</Tip>
