Defining a schedule
defineSchedule — the *.cron.tsx file convention, cron expression syntax, the mandatory timezone, and the handler context.
A schedule is a *.cron.tsx file that default-exports defineSchedule({...}). The CLI discovers it the same way it discovers queries and mutations — no registration, no manifest.
// apps/api/schedules/cleanup.cron.tsx
import { defineSchedule } from '@voltro/runtime'
export default defineSchedule({
name: 'cleanupSessions',
cron: '*/15 * * * *',
timezone: 'UTC',
description: 'Prune expired sessions.',
handler: async ({ app, scheduledAt }) => {
await app.store.sessions.where({ expiresAt: { lt: scheduledAt } }).delete()
},
})The config
| Field | Required | Default | Notes |
|---|---|---|---|
name |
yes | — | Stable identifier. Used as the idempotency/coordination key and the dashboard label. |
cron |
yes | — | Standard cron expression (see below). Validated eagerly. |
timezone |
yes | — | IANA zone ("UTC", "Europe/Berlin"). Never server-local. |
handler |
one of handler / workflow |
— | (ctx) => void | Promise<void>. Custom work for this firing. |
workflow |
one of handler / workflow |
— | Direct workflow target: { name, payload }. Use when the schedule only starts durable work. |
trigger |
no | app default | 'self' or 'external' — per-job override. See triggers. |
onOverlap |
no | 'skip' |
'skip' / 'queue' / 'parallel'. See overlap. |
backfill |
no | 'skip' |
'skip' / 'latest' / 'all'. See backfill. |
maxRuntimeMs |
no | 1_800_000 (30 min) |
Watchdog. A run exceeding it is recorded failed with reason timeout. |
description |
no | — | Shown in the dashboard and generated external manifests. |
Direct workflow target
When the schedule's only job is to start a durable workflow, declare it directly instead of writing a one-line handler:
// apps/api/schedules/daily-rollup.cron.tsx
import { defineSchedule } from '@voltro/runtime'
export default defineSchedule({
name: 'dailyRollup',
cron: '0 2 * * *',
timezone: 'UTC',
workflow: {
name: 'billing.dailyRollup',
payload: ({ scheduledAt }) => ({
day: scheduledAt.toISOString().slice(0, 10),
}),
},
})Use handler when the firing needs custom branching, extra writes, or multiple side effects. You can still call ctx.app.workflows.start(...) manually from that handler.
Cron syntax
The expression is parsed by effect's Cron module, which accepts five fields (minute precision) or six fields (with a leading seconds field):
┌───────────── second (0–59) ← optional 6th field
│ ┌─────────── minute (0–59)
│ │ ┌───────── hour (0–23)
│ │ │ ┌─────── day of month (1–31)
│ │ │ │ ┌───── month (1–12)
│ │ │ │ │ ┌─── day of week (0–6, Sun=0)
│ │ │ │ │ │
* * * * * *| Expression | Fires |
|---|---|
0 9 * * * |
09:00 every day |
*/15 * * * * |
every 15 minutes |
0 0 1 * * |
midnight on the 1st of each month |
0 0 * * 1 |
midnight every Monday |
*/30 * * * * * |
every 30 seconds (six-field) |
A typo throws at definition time, surfaced in the boot log — not silently swallowed by a scheduler loop that then never fires:
defineSchedule("dailyDigest"): invalid cron expression "0 25 * * *" (tz="UTC") — …Six-field (seconds) expressions only run under the
selftrigger. They are rejected when generating external manifests because k8s/EventBridge/Cloud Scheduler are minute-granular.
The timezone is mandatory — on purpose
There is no default timezone. Omitting it throws. Server-local time is a bug factory: a container's TZ is usually UTC regardless of where your users are, so "09:00" silently means something different in dev, CI, and prod. Stating the zone makes the intent explicit and identical everywhere.
timezone: 'Europe/Berlin' // 09:00 Berlin — DST handled by effect's CronThe handler context
The handler receives a ScheduleContext — the same app a mutation gets, plus firing metadata.
The API is identical; the subject is not.
ctx.app.storehere is not tenant-scoped — a schedule runs assystemwith no tenant. Reads see every tenant's rows, and a write to atenant()table fails withTenantScopeViolationunless you passtenantIdexplicitly. See below. This sentence is here rather than only further down because "same shape as a mutation" is what sets the expectation that gets violated.
handler: async (ctx) => {
ctx.scheduledAt // Date — the instant this firing was scheduled for (deterministic, cron-derived)
ctx.firedAt // Date — when the handler actually started (may lag under load/contention)
ctx.runId // string — the _voltro_schedule_runs row id for this firing
ctx.trigger // 'self' | 'external' | 'manual' — where the firing came from
ctx.app // AppContext — store, request subject, webhooks, plugin interceptors
}Use scheduledAt, not Date.now(), for any time-bucketed query (the "prune sessions older than this slot" pattern). It's stable across replicas and reflects the intended instant even if the run was delayed.
ctx.trigger === 'manual' distinguishes a dashboard Run now click from a clock firing — handy when a manual run should skip a guard (e.g. "only on weekdays") that the scheduled path enforces.
A schedule runs as the SYSTEM subject — no tenant
ctx.app.store is not tenant-scoped. A schedule has no request, so it has
no signed-in user and no tenant to infer, and the framework refuses to pick one
for you. Reads see every tenant's rows.
That is the right default for what crons usually are — a backfill, a reconcile, a GC sweep — but it means a per-tenant cron has to say which tenant it means:
handler: async (ctx) => {
const tenants = await ctx.app.store.select('tenants').all()
for (const t of tenants) {
const stale = await ctx.app.store.select('sessions')
.where('tenantId', t.id) // explicit, not inferred
.where('expiresAt', '<', ctx.scheduledAt)
.all()
// …
}
}Writes to a tenant() table need the same treatment: pass tenantId
explicitly, or the write fails with TenantScopeViolation rather than landing
somewhere arbitrary.
ctx.app.storeForTenant(id) — the fan-out shortcut
Doing that by hand means every .where('tenantId', …) and every explicit
tenantId: is one forgotten call away from reading or writing across tenants.
storeForTenant hands you a store scoped to exactly one:
handler: async (ctx) => {
for (const t of await ctx.app.store.select('tenants').all()) {
const scoped = ctx.app.storeForTenant(t.id)
await scoped.insert('digests', { body: summary }) // tenantId stamped, not passed
}
}Inside a REQUEST this is almost always the wrong tool — the subject already carries a tenant, and reaching for another one is a cross-tenant access with extra steps. It exists because the system subject has no tenant to infer.
One implementation detail worth knowing, because it is counter-intuitive: the
scoped store does not run as a system subject with a tenant attached. A
system subject carries tenantId: null by construction and the tenant mixin
special-cases it to skip scoping entirely — on a system subject, a null tenant
means all tenants. So the scoped view runs as a serviceAccount bound to that
one tenant, keeping the schedule's scopes.
This behaves identically under voltro dev and voltro serve. It did not
always — before 0.10.0, dev scoped schedules to $TENANT (default acme)
while production ran them unscoped, so the same cron read one tenant in
development and all of them in production. If you added .unscoped() to a cron
to work around that, it is now a no-op and can go.
Discovery
voltro dev and voltro build glob **/*.cron.{ts,tsx} under your api app. Each discovered schedule is logged at boot:
scheduler: starting total=2 self=2 external=0 coordinator=advisoryLockThe framework also creates the _voltro_schedule_runs and (for advisoryLock) _voltro_schedule_claims tables — voltro migrate emits their DDL automatically, so you never hand-write a migration for them.