@pumped-fn/lite-extension-scheduler
v0.2.0
Published
Recurring schedule extension for @pumped-fn/lite: a keepAlive schedule() atom against a pluggable SchedulerBackend
Maintainers
Readme
@pumped-fn/lite-extension-scheduler
A recurring-schedule extension for @pumped-fn/lite: schedule() returns a keepAlive atom whose
resolved value is a registration ({ trigger, next, stop }) against a pluggable SchedulerBackend.
This package does not import or ship a durable backend — only an in-process, non-persistent one for
dev/test.
Contract
interface Scheduler.Backend {
register(
spec: {
name: string
cadence: { cron: string } | { every: string }
overlap: "skip" | "queue"
catchUp: "skip" | "last" | "all"
onError?: (error: unknown, run: { key: string; scheduledAt: Date }) => void
},
tick: (run: { key: string; scheduledAt: Date }) => Promise<void>
): { trigger(dedupKey?: string): Promise<void>; next(): Date | undefined; stop(): Promise<void> }
}scheduler.backend— a required tag (tags.required(scheduler.backend)) thatschedule()reads off the scope. Wire your ownSchedulerBackend(durable, distributed, whatever) viacreateScope({ tags: [scheduler.backend(myBackend)] }).scheduler.schedule({ name?, cadence, overlap?, catchUp?, flow, input, onError?, tags? })— returns akeepAliveatom. Resolving it callsbackend.register(...)once and returns the registration. On scope disposal,ctx.cleanupcallsregistration.stop().namedefaults to the flow's ownname; if neither is set,schedule()throws immediately. Each tick creates a freshctx.scope.createContext(...)with the scope's ambient tags plustags()(if given — notapp.context(), which is request-scoped and never runs for background ticks), execsflowwithinput(), and closes the context (ok: trueon success,ok: falsewith the error otherwise) — the error is rethrown afterclose()so the immediate tick promise (e.g. whattrigger()awaits) rejects, while the backend's own overlap chain does not die from it (seeonErrorbelow).onError— called once per failed tick with the raw error and its{ key, scheduledAt }. The default (whenonErroris omitted) is to swallow the error after the fact: the tick's context already closed withok: falseand the error attached, so observability sees it there; nothing is logged or thrown from the backend's internal bookkeeping. A tick you triggered explicitly viaregistration.trigger()still rejects that call's promise regardless ofonError— only the backend's own queue/timer plumbing swallows-by-default.overlap: "queue"chains ticks through a.then()chain that always settles fulfilled internally (a failed tick does not poison the chain) — the next queued tick still runs. Every backend (inProcess,nats) is expected to uphold this.scheduler.inProcess()— croner-based backend, dev/test grade only, not durable: no persistence, no distributed coordination, ticks are lost across process restarts.cadence: { cron }— a standard cron expression, parsed bycroner.cadence: { every }— a plain string of milliseconds (e.g."5000"for every 5s). There is no duration-string parser here; if you need"5m"-style parsing, convert it yourself before passingevery.overlap: "skip"— if the previous tick's promise hasn't resolved, the next tick is dropped entirely (not queued).overlap: "queue"— the next tick'stick()call is chained after the previous tick's promise settles (a simple.then()chain, no queue library, no backpressure limit).catchUp—inProcesshas no persistence to catch up from, so only"skip"is accepted;"last"/"all"throw immediately fromregister()pointing at "a durable backend".trigger(dedupKey?)—dedupKeyis accepted for contract parity with distributed backends but ignored: a singleinProcessinstance is trivially deduped already (there is only ever one process running the tick).stop()stops the underlyingcroner/interval job first (no new ticks start), then awaits any in-flight or queued tick before resolving.
Backends
@pumped-fn/lite-extension-scheduler-nats— a distributed backend built on NATS JetStream KV: exactly-once ticks across instances via KVcreatewhile the lock holder completes withinlockTtlMs(degrading to at-least-once across a lease expiry — see that package's README),catchUpderived from alast:key, and a run history/audit trail in the KV itself.
Example
import { createScope } from "@pumped-fn/lite"
import { scheduler } from "@pumped-fn/lite-extension-scheduler"
import { sweepExpired } from "./flows"
const nightlySweep = scheduler.schedule({
name: "nightly-sweep",
cadence: { cron: "0 2 * * *" },
flow: sweepExpired,
input: () => undefined,
})
const scope = createScope({ tags: [scheduler.backend(scheduler.inProcess())] })
const registration = await scope.resolve(nightlySweep)
registration.next() // next scheduled Date, or undefined
await registration.trigger() // run one tick immediately, awaiting it
await scope.dispose() // stops the registration