@lunora/scheduler 0.0.0 → 1.0.0-alpha.1

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.
@@ -0,0 +1,785 @@
1
+ /**
2
+ * Opaque reference to a Lunora function. Mirrors the `FunctionReference` shape
3
+ * emitted by `@lunora/codegen` (and consumed by `@lunora/client`). We avoid a
4
+ * direct dependency to keep this package usable from the codegen pipeline
5
+ * itself.
6
+ *
7
+ * The runtime identifier lives in `__lunoraRef` — this MUST stay in lockstep
8
+ * with the codegen emit + `@lunora/client`'s `FunctionReference`.
9
+ */
10
+ interface FunctionReference {
11
+ readonly __lunoraRef: string;
12
+ /** Marker phantom type — discriminates queries / mutations / actions. */
13
+ readonly _kind?: "query" | "mutation" | "action";
14
+ }
15
+ type ArgsOf<F extends FunctionReference> = F extends {
16
+ _args?: infer A;
17
+ } ? A : Record<string, unknown>;
18
+ /**
19
+ * Typed reference to a Lunora durable workflow — either the generated
20
+ * `workflows.&lt;name>` reference object (`_generated/api.ts`, which carries the
21
+ * `WORKFLOW_*` binding + export name) or, structurally, a `defineWorkflow()`
22
+ * result imported directly. Both are matched by the `isLunoraWorkflow` brand and
23
+ * carry the workflow's `params` in the phantom `__params`, so a `cronJobs()`
24
+ * registration infers them.
25
+ *
26
+ * Declared structurally here so `@lunora/scheduler` can let a `cronJobs()`
27
+ * builder target a workflow without depending on `@lunora/workflow` (and so the
28
+ * generated `workflows.*` object needs no `@lunora/scheduler` import — it
29
+ * matches structurally). A cron whose target is a {@link WorkflowReference}
30
+ * starts a new workflow INSTANCE on each fire (the args become its `params`)
31
+ * instead of dispatching a one-shot function. `@lunora/codegen` resolves the
32
+ * concrete `lunora/workflows.ts` export statically; the runtime brand here is
33
+ * the authoring-time guard.
34
+ */
35
+ interface WorkflowReference<Params = Record<string, unknown>> {
36
+ /** Phantom carrier for the workflow's `params` type — drives `cronJobs()` arg inference. Never read at runtime. */
37
+ readonly __params?: Params;
38
+ /** The `WORKFLOW_*` binding name (present on a generated `workflows.&lt;name>` ref). */
39
+ readonly binding?: string;
40
+ readonly isLunoraWorkflow: true;
41
+ /** The workflow's export/stable name (present on a generated ref; a `defineWorkflow({ name })` override otherwise). */
42
+ readonly name?: string;
43
+ }
44
+ /** A cron job's target: either a one-shot function dispatch or a durable workflow start. */
45
+ type CronTarget = FunctionReference | WorkflowReference;
46
+ /** The arguments a cron's target accepts: a workflow's inferred `params`, else an open record (function args aren't inferred). */
47
+ type CronTargetArgs<T extends CronTarget> = T extends WorkflowReference<infer Params> ? Params : Record<string, unknown>;
48
+ /** Narrow a {@link CronTarget} to a {@link WorkflowReference} by its runtime brand. */
49
+ declare const isWorkflowReference: (target: unknown) => target is WorkflowReference;
50
+ /**
51
+ * Per-job retry policy. Wired into the SchedulerDO's existing attempts/backoff
52
+ * machinery. When omitted, the DO falls back to its built-in defaults
53
+ * (`maxAttempts: 5`, `backoff: "exponential"`, `baseMs: 30_000`) so existing
54
+ * `runAfter`/`runAt` callers keep today's behaviour unchanged.
55
+ *
56
+ * On exhaustion (attempts > `maxAttempts`) the record is parked under the
57
+ * `dead:` dead-letter key for inspection — never silently dropped.
58
+ */
59
+ interface RetryPolicy {
60
+ /**
61
+ * Backoff growth across attempts. `"exponential"` doubles the delay each
62
+ * attempt (`baseMs * 2 ** (attempt - 1)`); `"linear"` grows it linearly
63
+ * (`baseMs * attempt`). Default `"exponential"`.
64
+ */
65
+ backoff?: "exponential" | "linear";
66
+ /** Base delay in milliseconds for the first retry. Default `30_000`. */
67
+ baseMs?: number;
68
+ /** Maximum number of dispatch attempts before dead-lettering. Default `5`. */
69
+ maxAttempts?: number;
70
+ /** Optional ceiling clamping the computed backoff delay. */
71
+ maxMs?: number;
72
+ }
73
+ interface RunOptions {
74
+ /**
75
+ * Logical workpool this job belongs to. When set, the SchedulerDO gates the
76
+ * job behind the pool's `maxConcurrency` (see {@link WorkpoolOptions}).
77
+ * Usually populated by {@link Workpool.enqueue}; callers rarely set it on a
78
+ * bare `runAfter`/`runAt`.
79
+ */
80
+ pool?: string;
81
+ /** Per-job retry policy. Falls back to the DO's built-in defaults when omitted. */
82
+ retry?: RetryPolicy;
83
+ /** Routing hint — forwarded to the Worker so the call lands on the right shard. */
84
+ shardKey?: string;
85
+ }
86
+ interface ScheduleRecord {
87
+ args: Record<string, unknown>;
88
+ /**
89
+ * Number of dispatch attempts already made. Absent (treated as 0) until the
90
+ * first failure, after which `recordRetry()` persists it on both the
91
+ * `retry:` row and the `id:` header. Surfaced here so `/list` consumers and
92
+ * the studio see the field the storage layer actually writes.
93
+ */
94
+ attempts?: number;
95
+ enqueuedAt: number;
96
+ functionPath: string;
97
+ id: string;
98
+ /**
99
+ * Scheduler/workpool instance name the job was enqueued through. Echoed in
100
+ * the dispatch payload so the runtime can call back the SAME DO instance's
101
+ * `/complete` to release a pooled slot. Absent for the default instance.
102
+ */
103
+ instanceName?: string;
104
+ /**
105
+ * Logical workpool this job belongs to (set by {@link Workpool.enqueue}).
106
+ * When present, the SchedulerDO only dispatches the job while the pool's
107
+ * in-flight count is below its `maxConcurrency`; otherwise it stays queued
108
+ * and drains as slots free. Absent for plain `runAfter`/`runAt` jobs, which
109
+ * are never concurrency-gated.
110
+ */
111
+ pool?: string;
112
+ /** Per-job retry policy (see {@link RetryPolicy}); absent means DO defaults. */
113
+ retry?: RetryPolicy;
114
+ scheduledFor: number;
115
+ shardKey?: string;
116
+ }
117
+ interface Scheduler {
118
+ cancel: (id: string) => Promise<{
119
+ cancelled: boolean;
120
+ }>;
121
+ /** Resolve a single pending job by id, or `null` when absent (derived from {@link Scheduler.list}). */
122
+ get: (id: string) => Promise<ScheduleRecord | null>;
123
+ /** All pending scheduled jobs (the DO's `/list` view). */
124
+ list: () => Promise<ScheduleRecord[]>;
125
+ runAfter: <F extends FunctionReference>(delayMs: number, function_: F, args: ArgsOf<F>, options?: RunOptions) => Promise<{
126
+ id: string;
127
+ scheduledFor: number;
128
+ }>;
129
+ runAt: <F extends FunctionReference>(date: Date | number, function_: F, args: ArgsOf<F>, options?: RunOptions) => Promise<{
130
+ id: string;
131
+ scheduledFor: number;
132
+ }>;
133
+ }
134
+ /** Subset of `DurableObjectNamespace` the package consumes. */
135
+ interface DurableObjectNamespaceLike {
136
+ get: (id: DurableObjectIdLike) => DurableObjectStubLike;
137
+ idFromName: (name: string) => DurableObjectIdLike;
138
+ }
139
+ interface DurableObjectIdLike {
140
+ toString: () => string;
141
+ }
142
+ interface DurableObjectStubLike {
143
+ fetch: (input: Request | string, init?: RequestInit) => Promise<Response>;
144
+ }
145
+ interface LunoraSchedulerOptions {
146
+ /** Optional named instance — useful for tenant isolation. Default `default`. */
147
+ instanceName?: string;
148
+ /** Binding to the `SchedulerDO` durable object namespace. */
149
+ namespace: DurableObjectNamespaceLike;
150
+ /**
151
+ * Origin where the Worker is mounted. SchedulerDO uses this base URL when
152
+ * dispatching scheduled functions back to the Worker on alarm fire.
153
+ */
154
+ originUrl: string;
155
+ }
156
+ /** Per-enqueue options for a {@link Workpool}. Extends {@link RunOptions} minus the implicit `pool` (the pool sets that). */
157
+ interface EnqueueOptions {
158
+ /** Run the job no sooner than this delay (ms) from now. Default `0` (next drain). */
159
+ delayMs?: number;
160
+ /** Per-job retry policy. Falls back to the DO's built-in defaults when omitted. */
161
+ retry?: RetryPolicy;
162
+ /** Routing hint — forwarded to the Worker so the call lands on the right shard. */
163
+ shardKey?: string;
164
+ }
165
+ /**
166
+ * Options for `createWorkpool`. Mirrors {@link LunoraSchedulerOptions}
167
+ * (same `namespace` / `originUrl` / `instanceName`) plus the bounded-concurrency
168
+ * controls. A workpool is a NAMED logical pool inside the existing SchedulerDO —
169
+ * it needs no extra Durable Object or wrangler binding beyond the SchedulerDO
170
+ * the scheduler already uses.
171
+ */
172
+ interface WorkpoolOptions extends LunoraSchedulerOptions {
173
+ /**
174
+ * Maximum number of jobs from this pool that may be in flight at once.
175
+ * Excess enqueues are persisted and drain as slots free. Must be a positive
176
+ * integer.
177
+ */
178
+ maxConcurrency: number;
179
+ /**
180
+ * Pool name — the concurrency counter is keyed by this inside the
181
+ * SchedulerDO storage (`pool:&lt;name>`). Default `default`.
182
+ */
183
+ name?: string;
184
+ }
185
+ /**
186
+ * Bounded-concurrency action queue (Lunora equivalent of `@convex-dev/workpool`).
187
+ * Built on the existing SchedulerDO: `enqueue` schedules a job tagged with this
188
+ * pool's name; the DO caps simultaneous dispatch at `maxConcurrency` and queues
189
+ * the rest durably.
190
+ */
191
+ interface Workpool {
192
+ /** Cancel a queued/in-flight pool job by id. */
193
+ cancel: (id: string) => Promise<{
194
+ cancelled: boolean;
195
+ }>;
196
+ /**
197
+ * Enqueue `function_(args)` into the pool. Resolves with the durable job id
198
+ * and the time it was scheduled for (it may not run immediately if the pool
199
+ * is at capacity).
200
+ */
201
+ enqueue: <F extends FunctionReference>(function_: F, args: ArgsOf<F>, options?: EnqueueOptions) => Promise<{
202
+ id: string;
203
+ scheduledFor: number;
204
+ }>;
205
+ /** The pool's name (the `pool:&lt;name>` storage key suffix). */
206
+ readonly name: string;
207
+ /** Inspect the pool's current state — `inFlight` slots used and the configured `maxConcurrency`. */
208
+ status: () => Promise<{
209
+ inFlight: number;
210
+ maxConcurrency: number;
211
+ queued: number;
212
+ }>;
213
+ }
214
+ /** Per-message options for {@link QueueLike.send} — the subset Lunora uses. */
215
+ interface QueueSendOptionsLike {
216
+ /** Delay delivery to the consumer by this many seconds. */
217
+ delaySeconds?: number;
218
+ }
219
+ /** One message in a `sendBatch` call. */
220
+ interface QueueSendRequestLike<Body = unknown> {
221
+ body: Body;
222
+ delaySeconds?: number;
223
+ }
224
+ /** Producer side of a Cloudflare Queue binding (the `env` queue binding). */
225
+ interface QueueLike<Body = unknown> {
226
+ send: (body: Body, options?: QueueSendOptionsLike) => Promise<void>;
227
+ sendBatch: (messages: Iterable<QueueSendRequestLike<Body>>, options?: QueueSendOptionsLike) => Promise<void>;
228
+ }
229
+ /** One delivered Cloudflare Queue message (consumer side). */
230
+ interface QueueMessageLike<Body = unknown> {
231
+ /** Marks the message delivered (won't be retried). */
232
+ ack: () => void;
233
+ /** 1-based count of delivery attempts so far. */
234
+ readonly attempts: number;
235
+ readonly body: Body;
236
+ readonly id: string;
237
+ /** Marks the message for retry on a later batch (→ dead-letter after `max_retries`). */
238
+ retry: (options?: {
239
+ delaySeconds?: number;
240
+ }) => void;
241
+ readonly timestamp: Date;
242
+ }
243
+ /** A batch of messages handed to a `queue()` consumer handler. */
244
+ interface MessageBatchLike<Body = unknown> {
245
+ /** Mark every message delivered. */
246
+ ackAll: () => void;
247
+ readonly messages: ReadonlyArray<QueueMessageLike<Body>>;
248
+ readonly queue: string;
249
+ /** Mark every message for retry. */
250
+ retryAll: (options?: {
251
+ delaySeconds?: number;
252
+ }) => void;
253
+ }
254
+ /** The wire payload Lunora puts on the queue: a function dispatch. */
255
+ interface QueueJob {
256
+ args?: Record<string, unknown>;
257
+ functionPath: string;
258
+ /** Routing hint forwarded to the Worker so the call lands on the right shard. */
259
+ shardKey?: string;
260
+ }
261
+ /** Per-enqueue options for a {@link QueueWorkpool}. */
262
+ interface QueueEnqueueOptions {
263
+ /** Delay delivery by this many seconds (Queues-native). Default: immediate. */
264
+ delaySeconds?: number;
265
+ /** Routing hint — which shard the job should run against. */
266
+ shardKey?: string;
267
+ }
268
+ /** Options for `createQueueWorkpool`. */
269
+ interface QueueWorkpoolOptions {
270
+ /** The Cloudflare Queue producer binding to enqueue onto. */
271
+ queue: QueueLike<QueueJob>;
272
+ }
273
+ /**
274
+ * Queues-backed producer: enqueue function dispatches onto a Cloudflare Queue.
275
+ * Concurrency, retries, and dead-lettering are configured on the queue consumer
276
+ * in `wrangler.jsonc` (`max_concurrency` / `max_retries` / `dead_letter_queue`),
277
+ * not here — that's the whole point of using Queues over the DO workpool.
278
+ */
279
+ interface QueueWorkpool {
280
+ /** Enqueue a single `fn(args)` dispatch. */
281
+ enqueue: <F extends FunctionReference>(function_: F, args: ArgsOf<F>, options?: QueueEnqueueOptions) => Promise<void>;
282
+ /** Enqueue many dispatches in one `sendBatch`. Each job names its function `ref`. */
283
+ enqueueBatch: (jobs: ReadonlyArray<{
284
+ args?: Record<string, unknown>;
285
+ ref: FunctionReference;
286
+ shardKey?: string;
287
+ }>, options?: QueueSendOptionsLike) => Promise<void>;
288
+ }
289
+ /** Dispatches a single {@link QueueJob} — the consumer's per-message worker. */
290
+ type QueueDispatch = (job: QueueJob) => Promise<void>;
291
+ /** Options for `createQueueConsumer`. */
292
+ interface QueueConsumerOptions {
293
+ /** How each job is executed; e.g. the `httpDispatcher`. */
294
+ dispatch: QueueDispatch;
295
+ }
296
+ /** Options for the `httpDispatcher` — the default HTTP dispatcher. */
297
+ interface HttpDispatcherOptions {
298
+ /** Admin bearer token the dispatch endpoint accepts (`LUNORA_ADMIN_TOKEN`). */
299
+ adminToken: string;
300
+ /** Injectable fetch (tests); defaults to the global. */
301
+ fetchImpl?: typeof fetch;
302
+ /** Origin where the Worker is mounted (the `/_lunora/scheduler/dispatch` endpoint). */
303
+ originUrl: string;
304
+ }
305
+ /**
306
+ * Client-side scheduler — forwards `runAfter` / `runAt` / `cancel` calls to a
307
+ * `SchedulerDO` over HTTP. The DO owns the alarm and the storage; this is a
308
+ * thin RPC wrapper.
309
+ */
310
+ declare const createScheduler: (options: LunoraSchedulerOptions) => Scheduler;
311
+ /**
312
+ * Bounded-concurrency action queue — the Lunora equivalent of
313
+ * `@convex-dev/workpool`. Mirrors `createScheduler`'s `namespace` /
314
+ * `originUrl` / `instanceName` options and is built on the SAME `SchedulerDO`:
315
+ * a workpool is just a NAMED logical pool inside that DO (concurrency counter
316
+ * keyed by {@link WorkpoolOptions.name} under the `pool:&lt;name>` storage key).
317
+ * It needs no extra Durable Object or wrangler binding beyond the SchedulerDO
318
+ * the scheduler already uses.
319
+ *
320
+ * `enqueue` schedules a job tagged with this pool; the DO dispatches at most
321
+ * `maxConcurrency` of the pool's jobs at once and queues the rest durably,
322
+ * draining them as the runtime reports completions (`POST /complete`).
323
+ *
324
+ * ```ts
325
+ * const pool = createWorkpool({ namespace: env.SCHEDULER, originUrl, maxConcurrency: 5 });
326
+ * await pool.enqueue(internal.stripe.sync, { invoiceId }, { retry: { maxAttempts: 3 } });
327
+ * ```
328
+ *
329
+ * Why not Cloudflare Queues? Queues natively cover concurrency-capped, retried,
330
+ * dead-lettered, delayed dispatch (`max_concurrency`, `max_retries`,
331
+ * `retry({ delaySeconds })`, `dead_letter_queue`), and are the right tool when
332
+ * you just want to rate-limit fire-and-forget background work. This workpool
333
+ * deliberately stays on `SchedulerDO` because it offers what a queue can't: a
334
+ * hard concurrency cap (the DO is the single serialization point — no
335
+ * cross-consumer overshoot), per-job cancellation, and per-job status
336
+ * introspection, all keyed by a stable job id. Reach for Queues when you don't
337
+ * need those; reach for this when you do. Either way, do NOT grow multi-step
338
+ * orchestration on top of this — that's Cloudflare **Workflows** (`step.do` /
339
+ * `step.sleep` / `step.waitForEvent`).
340
+ */
341
+ declare const createWorkpool: (options: WorkpoolOptions) => Workpool;
342
+ interface CronTriggerOptions {
343
+ /** Args passed to the function. */
344
+ args?: Record<string, unknown>;
345
+ /** The Lunora function to invoke on each trigger fire. */
346
+ fn: FunctionReference;
347
+ /** Standard cron expression, e.g. `"0 * * * *"`. */
348
+ schedule: string;
349
+ }
350
+ interface CronTriggerSnippet {
351
+ /** Paste these into `wrangler.jsonc` under `triggers.crons`. */
352
+ crons: string[];
353
+ /** Routes the Worker should mount to receive the trigger dispatch. */
354
+ dispatcher: {
355
+ args: Record<string, unknown>;
356
+ functionPath: string;
357
+ };
358
+ /** Human-readable wrangler.jsonc snippet developers can copy/paste. */
359
+ wranglerJsonc: string;
360
+ }
361
+ /**
362
+ * Produces the wrangler.jsonc fragment + dispatcher metadata for a recurring
363
+ * function. The actual cron handler is mounted by `@lunora/runtime` — we only
364
+ * emit the configuration here.
365
+ */
366
+ declare const createCronTrigger: (options: CronTriggerOptions) => CronTriggerSnippet;
367
+ /** Sub-day recurrence. Exactly one unit must be provided. */
368
+ interface IntervalSchedule {
369
+ hours?: number;
370
+ minutes?: number;
371
+ seconds?: number;
372
+ }
373
+ /** Daily recurrence at a fixed UTC wall-clock time. */
374
+ interface DailySchedule {
375
+ /** 0–23. */
376
+ hourUTC: number;
377
+ /** 0–59. */
378
+ minuteUTC: number;
379
+ }
380
+ /** Weekly recurrence at a fixed UTC time on a given weekday. */
381
+ interface WeeklySchedule extends DailySchedule {
382
+ /** Long weekday name, case-insensitive (e.g. `"monday"`). */
383
+ dayOfWeek: "friday" | "monday" | "saturday" | "sunday" | "thursday" | "tuesday" | "wednesday";
384
+ }
385
+ /** Monthly recurrence at a fixed UTC time on a given day-of-month. */
386
+ interface MonthlySchedule extends DailySchedule {
387
+ /** 1–31. */
388
+ day: number;
389
+ }
390
+ /**
391
+ * One registered cron job, normalized to a compiled cron expression. Shared
392
+ * verbatim with `@lunora/codegen` (which lifts the same fields out of the AST)
393
+ * and the runtime dispatcher — keep the shape stable across all three.
394
+ */
395
+ interface CronJob {
396
+ /** Args forwarded to the function (or, for a workflow target, used as its `params`) on each fire. */
397
+ args: Record<string, unknown>;
398
+ /** Compiled standard cron expression, e.g. `"0 9 * * *"`. */
399
+ cron: string;
400
+ /**
401
+ * `__lunoraRef` of the target function. Present for a function target;
402
+ * absent when the job targets a workflow ({@link CronJob.workflow} instead).
403
+ */
404
+ functionPath?: string;
405
+ /** Human-readable identifier — must be unique within one `cronJobs()`. */
406
+ name: string;
407
+ /**
408
+ * Set when the job targets a durable workflow rather than a function: the
409
+ * workflow's stable name (`defineWorkflow({ name })`) when one was declared,
410
+ * otherwise `""`. `@lunora/codegen` statically resolves the concrete
411
+ * `lunora/workflows.ts` export + its `WORKFLOW_*` binding for the emitted
412
+ * dispatch map, so this authoring-time value is informational only.
413
+ */
414
+ workflow?: string;
415
+ }
416
+ /** The ergonomic builder methods, excluding the raw `.cron` escape hatch. */
417
+ type CronScheduleKind = "daily" | "interval" | "monthly" | "weekly";
418
+ /** The ergonomic schedule kinds as a runtime set (codegen reads this to detect cron builder methods). */
419
+ declare const CRON_SCHEDULE_KINDS: ReadonlySet<CronScheduleKind>;
420
+ /**
421
+ * Compile one of the ergonomic schedule forms into a standard cron expression.
422
+ * Exposed as a pure function so `@lunora/codegen` can reuse the exact same
423
+ * compilation when it statically lifts a `crons.{kind}(...)` call out of the
424
+ * AST — codegen imports this directly (no duplicated mirror).
425
+ */
426
+ declare const compileCronSchedule: (kind: CronScheduleKind, schedule: DailySchedule | IntervalSchedule | MonthlySchedule | WeeklySchedule) => string;
427
+ /**
428
+ * Builder returned by {@link cronJobs}. Each method registers one recurring
429
+ * job; the compiled expression is validated immediately so authoring mistakes
430
+ * surface at definition time rather than at codegen.
431
+ */
432
+ interface CronJobsBuilder {
433
+ /**
434
+ * Raw cron expression escape hatch (5- or 6-field, full cron-parser grammar).
435
+ * The target may be a function (`internal.file.fn`) or a durable workflow
436
+ * (`workflows.&lt;name>`); a workflow's `args` are inferred from its `params`.
437
+ */
438
+ cron: <T extends CronTarget>(name: string, cronExpr: string, target: T, args?: CronTargetArgs<T>) => CronJobsBuilder;
439
+ /** Daily at `hourUTC:minuteUTC` (UTC). The target may be a function or a durable workflow (`workflows.&lt;name>`). */
440
+ daily: <T extends CronTarget>(name: string, schedule: DailySchedule, target: T, args?: CronTargetArgs<T>) => CronJobsBuilder;
441
+ /** Every `{ seconds | minutes | hours }`. The target may be a function or a durable workflow (`workflows.&lt;name>`). */
442
+ interval: <T extends CronTarget>(name: string, schedule: IntervalSchedule, target: T, args?: CronTargetArgs<T>) => CronJobsBuilder;
443
+ /** Snapshot of the registered jobs, in declaration order. */
444
+ jobs: () => ReadonlyArray<CronJob>;
445
+ /** Monthly on `day` at `hourUTC:minuteUTC` (UTC). The target may be a function or a durable workflow (`workflows.&lt;name>`). */
446
+ monthly: <T extends CronTarget>(name: string, schedule: MonthlySchedule, target: T, args?: CronTargetArgs<T>) => CronJobsBuilder;
447
+ /** Weekly on `dayOfWeek` at `hourUTC:minuteUTC` (UTC). The target may be a function or a durable workflow (`workflows.&lt;name>`). */
448
+ weekly: <T extends CronTarget>(name: string, schedule: WeeklySchedule, target: T, args?: CronTargetArgs<T>) => CronJobsBuilder;
449
+ }
450
+ /**
451
+ * Create a code-first cron registry. The returned builder is chainable;
452
+ * codegen discovers a `lunora/crons.ts` default export by AST, not a runtime
453
+ * brand.
454
+ */
455
+ declare const cronJobs: () => CronJobsBuilder;
456
+ /**
457
+ * Build a Queues producer that enqueues Lunora function dispatches. Concurrency
458
+ * and retry policy live on the consumer's `wrangler.jsonc` config, not here.
459
+ */
460
+ declare const createQueueWorkpool: (options: QueueWorkpoolOptions) => QueueWorkpool;
461
+ /**
462
+ * Wrap a {@link QueueDispatch} into a Cloudflare `queue()` consumer handler.
463
+ *
464
+ * Each message is dispatched independently (concurrently across the batch). On
465
+ * success the message is `ack()`-ed; on any failure — a thrown dispatcher or a
466
+ * structurally-invalid body — it is `retry()`-ed, so Queues' own `max_retries`
467
+ * + `dead_letter_queue` settings decide when to give up. Nothing is silently
468
+ * dropped: a permanently-bad message rides retries into the dead-letter queue
469
+ * where you can inspect it.
470
+ */
471
+ declare const createQueueConsumer: (options: QueueConsumerOptions) => ((batch: MessageBatchLike) => Promise<void>);
472
+ /**
473
+ * Default {@link QueueDispatch}: POST each job to the Worker's
474
+ * `/_lunora/scheduler/dispatch` endpoint (the same path SchedulerDO dispatches
475
+ * through), authenticated with the admin bearer. A non-2xx response throws so
476
+ * the consumer retries the message.
477
+ */
478
+ declare const httpDispatcher: (options: HttpDispatcherOptions) => QueueDispatch;
479
+ /**
480
+ * Minimal projection of `DurableObjectState` for the SchedulerDO. Declared
481
+ * structurally so unit tests can pass a fake state without booting the
482
+ * workers runtime. The WebSocket methods are optional: they back the live
483
+ * `/ws` subscription (push the job list on every change) and are absent in the
484
+ * storage-only fakes, in which case the DO simply serves no live sockets.
485
+ */
486
+ interface SchedulerDOState {
487
+ /** Accept a hibernatable server WebSocket (workers `state.acceptWebSocket`). */
488
+ acceptWebSocket?: (ws: WebSocket) => void;
489
+ /** Every accepted server WebSocket (workers `state.getWebSockets`). */
490
+ getWebSockets?: () => WebSocket[];
491
+ storage: {
492
+ delete: (key: string | string[]) => Promise<number | boolean>;
493
+ deleteAlarm: () => Promise<void> | void;
494
+ get: <T = unknown>(key: string) => Promise<T | undefined>;
495
+ getAlarm: () => Promise<number | null>;
496
+ list: <T = unknown>(options?: {
497
+ end?: string;
498
+ limit?: number;
499
+ prefix?: string;
500
+ }) => Promise<Map<string, T>>;
501
+ put: <T = unknown>(entries: Record<string, T> | string, value?: T) => Promise<void>;
502
+ setAlarm: (scheduledTime: number | Date) => Promise<void> | void;
503
+ };
504
+ }
505
+ interface SchedulerEnv {
506
+ [key: string]: unknown;
507
+ /**
508
+ * Fallback bearer token attached to the dispatch when
509
+ * {@link SchedulerEnv.LUNORA_SCHEDULER_SECRET} is not configured. Sent as
510
+ * `authorization: Bearer &lt;token>`.
511
+ */
512
+ LUNORA_ADMIN_TOKEN?: string;
513
+ /**
514
+ * Base URL where the Worker is mounted. SchedulerDO uses this at dispatch
515
+ * time to call back into the Worker. Read at fire time (NOT taken from the
516
+ * request body) to prevent SSRF via a forged `originUrl` field.
517
+ */
518
+ LUNORA_ORIGIN_URL?: string;
519
+ /**
520
+ * Shared secret used to HMAC-sign the dispatch body so the runtime receiver
521
+ * can authenticate the call (header `x-lunora-scheduler-signature`). Without
522
+ * it the dispatch is sent unsigned (optionally bearer-authenticated via
523
+ * {@link SchedulerEnv.LUNORA_ADMIN_TOKEN}).
524
+ */
525
+ LUNORA_SCHEDULER_SECRET?: string;
526
+ }
527
+ /**
528
+ * One pool's live backlog, as surfaced by `GET /status`. `inFlight`/
529
+ * `maxConcurrency` mirror the durable {@link PoolState} semaphore; `queued`
530
+ * is the number of pending (not-yet-dispatched) jobs routed to this pool.
531
+ */
532
+ interface SchedulerPoolStatus {
533
+ /** Jobs currently dispatched-but-not-yet-completed (the held slots). */
534
+ inFlight: number;
535
+ /** The pool's concurrency cap. */
536
+ maxConcurrency: number;
537
+ /** The logical workpool name (the `pool:&lt;name>` suffix). */
538
+ name: string;
539
+ /** Pending jobs routed to this pool but not yet dispatched. */
540
+ queued: number;
541
+ }
542
+ /**
543
+ * App-level scheduler backlog, as returned by `GET /status`. `pools` carries
544
+ * the per-pool breakdown; `backlog` and `inFlight` are the app-wide sums of
545
+ * `queued` and `inFlight` across every pool — the SLO view's headline numbers.
546
+ */
547
+ interface SchedulerStatus {
548
+ /** Sum of every pool's `queued` count — the total pending backlog. */
549
+ backlog: number;
550
+ /** Sum of every pool's `inFlight` count — the total held concurrency slots. */
551
+ inFlight: number;
552
+ /** Per-pool backlog breakdown, one entry per `pool:&lt;name>` record. */
553
+ pools: SchedulerPoolStatus[];
554
+ }
555
+ /**
556
+ * Durable Object that stores pending scheduled invocations sorted by their
557
+ * `scheduledFor` time and fires them via HTTP on alarm. Storage layout:
558
+ * `id:&lt;id>` maps to {@link ScheduleRecord}; `t:&lt;paddedTime>:&lt;id>` maps to the
559
+ * id (used as a sorted index).
560
+ *
561
+ * On every mutation the DO recomputes the earliest pending task and updates
562
+ * the alarm via `state.storage.setAlarm(time)`.
563
+ */
564
+ declare class SchedulerDO {
565
+ private static indexKey;
566
+ private static json;
567
+ private static error;
568
+ /**
569
+ * Resolve the effective retry parameters for a record: its per-job
570
+ * {@link RetryPolicy} merged over the DO's built-in defaults. Callers that
571
+ * never set `record.retry` get today's behaviour verbatim
572
+ * (`maxAttempts: 5`, exponential, `baseMs: 30_000`, no ceiling).
573
+ */
574
+ private static resolveRetry;
575
+ /** Clamp an untrusted `maxConcurrency` to a positive integer, else fall back. */
576
+ private static normalizeConcurrency;
577
+ /**
578
+ * Sanitize an untrusted retry policy from the wire into a `RetryPolicy` (or
579
+ * `undefined` when nothing valid was provided). Keeps obviously-bad values
580
+ * out of storage so {@link SchedulerDO.resolveRetry} never has to re-guard.
581
+ * @returns The normalized policy, or `undefined` if no valid policy was found.
582
+ */
583
+ private static normalizeRetry;
584
+ /**
585
+ * Idempotently release the slot held by `jobId`, returning the updated
586
+ * {@link PoolState} (pure — the caller persists it). A duplicate release for
587
+ * an id that no longer holds a slot is a no-op, so an at-least-once
588
+ * `/complete` (or a complete racing a failed-kick release) can never push
589
+ * `inFlight` below the true number of running jobs and oversubscribe the
590
+ * pool. Pools persisted before `inFlightIds` existed fall back to a clamped
591
+ * counter decrement.
592
+ */
593
+ private static releaseSlot;
594
+ /**
595
+ * Best-effort release with no job id (legacy `/complete` payloads). Drops one
596
+ * tracked id if the set exists, else clamps the counter. Less precise than
597
+ * {@link SchedulerDO.releaseSlot} — a duplicate id-less complete CAN
598
+ * over-release — but every current client sends the id, so this is the
599
+ * compatibility shim, not the hot path.
600
+ */
601
+ private static releaseFirstSlot;
602
+ protected readonly state: SchedulerDOState;
603
+ protected readonly env: SchedulerEnv;
604
+ constructor(state: SchedulerDOState, env: SchedulerEnv);
605
+ fetch(request: Request): Promise<Response>;
606
+ /** Called by the Workers runtime when the alarm previously set by `_rescheduleAlarm()` fires. */
607
+ alarm(): Promise<void>;
608
+ /**
609
+ * Internal dispatch hook; overridden in unit tests to capture the outgoing
610
+ * request. Returns `true` ONLY on an explicit 2xx response (`response.ok`).
611
+ * Anything else — a network failure, a 5xx, OR a non-2xx such as 404
612
+ * (receiver route not mounted) / 401 / 403 / 4xx — returns `false` and
613
+ * enters the retry pipeline via {@link recordRetry}. Treating 4xx as
614
+ * success used to permanently delete the job; since the receiver may simply
615
+ * be missing (404) or transiently failing, we retry rather than silently
616
+ * drop. After {@link MAX_RETRY_ATTEMPTS} the record is parked under a
617
+ * `dead:` key for inspection — never silently deleted.
618
+ *
619
+ * The dispatch target is taken from `env.LUNORA_ORIGIN_URL` (NOT from the
620
+ * stored record) to prevent SSRF via a forged `originUrl` on the schedule
621
+ * request. If that env var is missing at fire time (a deploy/binding
622
+ * regression — schedule time already enforced its presence) we return
623
+ * `false` so the record is retried rather than silently dropped.
624
+ */
625
+ protected dispatch(record: ScheduleRecord): Promise<boolean>;
626
+ /**
627
+ * Claim + drain one due record with per-record fault isolation, so a storage
628
+ * throw can never abort the whole alarm pass (which would skip the remaining
629
+ * due records and the `rescheduleAlarm()` that re-arms the clock).
630
+ *
631
+ * Claims the job by deleting its time-index entry BEFORE dispatch (an alarm
632
+ * re-fire then won't pick it up again), runs {@link drainRecord}, and on a
633
+ * thrown storage op decides whether the job stays re-fireable.
634
+ *
635
+ * When the record was NOT successfully dispatched, re-assert the time-index
636
+ * claim so a later alarm re-attempts it (at-least-once): the claim delete may
637
+ * have removed it and recordRetry()/requeuePooled() may not have re-armed it
638
+ * before throwing, and re-inserting the same key is idempotent, so a
639
+ * surviving claim is simply rewritten to its prior value. When the record WAS
640
+ * dispatched (the throw came from post-dispatch cleanup), leave the index
641
+ * deleted so the already-kicked, idempotent job is not re-fired.
642
+ */
643
+ private drainRecordGuarded;
644
+ /**
645
+ * Process one due (already index-claimed) record within an alarm drain:
646
+ * apply the workpool concurrency gate, dispatch, and settle the result.
647
+ * A saturated pool re-arms the job (backpressure, no attempt charged); a
648
+ * free slot is reserved durably before dispatch and released immediately if
649
+ * the kick fails (success holds it until the runtime reports completion).
650
+ * Success clears the `id:`/`retry:` rows; failure routes to
651
+ * {@link recordRetry}. `pools` caches each pool's {@link PoolState} for the
652
+ * lifetime of the drain so the budget decrements without re-reading storage.
653
+ * @returns `true` only when the record was successfully dispatched (a 2xx
654
+ * kick). The caller ({@link drainRecordGuarded}) uses this in its per-record
655
+ * error guard: a record that returns `true` (or whose post-dispatch cleanup
656
+ * later throws) must NOT have its time-index claim restored, since re-firing
657
+ * an already-kicked job would break idempotency. A `false` return (pool
658
+ * backpressure or a failed dispatch) means the job is still re-fireable —
659
+ * either already re-armed here, or, if a throw escapes, re-claimed by the
660
+ * guard's catch.
661
+ */
662
+ private drainRecord;
663
+ /**
664
+ * Concurrency gate for a pooled record. Returns `false` (and re-arms the
665
+ * job via {@link requeuePooled}) when the pool is at `maxConcurrency`;
666
+ * otherwise reserves a slot durably and returns `true`. Non-pooled records
667
+ * always return `true` without touching any pool state.
668
+ */
669
+ private reservePoolSlot;
670
+ /**
671
+ * Accept a hibernatable live subscription to the job list. The scheduler has
672
+ * exactly one subscription shape (the whole list), so there's no per-socket
673
+ * registry or dependency tracking — every accepted socket gets the full list
674
+ * on connect and on every change. The worker is responsible for gating the
675
+ * upgrade behind the admin token before it reaches here.
676
+ */
677
+ private handleWebSocketUpgrade;
678
+ /**
679
+ * Re-list the jobs and push them to every connected subscriber. Called after
680
+ * any change (schedule / cancel / alarm-fire) so live studios reflect it
681
+ * immediately. A no-op when the runtime doesn't support hibernated sockets.
682
+ */
683
+ private broadcastChange;
684
+ /** The current pending job records (shared by `/list` and the live channel). */
685
+ private listRecords;
686
+ /**
687
+ * HMAC-SHA-256 sign the dispatch body with `env.LUNORA_SCHEDULER_SECRET`,
688
+ * returning a base64url signature, or `undefined` when no secret is
689
+ * configured. Mirrors `@lunora/storage`'s signed-URL HMAC pattern (WebCrypto
690
+ * `crypto.subtle`, available in workerd).
691
+ */
692
+ private signDispatch;
693
+ /**
694
+ * Move a failed record into the retry pipeline with configurable backoff.
695
+ * The retry budget/backoff comes from the record's {@link RetryPolicy}
696
+ * (falling back to the DO defaults); on exhaustion the record is parked
697
+ * under a `dead:` key for manual inspection.
698
+ */
699
+ private recordRetry;
700
+ /** Read the durable `pool:&lt;name>` row, defaulting to a fresh `inFlight: 0` pool. */
701
+ private loadPool;
702
+ private savePool;
703
+ /**
704
+ * Re-arm a pooled job that couldn't run because its pool was at capacity.
705
+ * No attempt is charged (this is backpressure, not a failure): the job is
706
+ * pushed `POOL_BACKPRESSURE_DELAY_MS` into the future so a later alarm
707
+ * drains it once a slot frees, keeping its `id:` header and retry policy.
708
+ */
709
+ private requeuePooled;
710
+ /**
711
+ * Release a pool slot when the runtime reports an action finished. This is
712
+ * the durable-semaphore decrement: dispatch() only KICKS the action and
713
+ * holds the slot; the runtime calls back here (`POST /complete { id }`) once
714
+ * the action settles, freeing the slot for the next queued job. Idempotent
715
+ * and safe if the job/pool is already gone.
716
+ */
717
+ private handleComplete;
718
+ /** `GET /pool?name=` — inspect a pool's slot usage + queued count. */
719
+ private handlePoolStatus;
720
+ /**
721
+ * `GET /status` — the app-level backlog signal that powers the studio's
722
+ * SLO view. Enumerates every durable `pool:&lt;name>` row for its `inFlight`/
723
+ * `maxConcurrency` semaphore, counts the pending (not-yet-dispatched) jobs
724
+ * routed to each pool with the same single-pass scan {@link handlePoolStatus}
725
+ * uses, and rolls those up into app-wide `backlog` (sum of `queued`) and
726
+ * `inFlight` (sum of held slots) totals.
727
+ *
728
+ * Pools that have rows but no queued jobs still appear (with `queued: 0`) so
729
+ * a saturated-but-idle pool stays visible; a pool that only ever existed as
730
+ * queued jobs without a persisted row is unreachable here (the schedule path
731
+ * always writes a `pool:&lt;name>` row before the job's header), so a single
732
+ * scan over `pool:`/`id:` is sufficient.
733
+ */
734
+ private handleStatus;
735
+ private handleSchedule;
736
+ private handleCancel;
737
+ private handleList;
738
+ /**
739
+ * `GET /dead` — list the dead-letter records: jobs that exhausted their
740
+ * retry budget ({@link recordRetry}) and were parked under `dead:&lt;id>`
741
+ * instead of being silently dropped. These never appear in `/list` (their
742
+ * `id:` header is deleted on park), so this is the ONLY way the studio can
743
+ * surface — and recover — a permanently-failed job.
744
+ */
745
+ private handleDeadList;
746
+ /**
747
+ * `POST /dead/retry { id }` — resurrect a dead-letter record: reset its
748
+ * exhausted attempt count to 0 (a fresh retry budget), re-arm it for
749
+ * immediate dispatch via the standard time index, and drop the `dead:` row.
750
+ * The new `id:` header makes it visible to `/list` and the live `/ws`
751
+ * subscription again. A miss is a no-op (`{ retried: false }`).
752
+ */
753
+ private handleDeadRetry;
754
+ /**
755
+ * `POST /dead/cancel { id }` — permanently drop a dead-letter record the
756
+ * operator has decided not to recover. Returns `{ removed }` (false when
757
+ * nothing matched). Idempotent: a repeated purge is a harmless no-op.
758
+ */
759
+ private handleDeadCancel;
760
+ /**
761
+ * Resolve a single pending job by id via a direct `id:&lt;id>` storage read —
762
+ * O(1), versus scanning the whole `/list` view. Responds `{ record }` on a
763
+ * hit and `{}` on a miss (an absent `record` field — JSON has no `undefined`
764
+ * — which the client reads back as `null`).
765
+ */
766
+ private handleGet;
767
+ private removeRecord;
768
+ /**
769
+ * Arm the alarm for `scheduledFor` only if it is sooner than the currently
770
+ * set alarm (or none is set). Used on the schedule path: inserting a job
771
+ * can only ever pull the earliest-pending time *earlier*, never later, so a
772
+ * full `t:` rescan is unnecessary unless the new job is the new earliest.
773
+ */
774
+ private armAlarmIfEarlier;
775
+ private rescheduleAlarm;
776
+ }
777
+ /** Standard cron expression (5- or 6-field) or a supported `@macro`, per `cron-parser`. */
778
+ declare const isValidCronExpression: (schedule: string) => boolean;
779
+ /**
780
+ * Assert a raw cron expression is well-formed, throwing the same shaped error
781
+ * both cron surfaces use. The `context` prefix lets callers name the offending
782
+ * job (`cron job "send digest"`) vs. the bare trigger.
783
+ */
784
+ declare const assertValidCronExpression: (schedule: string, context?: string) => void;
785
+ export { type ArgsOf, CRON_SCHEDULE_KINDS, type CronJob, type CronJobsBuilder, type CronScheduleKind, type CronTarget, type CronTriggerOptions, type CronTriggerSnippet, type DailySchedule, type DurableObjectIdLike, type DurableObjectNamespaceLike, type DurableObjectStubLike, type EnqueueOptions, type FunctionReference, type HttpDispatcherOptions, type IntervalSchedule, type LunoraSchedulerOptions, type MessageBatchLike, type MonthlySchedule, type QueueConsumerOptions, type QueueDispatch, type QueueEnqueueOptions, type QueueJob, type QueueLike, type QueueMessageLike, type QueueSendOptionsLike, type QueueSendRequestLike, type QueueWorkpool, type QueueWorkpoolOptions, type RetryPolicy, type RunOptions, type ScheduleRecord, type Scheduler, SchedulerDO, type SchedulerDOState, type SchedulerEnv, type SchedulerPoolStatus, type SchedulerStatus, type WeeklySchedule, type WorkflowReference, type Workpool, type WorkpoolOptions, assertValidCronExpression, compileCronSchedule, createCronTrigger, createQueueConsumer, createQueueWorkpool, createScheduler, createWorkpool, cronJobs, httpDispatcher, isValidCronExpression, isWorkflowReference };