ElementsClock

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
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 named clock

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

export const digestClock = clock.every("notes.digest", "1d");

Bind a Flow

src/flows/notes/digest.ts
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

StyleWhen
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:

src/flows/health/ping.ts
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:

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 oke({ clock: { timezone: "Asia/Riyadh" } }) once (or the same in defineConfig). Omit per-clock timezone unless one schedule must differ — see Schedules.

Capability Reference

SurfaceSignaturePurposedo input
Helpersclock.daily · hourly · weekly · monthly · cronCalendar presets + field bagsnone (_)
Intervalclock.every(name, duration, opts?)Fixed duration loopnone (_)
Per-tenantclock.perTenant(name, opts)One Store row per tenantnone (_)
Bare callableclock(name, { cron? | every?, … })Same decl; lower-level formnone (_)
Now / offsetsfx.clock.now · ago · fromNow · durationInjectable time math
Durable sleepfx.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:

oke.config.ts
import { defineConfig } from "okengine/config";

export default defineConfig({
  drivers: {
    // omit clock to use defaults — pin only overrides
    // clock: { dev: "postgres", test: "frozen", prod: "postgres" },
  },
});
DriverRuns asBest for
postgresShared oke_crons + leasesDev/prod multi-instance (default)
file.oke/crons.jsonSingle-host multi-process
memoryIn-process mapSingle process; no cross-instance lock
frozenTime-travel harnessTests — 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

Troubleshooting

Learn more

  • Schedules — cron, every, per-tenant, DST, catch-up, overrides
  • Durable Sleepfx.clock.sleep(label, duration)
  • Consumers · Clock Jobs — bind with on(clockDecl, flow)
  • Workflowsdurable: true + fx.step around sleeps
  • fx — full fx.clock table
  • ErrorsScheduleNotOverridableError · ClockResourceNotFoundError · OKE1070 · OKE1072

Next

On this page