# Schedules (Cron)

`defineSchedule` is the trigger-side mirror of [`defineQueue`](/platform/queues). Drop a file in `services/schedules/` and the compiler emits a Worker `scheduled()` (cron) handler plus the matching `[triggers] crons` in `wrangler.toml` — no separate cron Worker, no hand-patching the generated entry.

## Define a schedule

```typescript
// services/schedules/sweepDigests.ts
import { defineSchedule } from "quickback";
import { runDigestSweep } from "../../features/comms/lib/digest-sweep";

export default defineSchedule({
  name: "sweepDigests",            // identifier + audit actor (system:cron-<name>)
  cron: "* * * * *",               // Cloudflare cron syntax
  description: "Send pending unread-email digests",
  execute: async ({ db, env, services }) => {
    await runDigestSweep(db, env, services);
  },
});
```

One file per schedule under `services/schedules/*.ts`. Files prefixed with `_` are ignored. `name` must be a valid camelCase identifier (it's used as a generated symbol and the audit actor).

## Importing feature code

Schedule files live at `quickback/services/schedules/<name>.ts`, so imports are relative to **that** location. To reach a feature table, a feature `lib/` helper, or `db/`, climb up to the `quickback/` root first (`../../`):

```typescript
// quickback/services/schedules/bookingReminders.ts
import { bookings }          from "../../features/crm/bookings";        // a feature table
import { generateIcsInvite } from "../../features/crm/lib/ics-generate"; // a feature lib/ helper
import { runDigestSweep }    from "../../lib/digest-sweep";             // a shared lib/ helper
```

Two `../` — up from `services/schedules/` to `quickback/`, then down into `features/` (or `lib/`, `db/`). The compiler relocates the handler into the generated `src/lib/schedules.ts` and **rewrites these paths to resolve from there automatically** — you always write them relative to your own source file, exactly as your editor resolves them.

> A single `../features/…` is the common mistake — from `services/schedules/` that resolves to `services/features/…`, which doesn't exist (the compiler consumes `services/`; there is no `src/services/`). Use `../../features/…`.


## What the compiler emits

1. **`scheduled(event, env, ctx)`** in the Worker's default export, dispatching to every schedule whose cron matches `event.cron`. Multiple schedules can share a cron; a failure in one is isolated from the others.
2. **`[triggers] crons = [...]`** in `wrangler.toml` — one deduped, sorted list across all schedules.
3. **`src/lib/schedules.ts`** — the generated runner. Regenerated on every `quickback compile`, so there's nothing to hand-maintain.

## Execute context

| Field | Type | Notes |
|-------|------|-------|
| `db` | Drizzle instance | The same DB the queue handlers get |
| `env` | `CloudflareBindings` | All worker bindings |
| `services` | `Services` | The services layer (`createServices(env)`) |
| `cron` | `string` | The cron pattern that fired |
| `scheduledTime` | `number` | `event.scheduledTime` (ms epoch) |
| `withInternalContext` | helper | Opt into the `INTERNAL` trust zone for `roles: ['INTERNAL']`-gated rows |

## Audit & trust

Cron runs are **server-internal**: there's no authenticated user, no request, no org scope. A fired tick gets a plain `db` (unscoped Drizzle) plus a `withInternalContext` helper:

```typescript
execute: async ({ db, withInternalContext }) => {
  // `db` here is unscoped — no firewall WHERE, no audit actor.

  // Opt into the INTERNAL trust zone for cross-tenant work:
  await withInternalContext(async ({ ctx, db, services }) => {
    // ctx.internal = true, ctx.userId = "system:cron-<name>"
    // audited writes are attributable to e.g. system:cron-sweepDigests
  });
},
```

`withInternalContext` builds a context with `internal: true` and `userId: "system:cron-<name>"`, so audited writes made through it are attributable to the schedule (e.g. `system:cron-sweepDigests`) and can reach `roles: ['INTERNAL']`-gated rows. Reach for it whenever the schedule legitimately operates across tenants.

## Retention and purge jobs

Retention is a strong fit for schedules because the cutoff stays in your
version-controlled business logic. This example permanently removes records
90 days after they were soft-deleted:

```typescript
// quickback/services/schedules/purgeDeletedRecords.ts
import { and, isNotNull, lt } from "drizzle-orm";
import { defineSchedule } from "quickback";
import { records } from "../../features/records/records";

const RETENTION_DAYS = 90;

export default defineSchedule({
  name: "purgeDeletedRecords",
  cron: "0 2 * * *",
  description: "Permanently remove records after the recovery window",
  execute: async ({ db, scheduledTime }) => {
    const cutoff = new Date(
      scheduledTime - RETENTION_DAYS * 24 * 60 * 60 * 1000,
    );

    await db
      .delete(records)
      .where(and(
        isNotNull(records.deletedAt),
        lt(records.deletedAt, cutoff),
      ));
  },
});
```

The schedule's plain `db` is intentionally unscoped so a maintenance job can
operate across tenants and can see rows hidden by the normal
`deletedAt IS NULL` firewall. Keep the cutoff predicate explicit, process large
sets in bounded batches, and add the audit/metrics your retention policy
requires. Use an on-demand admin action or queue workflow for subject- or
organization-level erasure that also needs to remove R2 objects, auth records,
or external-provider data.

See [Schema → Retention and permanent
erasure](/define/schema#retention-and-permanent-erasure) for the
full policy pattern, including hard-delete routes, cascades, and encrypted
data.

## Failure isolation

The generated `scheduled()` dispatcher runs every schedule whose cron matches `event.cron` concurrently (`Promise.all`), and each handler's errors are caught individually — one failing schedule logs `[Cron] Schedule error` and the others still run to completion. A cron with no registered handler logs `[Cron] No schedule registered` and returns.

## Cron syntax

Standard Cloudflare cron (`minute hour day-of-month month day-of-week`):

| Pattern | Runs |
|---------|------|
| `* * * * *` | every minute |
| `0 * * * *` | top of every hour |
| `0 0 * * *` | daily at 00:00 UTC |
| `*/5 * * * *` | every 5 minutes |

See Cloudflare's [Cron Triggers](https://developers.cloudflare.com/workers/configuration/cron-triggers/) docs for the full grammar and limits.

## See also

- [Queues](/platform/queues) — the async-message counterpart (`defineQueue`)
- [Triggers](/define/triggers) — row-level lifecycle hooks (distinct from cron)
