Skip to main content
MigrationVal Town

Migrate scheduled jobs

Move cron and interval vals to Easel Schedules.

Cron and interval vals become jobs that Easel Schedules runs on a cron expression. Easel Schedules is in beta.

Choose the schedule

Val Town ran cron expressions in UTC, and Easel does too by default, so a cron val keeps its expression. An interval val ran a fixed time after its last run; pick the cron expression closest to it, such as 0 * * * * for hourly. On Hobby, a job runs at most every 15 minutes.

Create the job

In a project with a Hono app, schedule a route. Add a random CRON_SECRET to the project's environment variables; each run sends it as a bearer token.

src/index.ts
import { runDigest } from "../lib/jobs/digest.js";

app.post("/jobs/digest", async (c) => {
  if (c.req.header("authorization") !== `Bearer ${process.env.CRON_SECRET}`) {
    return c.text("Unauthorized", 401);
  }
  await runDigest({
    runId: c.req.header("ce-id") ?? crypto.randomUUID(),
    scheduledAt: new Date(c.req.header("ce-vssscheduledat") ?? Date.now()),
  });
  return c.body(null, 204);
});
vercel.json
{
  "framework": "hono",
  "schedules": {
    "digest": { "schedule": "37 * * * *", "timezone": "UTC", "path": "/jobs/digest" }
  }
}

In a project without a Hono app, use a private function with @vercel/schedules instead:

api/digest.ts
import { handleSchedule } from "@vercel/schedules";
import { runDigest } from "../lib/jobs/digest.js";

export const POST = handleSchedule(async (metadata) => {
  await runDigest({ runId: metadata.executionId, scheduledAt: metadata.scheduledAt });
});
vercel.json
{
  "functions": {
    "api/digest.ts": {
      "experimentalTriggers": [{ "type": "schedule/v1beta" }]
    }
  },
  "schedules": {
    "digest": { "schedule": "37 * * * *", "timezone": "UTC", "function": "api/digest" }
  }
}

Make it safe to repeat

A run can be delivered twice and isn't retried if it fails. Record each run's ID before doing the work, and keep a watermark of the newest item processed so the next run catches up on anything missed:

lib/jobs/digest.ts
lib/jobs/digest.ts
import { getDb } from "../db.js";
import { sendMail } from "../mail.js";

export async function runDigest(run: { runId: string; scheduledAt: Date }) {
  const db = getDb();
  // Claim the run. A second delivery of the same run fails here and stops.
  try {
    await db.execute({
      sql: "INSERT INTO job_runs (run_id, job, started_at) VALUES (?, 'digest', ?)",
      args: [run.runId, Date.now()],
    });
  } catch {
    return;
  }

  const state = await db.execute("SELECT watermark FROM job_state WHERE job = 'digest'");
  const since = Number(state.rows[0]?.watermark ?? 0);
  const links = await db.execute({
    sql: "SELECT title, url, created_at FROM links WHERE created_at > ? ORDER BY created_at",
    args: [since],
  });
  if (links.rows.length === 0) return;

  const newest = Number(links.rows.at(-1)!.created_at);
  // The key is the same for every attempt at this batch, so a retry
  // after a crash doesn't send a second email.
  await sendMail(
    {
      to: "casey@example.com",
      subject: `${links.rows.length} new links on your shelf`,
      text: links.rows.map((link) => `- ${link.title}: ${link.url}`).join("\n"),
    },
    `digest-${since}-${newest}`,
  );

  await db.execute({
    sql: "UPDATE job_state SET watermark = ? WHERE job = 'digest'",
    args: [newest],
  });
}
CREATE TABLE job_runs (run_id TEXT PRIMARY KEY, job TEXT NOT NULL, started_at INTEGER NOT NULL);
CREATE TABLE job_state (job TEXT PRIMARY KEY, watermark INTEGER NOT NULL);
INSERT INTO job_state (job, watermark) VALUES ('digest', 0);

Interval vals tracked progress in lastRunAt, which Val Town doesn't export. Seed the watermark from your data, such as the newest record the last run handled.

Switch to production

Preview schedules start disabled, so test with Run Schedule in the project's Schedules tab. To switch:

  1. Disable the cron val, and wait for any running job to finish.
  2. Copy the final watermark and data to the production database.
  3. Deploy to production. The schedule starts enabled.

Back to the feature list.