@schedule-kit/deadline
v0.1.3
Published
A tiny, framework-agnostic scheduler for running a callback shortly before a *dynamic* deadline.
Readme
@schedule-kit/deadline
A tiny, framework-agnostic scheduler for running a callback shortly before a dynamic deadline.
DeadlineScheduler watches an expiry, arms a one-shot timer, runs your callback when the deadline is close, then re-reads the expiry and schedules the next cycle. It has no idea what the deadline represents — a lease, a reservation hold, a cached value, anything with an expiry works.
- Zero dependencies
- One timer per scheduler, no polling
- Works with sync or async callbacks
- Pause/resume/stop/reset as distinct, well-defined operations
- Clock, timer, and error handling are all injectable
- Never enters an unbounded immediate-fire loop, whatever the deadline does
Install
npm install @schedule-kit/deadlineQuick start
import { DeadlineScheduler } from "@schedule-kit/deadline";
const reservation = { expiresAt: initialExpiry };
const scheduler = new DeadlineScheduler({ leadTimeMs: 60_000 });
scheduler.watch(
() => reservation.expiresAt,
async () => {
reservation.expiresAt = await renewReservation(reservation.id);
}
);leadTimeMs before reservation.expiresAt, the callback runs. Once it resolves, the scheduler reads reservation.expiresAt again — since the callback just updated it, the next cycle is armed against the new value automatically.
Core concept
watch()
│
▼
read the deadline
│
▼
arm a timer for (deadline − leadTime)
│
▼
timer fires → run onDue()
│
▼
wait for onDue() to settle
│
▼
read the deadline again ──► arm the next cycleThe scheduler never caches the deadline across cycles — it's re-read every time, so it always schedules against the latest value. The caller owns all state; the scheduler only tracks timing.
Lifecycle
scheduler.watch(getDeadline, onDue); // start, or replace the current watch
scheduler.pause(); // temporarily stop the timer, keep the watch
scheduler.resume(); // re-read the deadline and continue
scheduler.stop(); // stop scheduling, keep the watch
scheduler.reset(); // stop and forget the watch entirelypause()/resume() and stop()/reset() are deliberately separate: pausing is a temporary hold you expect to resume, while stopping ends the current watch (reset() additionally forgets it, so a later watch() starts clean rather than looking like a resumed one).
API
new DeadlineScheduler(config?)
| Option | Type | Default | Description |
| --- | --- | --- | --- |
| leadTimeMs | number | 60_000 | How long before the deadline onDue should run. |
| clock | DeadlineClock | createSystemClock(onError) | Supplies the current time and parses deadline values. |
| setTimer | SetDeadlineTimer | setTimeout | Creates a one-shot timer. |
| clearTimer | ClearDeadlineTimer | clearTimeout | Cancels a timer. |
| onError | DeadlineErrorHandler | console.error | Called for a failed onDue or a halted busy-loop-risk cycle. |
watch(getDeadline, onDue)
Starts watching. getDeadline returns the current deadline as an ISO 8601 string, or undefined if there's nothing to watch. Calling watch() again replaces the current watch and starts a fresh cycle — any in-flight onDue from the previous watch is ignored when it settles.
pause() / resume()
pause() cancels the active timer but keeps the watch. resume() re-reads the deadline and arms a new timer. Safe to call repeatedly; calling either while not watching is a no-op.
stop() / reset()
stop() cancels the timer and stops scheduling; the watch (the last getDeadline/onDue pair) is retained. reset() does the same and additionally forgets the watch, so watch() is required to start again.
isRunning / isPaused
isRunning is true whenever a watch is active — including while paused, and while idle after a halted cycle (see below). isPaused is true only after pause().
leadTimeMs / clock
Read back the configured lead time and the DeadlineClock in use (the injected one, or the default createSystemClock() instance).
isExpired
true if there's no active watch, getDeadline() returns undefined, the deadline is unparseable, or the current time is at or past deadline − leadTimeMs. Read-only and side-effect free — checking it never arms, cancels, or reports anything, so it's safe to poll from application code that just wants to ask "is it due?" without waiting on the timer.
Public exports
export class DeadlineScheduler { ... }
export interface DeadlineSchedulerConfig { ... }
export interface DeadlineClock { now(): number; parse(value: string): number }
export type GetDeadline = () => string | undefined;
export type DeadlineCallback = () => void | Promise<void>;
export type DeadlineTimerHandle = unknown;
export type SetDeadlineTimer = (delayMs: number, onFire: () => void) => DeadlineTimerHandle;
export type ClearDeadlineTimer = (handle: DeadlineTimerHandle) => void;
export type DeadlineErrorHandler = (error: unknown) => void;
export function createSystemClock(onError: DeadlineErrorHandler): DeadlineClock;Everything a custom clock, setTimer, clearTimer, or onError implementation needs is exported — nothing else is public.
Behavior and error semantics
- No deadline. If
getDeadline()returnsundefined, the scheduler stays disarmed until the value changes andresume()/watch()is called. - Invalid deadline. If
clock.parse()returnsNaN, the scheduler disarms. Reporting is the clock's job, not the scheduler's — it has no way to describe why a value is invalid.createSystemClockreports unparseable values throughonError; a custom clock that stays silent onNaNwill leave the scheduler quietly disarmed. onDuefails. A rejection or synchronous throw is reported viaonError; the scheduler does not automatically retry. Callresume()once the underlying issue is addressed.- Busy-loop guard. After a cycle completes, the scheduler re-reads the deadline and re-arms automatically. If that re-arm computes a delay that's already due (≤ 0), it does not arm — arming would fire immediately, run
onDue, and land back here to compute another already-due delay, over and over. This holds regardless of whether the deadline value itself changed between cycles: agetDeadlinethat returns a different but still-overdue value every time is caught just as well as one returning the same value. The scheduler reports it viaonErrorand goes idle;resume()retries once you're ready. Only an explicitwatch()/resume()call is allowed to fire immediately — that's a deliberate request, not a loop. - Stale async work. If
onDueis still running whenwatch()/stop()/reset()is called, its eventual result is ignored — it won't re-arm a timer or report an error for a cycle you've already superseded.
Timer limits
The default setTimer/clearTimer use the platform's setTimeout/clearTimeout, whose maximum single delay is about 24.8 days. For longer deadlines, supply a custom setTimer that chains shorter delays internally.
License
MIT
