Scheduled tasks

Scheduled tasks 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

Create a context-bound helper once, then define schedules from it:

import { createScheduleHandlers } from "@beignet/core/schedules";
import { z } from "zod";
import type { AppContext } from "@/app-context";

const schedules = createScheduleHandlers<AppContext>();

export const SendDailyDigestSchedule = schedules.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.

Run inline

Use the inline runner for tests, local scripts, and simple cron route handlers:

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"),
  source: "vercel-cron",
});

Pass payload explicitly when a test or script needs full control:

await runner.run(SendDailyDigestSchedule, {
  payload: { date: "2026-01-01" },
});

Expose a cron route

In Next.js, keep the public trigger route small. The route should authenticate the schedule provider, create an app context, and run the schedule:

// app/api/cron/digests/daily-digest/route.ts
import { createInlineScheduleRunner } from "@beignet/core/schedules";
import type { AppContext } from "@/app-context";
import { SendDailyDigestSchedule } from "@/features/digests/schedules";
import { env } from "@/lib/env";
import { server } from "@/server";

export async function GET(request: Request) {
  const cronSecret = env.CRON_SECRET;

  if (!cronSecret) {
    return Response.json(
      { error: "CRON_SECRET is not configured." },
      { status: 500 },
    );
  }

  if (request.headers.get("authorization") !== `Bearer ${cronSecret}`) {
    return Response.json({ error: "Unauthorized" }, { status: 401 });
  }

  const ctx = await server.createContextFromNext();
  const runner = createInlineScheduleRunner<AppContext>({
    ctx,
    onError({ error, schedule }) {
      ctx.ports.logger.error("Schedule failed", {
        error,
        scheduleName: schedule.name,
      });
    },
  });

  try {
    await runner.run(SendDailyDigestSchedule, {
      source: "vercel-cron",
    });
  } catch {
    return Response.json({ error: "Schedule failed" }, { status: 500 });
  }

  return Response.json({ ok: true });
}

The schedule remains the reusable unit. Vercel Cron, a platform cron route, Inngest, a worker, or a local script can all trigger the same definition. Schedule failures are rethrown after onError runs so schedule providers can retry failed cron invocations.

Generated cron routes fail closed when CRON_SECRET is missing. Set it in your deployment environment and send Authorization: Bearer <secret> from your cron provider.

Schedules and jobs

Schedules decide when work should begin. Jobs own durable work.

For production workflows, prefer schedule handlers that dispatch jobs:

async handle({ payload, ctx }) {
  await ctx.ports.jobs.dispatch(SendDigestEmailJob, payload);
}

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.

Observability

The inline runner exposes lifecycle hooks:

const runner = createInlineScheduleRunner<AppContext>({
  ctx: createBackgroundContext,
  onStart({ schedule, run }) {
    devtools.record({
      type: "schedule",
      watcher: "schedules",
      scheduleName: schedule.name,
      status: "started",
      cron: schedule.cron,
      timezone: schedule.timezone,
      details: {
        scheduledAt: run.scheduledAt?.toISOString(),
        source: run.source,
      },
    });
  },
  onSuccess({ schedule }) {
    devtools.record({
      type: "schedule",
      watcher: "schedules",
      scheduleName: schedule.name,
      status: "completed",
    });
  },
  onError({ error, schedule }) {
    devtools.record({
      type: "schedule",
      watcher: "schedules",
      scheduleName: schedule.name,
      status: "failed",
      details: { error },
    });
  },
  onHookError({ error, hook, schedule }) {
    logger.warn("Schedule lifecycle hook failed", {
      error,
      hook,
      scheduleName: schedule.name,
    });
  },
});

Providers can emit the same schedule devtools events through provider instrumentation.

Lifecycle hook failures are isolated from schedule execution. onStart, onSuccess, and onError failures are reported to onHookError when provided, but they do not prevent the schedule handler from running or turn a successful handler into a failed schedule run.

Schedule handler failures still reject runner.run(...) after onError runs. That keeps onError useful for logging and devtools 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.