@cronvello/sdk 0.1.4 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/dev.d.ts ADDED
@@ -0,0 +1,318 @@
1
+ /**
2
+ * The **local engine** — Cronvello running entirely on your machine, with no account, no cloud, and
3
+ * no network. It is the piece that turns `@cronvello/sdk` from a thin client into a real cron tool:
4
+ * give it your jobs and it computes each one's next fire time, runs the handler when it's due, and
5
+ * enforces the production policies (overlap protection, per-run timeout, retry with backoff) locally
6
+ * — exactly the guarantees the cloud gives you, on your laptop.
7
+ *
8
+ * Everything is driven through an injectable {@link EngineClock}, so the whole scheduler is
9
+ * deterministic under test (a virtual clock can fast-forward days in microseconds) and uses the real
10
+ * `setTimeout`/`Date.now` in production. The engine never touches the network.
11
+ */
12
+ /** The outcome of a single execution (which may have spanned several attempts). */
13
+ type RunStatus = "success" | "error" | "timed_out" | "skipped";
14
+ /** One job the engine should schedule. Derived from a `defineCronvello` registry, but standalone. */
15
+ interface EngineJob {
16
+ /** Stable registry key. */
17
+ key: string;
18
+ /** Cron expression (5- or 6-field, macros). `@reboot` fires once when the engine starts. */
19
+ schedule: string;
20
+ /** IANA timezone the schedule is read in. */
21
+ timeZone: string;
22
+ /** Abort a run that exceeds this many ms (the handler's `ctx.signal` aborts). 0/undefined = no limit. */
23
+ timeoutMs?: number;
24
+ /** Retries after the first failed attempt (default 0). */
25
+ maxRetries?: number;
26
+ /** Allow a fire while a previous run of the same job is still in flight (default false). */
27
+ allowConcurrentRuns?: boolean;
28
+ /** Optional human description (shown in the dev table). */
29
+ description?: string;
30
+ }
31
+ /** A completed execution recorded in the in-memory history ring buffer. */
32
+ interface RunRecord {
33
+ key: string;
34
+ source: "local";
35
+ /** Epoch ms when the execution began. */
36
+ startedAt: number;
37
+ /** Epoch ms when it settled. */
38
+ finishedAt: number;
39
+ durationMs: number;
40
+ status: RunStatus;
41
+ /** How many attempts were made (1 + retries that ran). */
42
+ attempts: number;
43
+ /** Error message for a failed/timed-out run. */
44
+ error?: string;
45
+ /** The handler's return value for a successful run. */
46
+ result?: unknown;
47
+ }
48
+ /** Lifecycle events the engine emits — the CLI renders them as a live feed. */
49
+ type EngineEvent = {
50
+ type: "engine-start";
51
+ jobs: number;
52
+ at: number;
53
+ } | {
54
+ type: "engine-stop";
55
+ at: number;
56
+ } | {
57
+ type: "scheduled";
58
+ key: string;
59
+ at: number;
60
+ } | {
61
+ type: "fire";
62
+ key: string;
63
+ attempt: number;
64
+ at: number;
65
+ } | {
66
+ type: "success";
67
+ key: string;
68
+ durationMs: number;
69
+ attempts: number;
70
+ result: unknown;
71
+ at: number;
72
+ } | {
73
+ type: "error";
74
+ key: string;
75
+ durationMs: number;
76
+ attempt: number;
77
+ willRetry: boolean;
78
+ error: string;
79
+ at: number;
80
+ } | {
81
+ type: "timeout";
82
+ key: string;
83
+ durationMs: number;
84
+ attempt: number;
85
+ willRetry: boolean;
86
+ at: number;
87
+ } | {
88
+ type: "retry";
89
+ key: string;
90
+ attempt: number;
91
+ delayMs: number;
92
+ at: number;
93
+ } | {
94
+ type: "skipped";
95
+ key: string;
96
+ reason: "overlap";
97
+ at: number;
98
+ };
99
+ /** A snapshot of one job's scheduling state, for the dev table. */
100
+ interface JobSnapshot {
101
+ key: string;
102
+ schedule: string;
103
+ timeZone: string;
104
+ description?: string;
105
+ /** Next fire time, or null for `@reboot` / unschedulable jobs. */
106
+ nextFire: Date | null;
107
+ /** True while a run of this job is in flight. */
108
+ running: boolean;
109
+ }
110
+ /**
111
+ * The timer/clock seam. Production wires this to the global `setTimeout`/`Date.now`; tests inject a
112
+ * virtual clock so scheduling is fully deterministic.
113
+ */
114
+ interface EngineClock {
115
+ now(): number;
116
+ setTimeout(fn: () => void, ms: number): unknown;
117
+ clearTimeout(handle: unknown): void;
118
+ }
119
+ /** Runs a job's handler in-process (with lifecycle hooks). `signal` aborts on timeout. */
120
+ type EngineRunner = (key: string, signal: AbortSignal) => Promise<unknown>;
121
+ interface LocalEngineOptions {
122
+ /** Timer source. Defaults to the real `setTimeout`/`Date.now`. */
123
+ clock?: EngineClock;
124
+ /** Receives every lifecycle event (for live rendering / metrics). */
125
+ onEvent?: (event: EngineEvent) => void;
126
+ /** Cap on the in-memory run history (default 100). Oldest records drop first. */
127
+ historyLimit?: number;
128
+ /** Backoff before retry attempt N (1-based: N=1 is the delay before the 2nd attempt). */
129
+ backoff?: (attempt: number) => number;
130
+ /** Install SIGINT/SIGTERM handlers that stop the engine cleanly (default false). */
131
+ installSignalHandlers?: boolean;
132
+ }
133
+ /**
134
+ * Construct a local engine. Call {@link LocalEngine.start} to begin the loop and
135
+ * {@link LocalEngine.stop} for a clean shutdown.
136
+ */
137
+ declare function createLocalEngine(jobs: EngineJob[], runner: EngineRunner, options?: LocalEngineOptions): LocalEngine;
138
+ declare class LocalEngine {
139
+ private readonly clock;
140
+ /** Every event listener. The constructor's `onEvent` is registered as one of them. */
141
+ private readonly listeners;
142
+ /** Run after the engine has drained, e.g. to close a dashboard server. */
143
+ private readonly closeHooks;
144
+ private readonly historyLimit;
145
+ private readonly backoff;
146
+ private readonly runner;
147
+ private readonly states;
148
+ private readonly history;
149
+ private readonly inFlight;
150
+ private started;
151
+ private stopped;
152
+ private signalCleanup;
153
+ constructor(jobs: EngineJob[], runner: EngineRunner, options?: LocalEngineOptions);
154
+ /**
155
+ * Subscribe to every lifecycle event (in addition to the constructor's `onEvent`). Returns an
156
+ * unsubscribe function. Used by the local dashboard to fan events out to many SSE clients without
157
+ * disturbing the scheduler. A listener that throws is isolated — it can't break the loop.
158
+ */
159
+ subscribe(listener: (event: EngineEvent) => void): () => void;
160
+ /**
161
+ * Register a hook to run once, after {@link stop} has drained in-flight runs — e.g. to close a
162
+ * dashboard server so `engine.stop()` tears everything down together. Hooks are awaited.
163
+ */
164
+ onStop(hook: () => void | Promise<void>): this;
165
+ /** Deliver an event to every listener, isolating each so one bad listener can't stall the loop. */
166
+ private emit;
167
+ /** Begin scheduling. Idempotent — a second call is a no-op. */
168
+ start(): this;
169
+ /**
170
+ * Stop scheduling and wait for in-flight runs to settle. After this resolves no further handlers
171
+ * will start. Safe to call from a signal handler.
172
+ */
173
+ stop(): Promise<void>;
174
+ /** The run history, newest first (a copy — safe to keep). */
175
+ runs(): RunRecord[];
176
+ /** History entries for one job, newest first. */
177
+ runsFor(key: string): RunRecord[];
178
+ /** Current scheduling state of every job, for the dev table. */
179
+ snapshot(): JobSnapshot[];
180
+ /** The jobs this engine manages (read-only view). */
181
+ jobs(): readonly EngineJob[];
182
+ /** Number of runs currently in flight. */
183
+ get activeRuns(): number;
184
+ private scheduleNext;
185
+ private onDue;
186
+ /**
187
+ * Run a job once, right now, by key — the local equivalent of "run now" in the cloud. Goes through
188
+ * the exact same execution path as a scheduled fire (overlap protection, timeout, retry/backoff,
189
+ * history + events), so a manual run shows up in the feed just like any other. Resolves with the
190
+ * resulting {@link RunRecord}. Throws for an unknown key or after the engine has stopped.
191
+ */
192
+ trigger(key: string): Promise<RunRecord>;
193
+ /**
194
+ * The run history as newline-delimited JSON (NDJSON), oldest run first — one record per line, the
195
+ * natural shape for piping to a file or another tool. No trailing newline.
196
+ */
197
+ toNdjson(): string;
198
+ /** Start an execution and track it so {@link stop} can await it. Resolves with the run's record. */
199
+ private launch;
200
+ private execute;
201
+ /** A single attempt: run the handler, racing it against the per-job timeout. */
202
+ private runOnce;
203
+ private sleep;
204
+ private record;
205
+ private installSignalHandlers;
206
+ }
207
+
208
+ /**
209
+ * Cron schedule arithmetic for the local engine — next-occurrence, preview, and an upcoming-window
210
+ * planner, all timezone-aware (IANA / DST) and **zero-dependency**.
211
+ *
212
+ * The cloud SDK only needs to *validate* a cron string (see `cron.ts`), because the server owns the
213
+ * scheduler. The local engine has to actually *fire* jobs, so it needs the next time an expression
214
+ * matches. This module is that calculator.
215
+ *
216
+ * How the timezone math works without a date library:
217
+ * • A cron expression matches against **wall-clock** fields (the time a person reads off a clock in
218
+ * the job's timezone), so the search is done on naive calendar fields and only converted to a
219
+ * real epoch once a full match is found.
220
+ * • To convert a wall-clock time in a zone to an absolute epoch we use the runtime's own IANA
221
+ * database via `Intl.DateTimeFormat`: format a guess into the zone, measure the offset it
222
+ * implies, and correct once. This is the standard offset-probe technique and it handles DST
223
+ * transitions (a daily job at 04:00 stays at 04:00 local; the gap to the previous fire is 23h or
224
+ * 25h across the spring/autumn switch).
225
+ *
226
+ * Supported syntax mirrors `validateCron`: 5-field crontab, an optional leading seconds field
227
+ * (6 fields), the `@macros`, month/weekday names, and `*` / ranges / lists / steps. Day-of-month and
228
+ * day-of-week combine with Vixie cron's OR semantics when both are restricted.
229
+ */
230
+ /** A cron expression compiled into the set of values each field matches. */
231
+ interface ParsedCron {
232
+ /** Allowed seconds. For a 5-field expression this is `{0}`. */
233
+ seconds: Set<number>;
234
+ minutes: Set<number>;
235
+ hours: Set<number>;
236
+ daysOfMonth: Set<number>;
237
+ months: Set<number>;
238
+ /** Allowed weekdays, 0 = Sunday … 6 = Saturday (7 is folded to 0). */
239
+ daysOfWeek: Set<number>;
240
+ /** True when the day-of-month field was anything other than `*`/`?`. */
241
+ domRestricted: boolean;
242
+ /** True when the day-of-week field was anything other than `*`/`?`. */
243
+ dowRestricted: boolean;
244
+ /** True for `@reboot`, which has no scheduled next time (the engine fires it once at start). */
245
+ reboot: boolean;
246
+ }
247
+ /** Parse a cron expression into matchable value-sets. Throws on a malformed expression. */
248
+ declare function parseCron(expr: string): ParsedCron;
249
+ interface NextOccurrenceOptions {
250
+ /** Search strictly after this instant (epoch ms or Date). Defaults to now. */
251
+ from?: Date | number;
252
+ /** IANA timezone the expression is read in. Defaults to "UTC". */
253
+ timeZone?: string;
254
+ }
255
+ /**
256
+ * The next instant the expression fires, strictly after `from`, in the given timezone — or `null`
257
+ * when nothing matches within {@link SEARCH_HORIZON_YEARS} (e.g. Feb-30). Throws for `@reboot`.
258
+ */
259
+ declare function nextOccurrence(expr: string | ParsedCron, opts?: NextOccurrenceOptions): Date | null;
260
+ interface PreviewOptions {
261
+ /** Start the preview after this instant. Defaults to now. */
262
+ from?: Date | number;
263
+ /** IANA timezone. Defaults to the runtime's local zone. */
264
+ timeZone?: string;
265
+ /** How many fire times to return (default 5, capped at 100). */
266
+ count?: number;
267
+ }
268
+ /** The next N times an expression fires — the engine of `cronvello preview`. */
269
+ declare function previewSchedule(expr: string, opts?: PreviewOptions): Date[];
270
+ interface UpcomingJob {
271
+ key: string;
272
+ schedule: string;
273
+ timeZone: string;
274
+ }
275
+ interface UpcomingFire {
276
+ key: string;
277
+ time: Date;
278
+ schedule: string;
279
+ timeZone: string;
280
+ }
281
+ interface UpcomingOptions {
282
+ /** Window start (defaults to now). */
283
+ from?: Date | number;
284
+ /** Window length in ms (defaults to 1 hour). */
285
+ withinMs?: number;
286
+ /** Cap per job so a per-second schedule can't flood the plan (default 50). */
287
+ maxPerJob?: number;
288
+ }
289
+ /**
290
+ * Every fire across a set of jobs within the next window, merged and sorted by time — what powers
291
+ * `cronvello dev --dry-run`. Jobs whose schedule can't be parsed are skipped (a dev concern surfaced
292
+ * elsewhere); `@reboot` jobs are skipped because they have no scheduled time.
293
+ */
294
+ declare function upcomingFires(jobs: UpcomingJob[], opts?: UpcomingOptions): UpcomingFire[];
295
+ /** The runtime's local IANA zone, falling back to UTC if it can't be resolved. */
296
+ declare function localTimeZone(): string;
297
+
298
+ interface DashboardOptions {
299
+ /** Port to listen on. Default `4747`. Use `0` to let the OS pick a free port (handy in tests). */
300
+ port?: number;
301
+ /** Host/interface to bind. Default `127.0.0.1` — keep it loopback; this is not a public server. */
302
+ host?: string;
303
+ }
304
+ interface DashboardHandle {
305
+ /** The local URL the dashboard is reachable at, e.g. `http://127.0.0.1:4747`. */
306
+ url: string;
307
+ /** The actual port bound (resolved even when `port: 0` was requested). */
308
+ port: number;
309
+ /** Stop the server and drop every live SSE connection. Idempotent. */
310
+ close(): Promise<void>;
311
+ }
312
+ /**
313
+ * Start the dashboard server for an engine. Resolves once it is listening; rejects with a clear
314
+ * message if the port is already in use. Bind defaults to loopback.
315
+ */
316
+ declare function startDashboard(engine: LocalEngine, options?: DashboardOptions): Promise<DashboardHandle>;
317
+
318
+ export { type DashboardHandle, type DashboardOptions, type EngineClock, type EngineEvent, type EngineJob, type EngineRunner, type JobSnapshot, LocalEngine, type LocalEngineOptions, type NextOccurrenceOptions, type ParsedCron, type PreviewOptions, type RunRecord, type RunStatus, type UpcomingFire, type UpcomingJob, type UpcomingOptions, createLocalEngine, localTimeZone, nextOccurrence, parseCron, previewSchedule, startDashboard, upcomingFires };