Schedules
Schedules represent time-triggered application work. Use a schedule when the code says "run this at this time": send daily digests, sync billing state, clean expired records, refresh search indexes, or generate periodic reports.
Beignet schedules are typed definitions. They describe the cron expression, optional timezone, payload schema, and handler. The runtime that triggers them can be a cron route, Inngest, Vercel Cron, a worker process, or an app-owned adapter.
bun add @beignet/coreDefine a schedule
Start with the Quickstart app. Generate a schedule and its central registry:
bun beignet make schedule todos.daily-checkThis complete generated definition logs the execution date. It needs no jobs provider or additional feature:
// features/todos/schedules/daily-check.ts
import { z } from "zod";
import { defineSchedule } from "@/lib/schedules";
export const DailyCheckSchedulePayloadSchema = z.object({
date: z.string(),
});
export type DailyCheckSchedulePayload = z.infer<typeof DailyCheckSchedulePayloadSchema>;
export const DailyCheckSchedule = defineSchedule(
"todos.daily-check",
{
cron: "0 9 * * *",
payload: DailyCheckSchedulePayloadSchema,
createPayload({ run }) {
const date = run.scheduledAt ?? run.triggeredAt;
return {
date: date.toISOString().slice(0, 10),
};
},
async handle({ payload, ctx }) {
ctx.ports.logger.info("Schedule handled", {
scheduleName: "todos.daily-check",
date: payload.date,
});
},
},
);createPayload(...) derives schema-validated input from the trigger metadata.
The cron expression is a declaration; creating the file does not start a timer
or register it with a hosting platform.
Run a schedule
bun beignet db migrate
bun run typecheck
bun beignet schedule run todos.daily-check --scheduled-at 2026-01-01T09:00:00.000ZExpect a Schedule handled log naming todos.daily-check with date 2026-01-01.
The CLI runs it once and closes the server. It does not wait for the cron time.
Omit --scheduled-at to use the current trigger time, or pass
--payload '{"date":"2026-01-01"}' to supply the payload explicitly.
server/schedules.ts contains the central registry, createScheduleContext(),
and stopScheduleContext(). The generator creates these and registers new
feature schedules. Run bun beignet doctor --strict after manual registry edits.
Run inline
Use the inline runner for an app-owned script or test. This complete script uses the server's scoped service context and closes the server when it finishes:
// scripts/run-daily-check.ts
import { createInlineScheduleRunner } from "@beignet/core/schedules";
import { createServiceActor } from "@beignet/core/ports";
import { DailyCheckSchedule } from "@/features/todos/schedules/daily-check";
import { getServer } from "@/server";
const server = await getServer();
try {
await server.runServiceContext(
{ actor: createServiceActor("beignet-schedule") },
async (ctx) => {
const runner = createInlineScheduleRunner({ ctx });
await runner.run(DailyCheckSchedule, {
scheduledAt: new Date("2026-01-01T09:00:00.000Z"),
attempt: 1,
source: "manual",
});
},
);
} finally {
await server.stop();
}Run bun run scripts/run-daily-check.ts from the app root. Expect the same log
as the CLI command. For isolated tests, provide test ports instead of booting
the server; see Testing.
Expose a cron route
In Next.js apps, prefer createScheduleRoute(...) from @beignet/next so the
public trigger route stays small. The helper authenticates the schedule
provider with a timing-safe bearer comparison, creates an app context, runs the
schedule inline, and records schedule events through the instrumentation
port resolved from ctx.ports:
To generate the route and secret configuration alongside a new schedule, run
bun beignet make schedule todos.daily-check --route in place of the command
above. For an existing schedule, create the route below and add CRON_SECRET
to your environment schema and deployment environment.
// app/api/cron/todos/daily-check/route.ts
import { createScheduleRoute } from "@beignet/next";
import { env } from "@/lib/env";
import { getServer } from "@/server";
import { schedules } from "@/server/schedules";
export const runtime = "nodejs";
export const { GET, POST } = createScheduleRoute({
server: getServer,
schedules,
schedule: "todos.daily-check",
secret: env.CRON_SECRET,
source: "vercel-cron",
});The schedule name is resolved when the route module loads, so a typo or an
unregistered schedule fails at build or boot time instead of at the first cron
invocation. Export both GET and POST when your scheduler may call either
method. The schedule remains the reusable unit — Vercel Cron, Inngest, a
worker, or a local script can all trigger the same definition — and schedule
failures return a 500 response so providers can retry failed invocations.
Cron routes fail closed when CRON_SECRET is missing. Set it in your
deployment environment and send Authorization: Bearer <secret> from your cron
provider. Beignet checks the secret before resolving the server or creating app
context. beignet make schedule --route scaffolds this route and adds a
generated CRON_SECRET to lib/env.ts and .env.example when the app does not
define one yet.
See Runtime recipes for when to use
cron routes, scheduled functions, worker-hosted tasks, or
beignet schedule run.
Schedules and jobs
Schedules decide when work should begin. Jobs own durable work. See Workflow primitives when deciding whether the scheduled handler should call a command use case, dispatch a job, send a notification, or write an outbox message.
When work needs durable retries, follow Jobs to define and register a job and configure its dispatcher first. Then have the schedule handler dispatch that job. This keeps retries in the job provider and lets the same job run from HTTP, events, scripts, or manual admin actions. The daily check above can run inline because losing one log does not require retryable delivery.
Failure semantics
Schedules do not define retry policies. They are trigger definitions. Cron
providers, worker hosts, and queue systems decide whether to retry a failed
schedule invocation, with provider-owned backoff. Beignet preserves that
behavior by rethrowing handler failures after onError runs. Dead-lettering
is not a schedule concept in core: for critical work, keep the schedule
handler small and dispatch a job or outbox message so retry policy, backoff,
and dead-letter behavior move into Beignet's durable primitives, described in
the retry vocabulary.
Cron syntax and timezone support are also provider-owned: Beignet stores the declared values but does not attempt to validate every scheduler dialect. Verify them against the deployment provider. Core does not prevent overlapping schedule invocations; platform retries or a slow prior run may overlap. Keep the trigger idempotent, dispatch a uniquely keyed job, or acquire an app-owned lock when one-at-a-time execution is required.
Observability
Pass a provider instrumentation target as instrumentation and the inline
runner records first-class schedule events for each run: started before
the handler, completed after it, and failed with the error when payload
creation, validation, or the handler throws. The target accepts the same
shapes as other Beignet subsystems, including the whole ports object:
const runner = createInlineScheduleRunner<AppContext>({
ctx,
instrumentation: ctx.ports,
instrumentationContext: {
requestId: ctx.requestId,
traceId: ctx.traceId,
},
});instrumentationContext attaches request correlation fields so the devtools
request view can expand into the schedule runs triggered by that invocation.
createScheduleRoute(...) and beignet schedule run wire this up
automatically from ctx.ports, and providers can emit the same schedule
devtools events through provider instrumentation. These events respect enabled
watchers and redaction settings. Both entrypoints also report terminal schedule
failures through an optional ctx.ports.errorReporter; reporter failures do not
replace the 500 response or CLI failure.
For custom logging or metrics, the runner also exposes onStart, onSuccess,
and onError lifecycle hooks. Lifecycle hook failures are isolated from
schedule execution and reported to onHookError when provided — they never
prevent the handler from running or turn a successful run into a failure.
Errors while recording instrumentation do not affect schedule execution.
Schedule handler failures still reject runner.run(...) after
onError runs, which keeps onError useful for logging while preserving
normal retry behavior for cron routes and workers.
Testing
Schedule tests can run handlers directly through the inline runner:
const runner = createInlineScheduleRunner<AppContext>({
ctx,
now: () => new Date("2026-01-01T09:00:00.000Z"),
});
await runner.run(DailyCheckSchedule, {
scheduledAt: "2026-01-01T09:00:00.000Z",
});Use in-memory or fake ports in ctx and assert on the resulting repository,
job, mail, log, or devtools effects.