ElementsClock

Schedules

Cron helpers, every-intervals, app timezones, per-tenant rows, leader locks, catch-up one, and Console overrides.

Named schedules trigger Flows on a calendar cron or a fixed duration. Declare with clock / helpers, set the zone once on the app, then bind with on(clockDecl, flow).

For developers running daily digests, health pings, and per-tenant billing loops on okengine.

The one rule

Calendar work: prefer oke({ clock: { timezone } }) (or defineConfig) and omit timezone on each schedule. Intervals: durations are ms|s|m|h|d only — no weeks. Both shapes share leader locks, catch-up "one", and the Store.

Two kinds, one species

on(trigger, flow)
  • clock.every("sweep", "1h")fixed interval
    Console
    Listed, with health
    Use
    Interval — purge, sweep, ping
  • clock.daily("report", { at: "06:00" })wall clock
    Console
    Listed, with health
    Use
    Named — zone from oke({ clock }) · pause / edit when overridable
all become

one Flow

Same on(trigger, flow). The do never knows whether an interval or a wall-clock helper woke it.

Smallest Example

Declare a schedule and bind a Flow

src/clocks/reports.ts
import { clock } from "okengine";

export const dailyReportClock = clock.daily("reports.daily", { at: "06:00" });
src/flows/reports/daily.ts
import { on, flow } from "okengine";
import { dailyReportClock } from "@/clocks/reports";

export const runDailyReport = on(
  dailyReportClock,
  flow("reports.runDaily", {
    plane: "operator",
    do: async (_, fx) => {
      await fx.call(buildDailyReport, { at: fx.clock.now() });
    },
  }),
);

Set the zone once so each schedule stays short:

src/app.ts
import { oke } from "okengine";

export const app = oke({
  name: "notes",
  clock: { timezone: "Asia/Riyadh" },
});

Or defineConfig({ clock: { timezone: "Asia/Riyadh" } }). oke({ clock }) wins over config; a per-clock timezone wins over both.

What fires

Every morning at 06:00 in the declared zone, the leader instance runs reports.runDaily. do receives no payload — use fx.clock.now() when you need the fire instant.

With oke dev, the scheduler reconciles reports.daily into the Store and leader-elects before each tick. Under drivers.clock.test = "frozen", advance time in tests instead of waiting for dawn.

Jobs are Flows

on(clockDecl, flow) is the same species as HTTP handlers and Signal consumers. There is no separate job runner — see Consumers · Clock Jobs.

Progressive Patterns

From helpers and intervals to per-tenant rows and Console-tunable schedules:

Named helpers compile to the same ClockDecl (a five-field cron or every string):

src/clocks/reports.ts
import { clock } from "okengine";

export const daily = clock.daily("reports.daily", { at: "06:00" });
export const rollup = clock.hourly("metrics.rollup", { minute: 0 });
export const digest = clock.weekly("notes.digest", {
  on: ["mon", "fri"],
  at: "09:00",
});
export const close = clock.monthly("billing.close", { on: [1, 15], at: "00:00" });
export const ping = clock.every("health.ping", "30s");

Prefer the named helpers above for fixed schedules. The bare callable remains fully supported for the same ClockDecl shape when you need a raw string or a schedule chosen programmatically: clock("x", { cron: "0 6 * * *" }), Bun nicknames (@hourly), and clock("x", { every: "30s" }).

Consequence: Manifest / Store still see cron: "0 6 * * *" — helpers are declare-time sugar.

Helper Reference

Helper / formSignatureDefault / notesCompiles to
clockclock(name, { cron? | every?, … })Need cron and/or everysame
clock.dailyclock.daily(name, { at?, … })at default "00:00"; invalid atcron at: expected "HH:MM"five-field cron
clock.hourlyclock.hourly(name, { minute?, … })minute default 0five-field cron
clock.weeklyclock.weekly(name, { on, at?, … })on required (sunsat / 0–6); at default "00:00"five-field cron
clock.monthlyclock.monthly(name, { on, at?, … })on required (day of month); no “last day” tokenfive-field cron
clock.cronclock.cron(name, expr | fields, opts?)Empty field bag throws; invalid string → Bun.cron.parsefive-field / nickname
clock.everyclock.every(name, duration, opts?)Same duration grammar as fx.clock.ago / sleepevery string
clock.perTenantclock.perTenant(name, opts)Expands {name}#{tenantId} rowssame + perTenant

At least one of cron or every is required on the callable form. Empty options throw clock("name"): require cron or every. Invalid cron throws at declare (invalid cron …).

An empty clock.cron field bag throws cron fields: require at least one of at, minute, hour, dayOfMonth, month, dayOfWeek.

Timezone Resolution

Cron math needs an IANA zone. Resolve it once; override only when a schedule must differ.

SourceWins whenExample
Per-clock timezoneAlwaysclock.daily("x", { at: "06:00", timezone: "UTC" })
oke({ clock: { timezone } })No per-clock zoneoke({ name: "app", clock: { timezone: "Asia/Riyadh" } })
defineConfig({ clock: { timezone } })No oke({ clock })config default
Built-in defaultNothing else set"UTC"
src/app.ts
import { oke } from "okengine";
import { clock } from "okengine";

export const app = oke({
  name: "notes",
  clock: { timezone: "Asia/Riyadh" },
});

// Inherits Asia/Riyadh
export const digest = clock.daily("notes.digest", { at: "08:00" });

// Explicit UTC wins over the app default
export const utcRollup = clock.hourly("metrics.utc", {
  minute: 0,
  timezone: "UTC",
});

{ tz: "…" } is not an option — it is ignored. Intervals (every) are duration-based and do not consult the zone for tick spacing; the zone still lands on the Store row.

Binding & Input

Bind with on(clockDecl, flow("name", { do })). One Flow can write clock.every inside on()Clock · Inline or named export. do has no payload; read time through fx.clock:

src/clocks/metrics.ts
import { clock } from "okengine";

export const cleanupClock = clock.every("metrics.cleanup", "1h");
src/flows/metrics/cleanup.ts
import { on, flow } from "okengine";
import { lt } from "drizzle-orm";
import { cleanupClock } from "@/clocks/metrics";
import { db, metricLogs } from "@/schema";

export const cleanup = on(
  cleanupClock,
  flow("metrics.cleanup", {
    plane: "operator",
    do: async (_, fx) => {
      await fx
        .store(db)
        .delete(metricLogs)
        .where(lt(metricLogs.timestamp, fx.clock.ago("7d")));
      return { at: new Date(fx.clock.now()).toISOString() };
    },
  }),
);
ConcernRule
Triggeron(clockDecl, flow) — same Flow species as HTTP / Signal
Inputnone — use _
Timefx.clock.now / ago / fromNow / duration only
PlanePrefer plane: "operator" for background work
ImportThe clock(…) module must load at boot so reconcile sees the decl

Options Reference

Shared options on clock(name, options) and helpers:

Option / helperTypeDefaultMeaning
cronstringFive-field cron or Bun nickname
everystringInterval ("30s", "1h", "7d", …)
timezoneIANA stringapp default / "UTC"Zone for cron math
overridablebooleanfalseConsole may override schedule in the Store
perTenantbooleanfalseExpand {name}#{tenantId} rows
descriptionstringthe nameConsole / docs blurb
oke({ clock: { timezone } })app optionDefault zone when timezone omitted
defineConfig({ clock: { timezone } })configSame; overridden by oke({ clock })

Duration units (every)

UnitExampleMilliseconds
ms"200ms"200
s"30s"30_000
m"5m"300_000
h"1h"3_600_000
d"7d"604_800_000

Per-tenant Schedules

clock.perTenant expands one Store row per tenant ({name}#{tenantId}). The bare template name never ticks — runNow("invoices") returns false.

Tenant ids come from reconcile (tenantIds). New tenants get rows; deleted tenants mark those rows orphaned.

ShapeExampleFires?
Template (declared name)invoicesNo — never ticked
Expanded rowinvoices#acmeYes — leader-elected

Consequence: ten tenants → ten leases. Catch-up is "one" per row, so downtime does not replay a burst of missed hours for each tenant.

Leader Lock

Multi-instance deploys need a shared clock driver — otherwise every pod fires the same tick. Dev/prod default is postgres (test is frozen). A short lease (default 30s) means only one instance runs each fire.

Consequence: three pods calling runNow still execute the Flow once. After the lease expires, another instance may take the next tick.

DriverCross-instance lockBest for
postgresYes — shared oke_cronsDev/prod multi-instance (default)
fileYes — on one host (.oke/crons.json)Single-host multi-process
memoryNo — single process onlyLocal single process
frozenTest harness — you drive tick / advanceTests

Catch-up Policy

Catch-up — count every miss, fire once

catchUp: "one"
miss
hourly clock · five hours down

Missed slots (health)

  • +1
  • +2
  • +3
  • +4
  • +5
missedRuns
1
catchUp
"one"

Runtime fire

Five ghosts stay counted. The handler runs once when the lease is taken — not once per missed hour.

Catch-up is "one": after 5 hours down on an hourly clock, the next tick fires once. Missed slots are visible as missedRuns — they are not replayed as a burst.

Consequence: a digest that missed the night still runs once at boot, not 24 times.

Console health on each row exposes driftMs, overdue, missedRuns, and catchUp: "one". Flow retry is separate — it retries a failed fire, not missed calendar slots.

Store Lifecycle & Console

Named clocks reconcile into the Store at boot. The scheduler reads effective state from the Store, never the source declaration directly.

Declared  (Manifest / code)     ← truth for names and defaults
Override  (Store, overridable)  ← operational drift
Effective = declared + override ← what actually runs
StatusMeaningFires?
activeReconciled and enabledYes (when due + lease)
pausedOperator paused via ConsoleNo — until active again
orphanedDecl removed (or tenant gone)No — row kept, never deleted

Reconcile upserts still-declared clocks as active (preserving lease / lastRunAt / overrides when overridable). Removed declarations become orphaned.

Console actionRequirementError when missing
Run nowActive row + leaseClockResourceNotFoundErrorcron "{id}" not found
PauseExisting rowsame
Edit cron/everyoverridable: trueScheduleNotOverridableError

Consequence: without overridable: true, change the schedule in source and redeploy — Console edits are refused. Redeploying a still-overridable clock preserves overrides.

DST gap / fall-back overlap attaches a Store warning (gap / overlap); UTC never warns; the scheduler still ticks. Detection covers simple M H * * * / M H * * DOW forms; on overlap days crontab fires once (first occurrence).

PreferWhen
timezone: "UTC"No DST warnings
Non-ambiguous local hour (08:00, not 02:00)Digests that stay silent on DST days

Troubleshooting

Learn more

Next

On this page