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/core

Define a schedule

Start with the Quickstart app. Generate a schedule and its central registry:

bun beignet make schedule todos.daily-check

This 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.000Z

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