# Cron Scheduler

Phase 18.λ. Declarative cron jobs driven by `Bun.cron` (Bun 1.3.12+) on the
local runtime and by Cloudflare Workers Cron Triggers when
`mandu build --target=workers` is invoked. One config, two deployment surfaces.

## Quick Start

### 1. Define a job

```ts
// jobs/cleanup.ts
import { defineCron } from "@mandujs/core/scheduler";

export const cleanupJob = defineCron({
  name: "cleanup-expired-sessions",
  schedule: "0 * * * *",    // every hour on the :00
  timezone: "UTC",
  runOn: ["bun", "workers"], // both local dev AND Cloudflare Workers
  handler: async (ctx) => {
    await db.sessions.deleteMany({
      where: { expiresAt: { lt: new Date() } },
    });
    ctx.log.info("cleaned up expired sessions");
  },
});
```

### 2. Register via `mandu.config.ts`

```ts
import { defineConfig } from "@mandujs/core";
import { cleanupJob } from "./jobs/cleanup";

export default defineConfig({
  scheduler: {
    jobs: [
      {
        name: "cleanup-expired-sessions",
        schedule: "0 * * * *",
        timezone: "UTC",
        runOn: ["bun", "workers"],
        handler: cleanupHandler,
      },
      // More jobs here...
    ],
  },
});
```

At `mandu dev` / `mandu start` the runtime scans `scheduler.jobs`, filters by
`runOn === "bun"`, and registers each surviving job with `Bun.cron`. Logs
appear as:

```
⏰ Scheduler: 3 cron job(s) registered on Bun runtime
```

### 3. Deploy to Cloudflare Workers

```bash
mandu build --target=workers
```

The CLI re-reads the same config, filters by `runOn === "workers"`, and
emits the cron schedule strings into the generated `wrangler.toml`:

```toml
# Generated by mandu build --target=workers
name = "my-mandu-app"
main = ".mandu/workers/worker.js"
compatibility_date = "2025-09-23"
compatibility_flags = ["nodejs_compat"]

[triggers]
crons = ["0 * * * *", "0 0 * * *"]

[assets]
directory = "public"
binding = "ASSETS"
```

If `wrangler.toml` already exists (hand-authored), the CLI **preserves it**
and prints the required cron strings to stderr for you to merge manually.
This avoids stomping on hand-tuned Worker bindings.

## API

### `defineCron(input)`

Accepts two shapes, both returning a `CronRegistration` handle:

1. **Array / single-entry form** (Phase 18.λ, canonical):
   ```ts
   defineCron({ name, schedule, handler, ... });
   defineCron([{ name, schedule, handler, ... }, ...]);
   ```

2. **Object form** (original API, preserved for backwards compatibility):
   ```ts
   defineCron({
     "job-name": { schedule, run, ... },
     "other-job": { schedule, run, ... },
   });
   ```

Both shapes accept the same per-job fields:

| Field        | Type              | Default             | Notes |
|--------------|-------------------|---------------------|-------|
| `name`       | `string`          | required            | Unique job identifier. |
| `schedule`   | `string`          | required            | 5-field crontab or `@alias`. Validated synchronously. |
| `handler`    | `(ctx) => ...`    | required            | Sync or async. `run` is an alias. |
| `timezone`   | `string`          | host system default | IANA zone. **UTC on Workers.** |
| `runOn`      | `CronRuntime[]`   | `["bun", "workers"]` | Filter by deployment target. |
| `skipInDev`  | `boolean`         | `false`             | Skip registration when `NODE_ENV !== "production"`. Bun-only. |
| `timeoutMs`  | `number`          | unlimited           | Soft timeout (logs warning, clears `inFlight`). Bun-only. |

### `CronContext`

Every handler receives:

```ts
interface CronContext {
  name: string;            // the job's name
  scheduledAt: Date;       // wall-clock at the tick
  log: {
    info: (...args: unknown[]) => void;
    warn: (...args: unknown[]) => void;
    error: (...args: unknown[]) => void;
  };
}
```

`log.*` writes through `console.*` with a `[scheduler:<name>]` prefix so
grep-based triage works without extra dependencies.

### `CronRegistration`

```ts
interface CronRegistration {
  start(): void;                        // idempotent
  stop(): Promise<void>;                // awaits in-flight handlers
  status(): Record<string, CronJobStatus>;
}
```

`status()` returns a per-job snapshot:

```ts
interface CronJobStatus {
  lastRunAt: number | null;     // epoch ms of last completed run
  lastDurationMs: number | null;
  inFlight: boolean;             // true while a handler is executing
  runCount: number;
  skipCount: number;             // ticks dropped due to overlap
  errorCount: number;
}
```

Hook it into an observability dashboard by adding the `schedulerCron`
middleware:

```ts
import { schedulerCron } from "@mandujs/core/middleware";
// ...
middleware: [schedulerCron({ statusHeader: "X-Mandu-Cron-Jobs" })],
```

## Runtime Differences

### Bun (local / server)

- `Bun.cron` interprets the schedule expression against the host system
  timezone by default; `timezone: "..."` overrides it per-job.
- `skipInDev: true` skips registration when `NODE_ENV !== "production"`.
- `timeoutMs` triggers a **soft** timeout — the tick is marked complete
  and the next tick may fire, but Bun cannot cancel an in-flight handler.
- Graceful shutdown via `server.stop()` awaits any in-flight handler before
  tearing down the cron timers. SIGINT / SIGTERM in standard Bun process
  handlers will pick this up automatically.

### Cloudflare Workers

- Cron triggers fire in **UTC only**. A job with `timezone: "America/New_York"`
  is still emitted to `wrangler.toml`, but the crontab expression is
  UTC-interpreted — your `0 9 * * *` schedule will fire at 09:00 UTC, not
  09:00 ET. The CLI prints a warning when it detects this mismatch.
- `skipInDev`, `timeoutMs`, and the `ctx.log.*` timeout semantics are
  **Bun-only**; the Workers environment ignores them. The CLI warns when
  `skipInDev: true` is set on a workers-eligible job.
- The `[triggers]` block is merged into a NEWLY GENERATED `wrangler.toml`
  only. If `wrangler.toml` exists, the CLI preserves it and prints the
  required crons for you to merge manually.

## Distributed Execution Caveats

`Bun.cron` and `defineCron` are **single-process** primitives. They offer:

- **No distributed lock.** Running two processes with identical config
  fires each job twice per tick.
- **No persistent queue.** Ticks missed during a process restart are lost
  — `Bun.cron` computes the next fire time from the moment it starts.
- **No exactly-once semantics.** For exactly-once / multi-instance /
  retry-with-backoff scheduling, integrate a dedicated queue (BullMQ,
  PG-boss, SQS, Cloudflare Queues) and treat cron as the trigger source,
  not the executor.

Workers Cron Triggers:

- Fire **once per Cloudflare colo (region)**. If your Worker runs in
  multiple regions, the scheduled event fires per region unless you gate
  behind a Durable Object or KV lease.
- Survive deploys. Missed ticks during a deploy rollout are not
  retroactively replayed.

## Validation & Error Handling

- `validateCronExpression(expr)` runs synchronously at `defineCron` time
  so malformed crontabs fail at module load, not on silent never-fires.
  Accepts standard 5-field expressions, step/range/list/named-month/named-day-of-week,
  and the seven named aliases (`@yearly`, `@monthly`, `@weekly`, `@daily`,
  `@hourly`, `@midnight`, `@annually`).
- `validateTimezone(tz)` uses `Intl.DateTimeFormat` to verify IANA strings.
- A handler that throws increments `errorCount` but does not halt the
  scheduler — subsequent ticks run normally. The error is logged with
  `[scheduler]` prefix; wire your own reporter for production.
- If `startServer()` fails to boot the scheduler (e.g., a bad cron
  expression slipped into a config), the HTTP surface keeps running and
  the failure is logged with `❌ [scheduler]` so you can fix-and-restart
  without a panic loop.

## See Also

- `@mandujs/core/scheduler` — `defineCron`, `validateCronExpression`,
  `validateTimezone`, `filterJobsForRuntime`.
- `@mandujs/core/middleware` — `schedulerCron` middleware wrapper.
- `docs/architect/middleware-composition.md` — request-level middleware.
- `docs/bun/phases-4-plus.md` — Phase 5 (scheduler) roadmap context.
