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
Create the app-bound defineSchedule builder once in lib/schedules.ts with
createSchedules<AppContext>() (see
app-bound builders), then define schedules in
feature files:
import { z } from "zod";
import { defineSchedule } from "@/lib/schedules";
export const SendDailyDigestSchedule = defineSchedule(
"digests.send-daily",
{
cron: "0 9 * * *",
timezone: "America/Chicago",
payload: z.object({
date: z.string(),
}),
createPayload({ run }) {
const date = run.scheduledAt ?? run.triggeredAt;
return {
date: date.toISOString().slice(0, 10),
};
},
async handle({ payload, ctx }) {
await ctx.ports.jobs.dispatch(SendDigestEmailJob, {
date: payload.date,
});
},
},
);The payload schema is validated before the handler runs. createPayload(...)
lets a provider or cron route trigger the schedule with timing metadata while
the schedule owns the app-specific payload.
Register app schedules in server/schedules.ts when they should be available
to operational runners. beignet make schedule creates this registry when it
does not exist yet and appends new feature schedule registries to it:
import { createServiceActor } from "@beignet/core/ports";
import type { AppContext } from "@/app-context";
import { digestSchedules } from "@/features/digests/schedules";
import { server } from "@/server";
export const schedules = [...digestSchedules] as const;
export async function createScheduleContext(): Promise<AppContext> {
return server.createServiceContext({
actor: createServiceActor("beignet-schedule"),
});
}
export async function stopScheduleContext(): Promise<void> {
await server.stop();
}server.createServiceContext(...) builds a
service context for background work. beignet doctor reports feature schedules that never made it into this registry, and
beignet doctor --fix registers them.
Run inline
Use the inline runner for tests, local scripts, and app-owned trigger adapters:
import { createInlineScheduleRunner } from "@beignet/core/schedules";
const runner = createInlineScheduleRunner<AppContext>({
ctx: createBackgroundContext,
onError({ error, schedule }) {
logger.error("Schedule failed", {
error,
scheduleName: schedule.name,
});
},
});
await runner.run(SendDailyDigestSchedule, {
scheduledAt: new Date("2026-01-01T09:00:00.000Z"),
attempt: 1,
source: "vercel-cron",
});Pass payload explicitly when a test or script needs full control instead of
deriving it through createPayload(...).
Run registered schedules from the Beignet CLI for local, CI, or worker-hosted entrypoints:
beignet schedule run digests.send-daily --scheduled-at 2026-01-01T09:00:00.000ZThe CLI loads server/schedules.ts, creates the app context, runs one
schedule, records schedule events into the resolved instrumentation port when
one exists, and then calls stopScheduleContext(...). Omit --payload to use
createPayload(...); pass --payload '{"date":"2026-01-01"}' when the caller
supplies the schedule payload.
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:
// app/api/cron/digests/daily-digest/route.ts
import { createScheduleRoute } from "@beignet/next";
import { env } from "@/lib/env";
import { server } from "@/server";
import { schedules } from "@/server/schedules";
export const runtime = "nodejs";
export const { GET, POST } = createScheduleRoute({
server,
schedules,
schedule: "digests.send-daily",
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 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 Deployment 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.
For production workflows, prefer schedule handlers that dispatch jobs. That keeps scheduled triggers small, makes retries a job-provider concern, and lets the same job run from HTTP, events, scripts, or manual admin actions.
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.
Observability
Pass an instrumentation sink 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:
import { resolveProviderInstrumentationPort } from "@beignet/core/providers";
const runner = createInlineScheduleRunner<AppContext>({
ctx,
instrumentation: resolveProviderInstrumentationPort(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 the instrumentation port resolved from ctx.ports, and
providers can emit the same schedule devtools events through provider
instrumentation.
For custom logging or metrics, the runner also exposes onStart, onSuccess,
and onError lifecycle hooks. 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. 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(SendDailyDigestSchedule, {
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.