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 self trigger. 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 Cron

The 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.store here is not tenant-scoped — a schedule runs as system with no tenant. Reads see every tenant's rows, and a write to a tenant() table fails with TenantScopeViolation unless you pass tenantId explicitly. 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=advisoryLock

The 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.