Guide · 08

Run code later.

Schedule a mutation to run after a deadline. The schedule is saved in the same commit as the change that made it.

This app marks an invoice ready five seconds after its last update. Save it as examples/invoices.ts and deploy it.

examples/invoices.ts
import { collection, define, mutation, query, v } from "@flower-js/sdk";
import { scheduler } from "@flower-js/sdk/scheduler";

const invoiceId = v.string({ min: 1 });
const invoices = collection("invoices", v.object({
  total: v.int({ min: 0 }), status: v.enum(["draft", "ready"]),
}));

const finalize = mutation("internal.invoice.finalize", { args: invoiceId }, (ctx, id) => {
  const invoice = ctx.get(invoices, id);
  if (invoice) ctx.set(invoices, id, { ...invoice, status: "ready" });
  return null;
});
const timers = scheduler("invoiceTimers", { finalize });

const update = mutation("internal.invoice.update", {
  args: v.object({ id: invoiceId, total: v.int({ min: 0 }) }),
}, (ctx, { id, total }) => {
  ctx.set(invoices, id, { total, status: "draft" });
  return timers.after(ctx, `finalize:${id}`, 5_000, "finalize", id);
});
const get = query("internal.invoice.get", { args: invoiceId }, (ctx, id) => ctx.get(invoices, id));

const app = define({
  uses: [timers],
  http: { "invoice.update": update, "invoice.get": get },
});
export default app;
Try the delayed update
node sdk/cli.ts deploy examples/invoices.ts
node sdk/cli.ts call invoice.update '{"id":"inv-1","total":2400}' \
  --request-id invoice-edit-1
node sdk/cli.ts watch invoice.get '"inv-1"'
  • uses: [timers] adds the timer collection and the task that runs due timers.
  • Handler names and arguments are type-checked against the handlers you passed. The handler's schema checks the arguments when you schedule (INVALID_ARGUMENT) and again when the timer runs.
  • Reusing a timer ID replaces its deadline and restarts its retries, which debounces bursts of edits. Use a new ID per callback you want.
  • timers.at takes an epoch-millisecond deadline. cancel, get, scan and retry manage timers; get and scan return each timer with its id.

How timers run

  • A timer stores a handler name and JSON arguments, not a closure. When renaming a handler, keep the old name or migrate pending timers.
  • Handlers don't need to be public. To repeat, have a handler reschedule itself under the same ID.
  • A failed handler's changes are discarded, then it is retried: three attempts, 1,000 ms first backoff, doubling up to 60,000 ms. Change these with scheduler(name, handlers, { maxAttempts, retryDelayMs, maxRetryDelayMs }).
  • After the last attempt, the timer stays failed with its error, until timers.retry(ctx, id) requeues it.
  • Timers run as a maintenance task. The leader checks every 250 ms (FLOWER_MAINTENANCE_INTERVAL_MS).

A deadline means “not before.” Load, elections or lost quorum can delay it. Its changes commit once, but it may be evaluated more than once, so send external side effects through a queue.

In tests, db.advance(5_000) moves the clock and runs due timers; see Testing. For background work that isn't tied to one deadline, write a task. examples/scheduling.ts is a fuller timer app.