Overview
Time as an explicit element — named cron schedules, recurring intervals, and durable sleep through fx.clock.
Clock is how your backend knows what time it is and when to run again. A monthly invoice job, a 30s health ping, and a three-day trial reminder share one vocabulary: declare a named schedule, bind it with on(clockDecl, flow("name", { do })), and read time only through fx.clock.
For developers scheduling work on okengine — one handle shape; drivers swap by environment.
The one rule
All time access and pauses go through fx.clock. Date.now(), new Date(), and setTimeout
bypass the injectable clock and break durable sleep / time-travel tests.
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
one Flow
Same on(trigger, flow). The do never knows whether an interval or a wall-clock helper woke it.
Smallest Example
Declare a named clock
import { clock } from "okengine";
export const digestClock = clock.every("notes.digest", "1d");Bind a Flow
import { on, flow } from "okengine";
import { digestClock } from "@/clocks/digest";
import { db, notes } from "@/schema";
import { isNull } from "drizzle-orm";
export const digest = on(
digestClock,
flow("notes.digest", {
plane: "operator",
do: async (_, fx) => {
const rows = await fx.store(db).select().from(notes).where(isNull(notes.archivedAt));
return { active: rows.length, at: new Date(fx.clock.now()).toISOString() };
},
}),
);See it tick
With oke dev, the scheduler reconciles notes.digest into the Store and leader-elects
before each fire. do receives no payload — read time with fx.clock.now() (epoch-ms).
Map to ISO on the wire with new Date(fx.clock.now()).toISOString().
Under drivers.clock.test = "frozen", advance time in tests instead of waiting a day.
Jobs are Flows
Write the schedule inline or export it — Inline or named export.
Every consumer still uses flow("name", { do }). See
Consumers · Clock Jobs.
Inline or named export
| Style | When |
|---|---|
on(clock.every("name", "1h"), flow("ops.ping", { do })) | One Flow owns this schedule |
export const x = clock.every("name", "1h") | Several Flows share this schedule |
Why Clock can be inline
fx.clock is now / sleep / offsets — not an emit. Signal stays an exported const so producers can
fx.emit(handle, payload). Export a Clock const only to share one schedule across named Flows.
Both styles pass a real flow("name", { do }). Nameless flow({ do }) fails
OKE1072 — the trigger's name is never the Flow's name.
One file — declare and bind together. The scheduler fires it; no other Flow binds this cadence:
import { on, flow, clock } from "okengine";
export const pingExternal = on(
clock.every("health.pingExternal", "30s"),
flow("health.pingExternal", {
plane: "operator",
do: async (_, fx) => {
await fx.call(pingUpstream);
},
}),
);Progressive Patterns
Same clock + fx.clock from a calendar cron to an interval, a durable pause, and typed offsets:
Five-field cron plus helpers. Prefer an app-wide zone so schedules stay short:
import { clock } from "okengine";
export const dailyReportClock = clock.daily("reports.daily", {
at: "06:00",
});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 oke({ clock: { timezone: "Asia/Riyadh" } }) once (or the same in
defineConfig). Omit per-clock timezone unless one schedule must differ —
see Schedules.
Capability Reference
| Surface | Signature | Purpose | do input |
|---|---|---|---|
| Helpers | clock.daily · hourly · weekly · monthly · cron | Calendar presets + field bags | none (_) |
| Interval | clock.every(name, duration, opts?) | Fixed duration loop | none (_) |
| Per-tenant | clock.perTenant(name, opts) | One Store row per tenant | none (_) |
| Bare callable | clock(name, { cron? | every?, … }) | Same decl; lower-level form | none (_) |
| Now / offsets | fx.clock.now · ago · fromNow · duration | Injectable time math | — |
| Durable sleep | fx.clock.sleep(label, duration) | Park a durable Flow until wake | — |
At least one of cron or every is required on every declaration.
Per-environment drivers
Standard starters inherit DRIVER_DEFAULTS. Pin only when you diverge:
import { defineConfig } from "okengine/config";
export default defineConfig({
drivers: {
// omit clock to use defaults — pin only overrides
// clock: { dev: "postgres", test: "frozen", prod: "postgres" },
},
});| Driver | Runs as | Best for |
|---|---|---|
postgres | Shared oke_crons + leases | Dev/prod multi-instance (default) |
file | .oke/crons.json | Single-host multi-process |
memory | In-process map | Single process; no cross-instance lock |
frozen | Time-travel harness | Tests — advance instead of waiting |
Consequence: three pods with postgres still run each tick once (30s leader lease).
memory does not coordinate across processes.
The Capabilities of Clock
Schedules
Cron helpers, every-intervals, per-tenant rows, leader locks, and catch-up one.
Durable Sleep
Journaled pauses that resume across deploys — label + duration, durable Flows only.
Troubleshooting
clock(name) needs {cron} and/or {every}. Empty options throw at declaration, before on().
The field is timezone (IANA). Prefer oke({ clock: { timezone } }) /
defineConfig({ clock: { timezone } }) so schedules can omit it.
{ tz: "Asia/Riyadh" } is ignored.
Catch-up is "one" — one fire per overdue clock, then nextRunAt advances. Missed slots show as
missedRuns. A burst usually means several clocks or clock.perTenant rows, not a replay of
hourly slots.
Leader election needs a shared Store (drivers.clock postgres, or file on one host). memory
does not coordinate across processes. Check that instances share DATABASE_URL.
Missing durable: true, or fx.clock.sleep("8h") with one argument — "8h" is the label,
duration is missing. Use fx.clock.sleep("label", "8h").
Cause: clock "{name}" is not overridable. Add overridable: true and redeploy, or edit the
declaration in source.
Cause: Flow "{flow}" is defined twice. Two flow("…") strings collide. Give at least one a
distinct name. Clock allows several consumers on one schedule — each needs its own name.
Cause: A clock flow on "{trigger}" has no name.
Fix: pass an explicit name — on(clockDecl, flow("notes.digest", { do })).
Inline clock.every still needs flow("…") — Inline or named export.
Learn more
- Schedules — cron, every, per-tenant, DST, catch-up, overrides
- Durable Sleep —
fx.clock.sleep(label, duration) - Consumers · Clock Jobs — bind with
on(clockDecl, flow) - Workflows —
durable: true+fx.steparound sleeps - fx — full
fx.clocktable - Errors —
ScheduleNotOverridableError·ClockResourceNotFoundError· OKE1070 · OKE1072