# @aweftjs/jobs

A scheduler over an array of rows you hand in. Each row says when; you say what runs. It
writes one thing back onto a row, `last`, and decides nothing else: not what a job is, not
who may schedule one, not whether the list is stored, and nothing about retries, queues or
catching up.

## Quickstart

```ts
import { createArray, createObject } from '@aweftjs/core';
import { createScheduler } from '@aweftjs/jobs';
import type { Job } from '@aweftjs/jobs';

const jobs = createArray<Job>([
	createObject({ name: 'digest', cron: '0 7 * * *', tz: 'America/Toronto' }),
	createObject({ name: 'cleanup', every: 60 * 60 * 1000 }),
	createObject({ name: 'remind', at: Date.parse('2026-09-05T15:00:00Z') }),
]);

const scheduler = createScheduler({
	jobs,
	run: async (job, { due }) => { await work(String(job.name), due); },
	handlers: { failed: (job, error) => log.warn(job.name, error) },
});
// ...
await scheduler.stop();
```

Two things, both required, and a handler. **The array** is an `@aweftjs/core` observable
array (`createArray`), because the scheduler follows it; a plain array is refused. **`run`**
is called with the row and `{ due }`, the millisecond time the fire was scheduled for, and is
awaited; what it returns is ignored. Everything on a row besides the four fields below is
yours: a name, a module and a method, arguments, an owner.

## A row

| field | means | example |
|---|---|---|
| `at` | once, at this time in milliseconds since the epoch | `Date.parse('2026-09-05T15:00:00Z')` |
| `every` | this many milliseconds apart, the first one interval after the row is seen | `3_600_000` |
| `cron` | on a five-field expression, read on the wall clock of `tz` | `'0 7 * * MON-FRI'` |
| `tz` | an IANA zone name, required beside `cron` | `'America/Toronto'` |

Exactly one of `at`, `every` and `cron`. A cron expression is minute, hour, day of month,
month and day of week: each `*`, a value, a range `9-17`, or a list of those, each with an
optional `/step`; months and weekdays may be named; Sunday is `0` or `7`; when both day
fields are given, a day that matches either one runs, which is cron's own rule.

A row that cannot be scheduled (none or two of the three, `cron` without `tz`, a zone the
runtime does not know, an expression that does not parse, one that does not occur in the four
years ahead, `every` that is not a positive number) is reported once to `handlers.failed` with
`reason: 'invalid'` and a message naming the field, and is not scheduled. Fix the row and it
is. The report comes once the walk that found the row is over, so a handler may edit the
array; for a row already in the array when the scheduler is made, it comes after
`createScheduler` has returned, so a handler may `stop()`.

## What it writes

`last`, the record of the row's most recent run:

```ts
{ started: 1772892000000, status: 'running' }                                    // while it runs
{ started: 1772892000000, ended: 1772892000412, status: 'ok' }                    // afterwards
{ started: 1772892000000, ended: 1772892000009, status: 'failed', error: { message: 'no rows', reason: 'empty' } }
```

That is all it writes. A run in flight when the process died is what its row says it is:
`running`, with no `ended`, until the next run overwrites it. Nothing resumes it, and a
one-off in that state is done: it started once, and it runs again only if you move `at` past
that start or `delete row.last`. If you assign your own `last` onto a row while a run is in
flight, the scheduler's record of that run has nowhere to go and is dropped.

Because `last` is on the row, a list you keep in a store is its own log: the store keeps the
document's history. A list you share over a link is a live admin page with no RPC: the page
edits a row's `cron` or removes it, and the scheduler follows the edit.

## Following the array

A row pushed is scheduled. A row removed has its timer cancelled, and a run of it already in
flight finishes without writing back. A row whose `at`, `every`, `cron` or `tz` changes is
rescheduled; a change to any other field, `last` included, is not an edit. None of it needs a
restart. Two schedulers over one array both run every row: make one.

## Time

**Nothing is made up.** On start, and after every run, a periodic row's next fire is computed
from now: the first `every` interval past now on the row's own grid, or the first cron minute
after now. A process asleep for a week brings an hourly row back with one run, not 168, and
that run's `due` is the slot it was armed for, so `run` can see how late it is. A one-off
`at` in the past runs once, on start, unless its `last.started` shows it already ran;
to run it again, move `at` past that start or `delete row.last`. A job that must make up for
every slot it missed keeps its own cursor on the row and does so inside `run`, with `due` and
`last.started` to work from.

**A row still running when it comes due is skipped that time**, and the fire after is found
from now. A row never runs concurrently with itself.

**A cron reads the wall clock of its zone.** On the day the clocks go forward a wall time that
does not exist (02:30 in Toronto) is not run that day; on the day they go back a wall time that
occurs twice runs twice. `tz` has no default, because a wrong zone is silent for weeks and a
missing field is loud once.

A wait longer than a timer can hold is taken in pieces. February 29 is found in whatever year
it is asked for.

## When something throws

A `run` that throws lands `failed` on the row, with the error's message and its `reason` when
it had one, reaches `handlers.failed(job, error)` with what was thrown, and does not touch the
schedule: the next fire is found as usual. Without a handler the error is raised where nothing
catches it, and the process says so; that is on purpose, the same as `follow` and `server`.

## Stopping

`stop()` cancels every timer, stops following the array, and resolves once the runs in flight
have settled. A `run` that never returns keeps it waiting; a timeout on a run is yours.

## Keeping the list, and running a module

The array can be the root of a document in a store, so the schedule and every `last` survive
the process:

```ts
const handle = await store.open('jobs', 'array');
const scheduler = createScheduler({ jobs: handle.root as Job[], run });
```

What runs is yours. The form that loads a module and calls a method on it is one line, and
because a sandbox has `load` too, the same line runs a module a stranger wrote in a room:

```ts
run: async (job) => {
	const name = String(job.module);
	const instance = (await loader.load([name]))[name] as Record<string, (args: unknown) => unknown>;
	return instance[String(job.method)]!(job.args);
},
```

## Testing a schedule

`clock` is `{ now, setTimeout, clearTimeout }`. Hand in your own and a week of schedule runs in
a millisecond, with every fire at a time you chose; nothing else changes. The real clock is
the only one that ships.

## What this package never decides

What a job is or does. Who may add, edit or remove a row. Whether the array is stored, or
shared. Catch-up, retries, queues, progress, notifications, and which process runs a row.

Every error this package raises carries a `reason`: `missing` (`createScheduler` without `jobs` or
`run`), `not-a-list` (`jobs` is not an observable array), `invalid` (a row that cannot be
scheduled). The design notes are in
[`docs/design/`](https://github.com/torrinworx/aweft/tree/9a5bb24770dc7555257f8a307d79917efc58df70/docs/design) 075 and 076.

## API

Every export of `@aweftjs/jobs`, its signature as the compiler resolves it, and its block comment.

### `@aweftjs/jobs`

#### `Clock`

```ts
interface Clock { now(): number; setTimeout(fn: () => void, ms: number): unknown; clearTimeout(handle: unknown): void; }
```

Where time comes from. The real clock is the only one that ships; a test hands in its own.

#### `Job`

```ts
interface Job { at?: number; every?: number; cron?: string; tz?: string; last?: Last; [key: string]: unknown; }
```

A row in the array: when to run, and whatever else the application keeps beside it.

Exactly one of `at`, `every` and `cron`, and `tz` beside `cron`. The scheduler reads those
four, writes `last`, and ignores every other field, which is where the application keeps
what the job is.

#### `JobsError`

```ts
interface JobsError extends Error { readonly reason: string; }
```

An error this package raises, with a reason a caller can branch on.

Reasons: `missing` (`createScheduler` without `jobs` or `run`), `not-a-list` (`jobs` is not
an observable array), `invalid` (a row that cannot be scheduled; the message names the
field). A run that throws reaches `handlers.failed` as it was thrown, not wrapped.

#### `Last`

```ts
interface Last { started: number; ended?: number; status: 'running' | 'ok' | 'failed'; error?: { message: string; reason?: string; }; }
```

What the scheduler writes onto a row about its most recent run.

#### `Run`

```ts
type Run = (job: Job, context: { readonly due: number; }) => unknown;
```

Does the job. Awaited; what it returns is ignored; what it throws lands on the row as
`last.error` and reaches `handlers.failed`.

**Params**

- `job`: the row, as it is in the array
- `context.due`: the millisecond time this fire was scheduled for. It is earlier than now when the fire is late: a one-off added with an `at` already past, or a periodic fire the process slept through, which runs once on waking with the due it had

#### `Scheduler`

```ts
interface Scheduler { stop(): Promise<void>; }
```

No block comment on this export.

#### `SchedulerHandlers`

```ts
interface SchedulerHandlers { readonly failed?: ((job: Job, error: unknown) => void) | undefined; }
```

No block comment on this export.

#### `SchedulerOptions`

```ts
interface SchedulerOptions { readonly jobs: readonly Job[]; readonly run: Run; readonly handlers?: SchedulerHandlers | undefined; readonly clock?: Clock | undefined; }
```

No block comment on this export.

#### `createScheduler`

```ts
createScheduler: (options: SchedulerOptions) => Scheduler
```

Make a scheduler over an array of rows.

**Params**

- `jobs`: an observable array from `@aweftjs/core` (`createArray`, or the root of a document a store opened as an array). Each row carries exactly one of `at`, `every` and `cron`, with `tz` beside `cron`; everything else on a row is yours.
- `run`: what to do when a row is due. Called with the row and `{ due }`, awaited.
- `handlers.failed`: hears a row that cannot be scheduled, and a run that threw. Without it the error is raised where nothing catches it. A row that cannot be scheduled is reported once the walk that found it is over, and, for a row already in the array when the scheduler is made, after `createScheduler` has returned.
- `clock`: `{ now, setTimeout, clearTimeout }`. The real clock unless you hand one in.

**Returns** the scheduler, which is `stop()`.

**Throws** a JobsError. `missing` without `jobs` or `run`, `not-a-list` when `jobs` is not an observable array. A row that cannot be scheduled is `invalid`, reported to `handlers.failed` rather than thrown from here. The scheduler follows the array: a row pushed is scheduled, a row removed has its timer cancelled, a row whose `at`, `every`, `cron` or `tz` changes is rescheduled. It writes `last` onto a row when a run starts and when it ends, and nothing else. A periodic row's next fire is always computed from now, so nothing missed is made up; a one-off `at` in the past runs once. Two schedulers over one array both run every row: make one.

**Example**

```ts
const jobs = createArray<Job>([createObject({ cron: '0 7 * * *', tz: 'America/Toronto', name: 'digest' })]);
const scheduler = createScheduler({ jobs, run: (job) => digest(job.name) });
// ...
await scheduler.stop();
```

## Refusals

Every error this package raises carries a `reason` to switch on and a `fix` that says what to do. These are its reasons, from `errors.txt`.

| reason | fix |
|---|---|
| `invalid` | Add tz to the row, such as America/Toronto. |
| `invalid` | Give the row one of at, every or cron, and tz beside cron. |
| `invalid` | Put the lower value first in the range. |
| `invalid` | Set at to a number, as Date.now() gives one. |
| `invalid` | Set every to a positive number of milliseconds. |
| `invalid` | Use an IANA time zone name, such as America/Toronto. |
| `invalid` | Write a cron expression whose day and month can fall together. |
| `invalid` | Write a range as two values with one hyphen between them, such as 1-5. |
| `invalid` | Write a value inside the range the message names. |
| `invalid` | Write a whole number, or a name such as JAN or MON. |
| `invalid` | Write five fields: minute, hour, day, month and weekday. |
| `invalid` | Write the field as a value, a range, or a range and one step. |
| `invalid` | Write the step after the slash as 1 or more. |
| `missing` | Pass both: createScheduler({ jobs, run }). |
| `not-a-list` | Build the array with createArray from @aweftjs/core. |

## Recipes

The programs in the stack's gate that use this package, each a job someone would have.

- [`recipes/posts-to-pages`](/docs/recipes/posts-to-pages): Pages written while the application runs: a post published over a socket becomes a page, and a scheduled full write refreshes the sitemap
- [`recipes/backend`](/docs/recipes/backend): The boot pattern to copy: a twelve-line boot file and a folder of modules, one holding a document, one the rules, one a scheduler, one the gate, and one configuring a battery module
- [`recipes/jobs`](/docs/recipes/jobs): the package's own recipe
