@lunora/scheduler 1.0.0-alpha.4 → 1.0.0-alpha.41

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/index.d.ts CHANGED
@@ -1,12 +1,13 @@
1
+ import { SchedulerHost } from '@lunora/platform';
1
2
  /**
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
- */
3
+ * Opaque reference to a Lunora function. Mirrors the `FunctionReference` shape
4
+ * emitted by `@lunora/codegen` (and consumed by `@lunora/client`). We avoid a
5
+ * direct dependency to keep this package usable from the codegen pipeline
6
+ * itself.
7
+ *
8
+ * The runtime identifier lives in `__lunoraRef` — this MUST stay in lockstep
9
+ * with the codegen emit + `@lunora/client`'s `FunctionReference`.
10
+ */
10
11
  interface FunctionReference {
11
12
  readonly __lunoraRef: string;
12
13
  /** Marker phantom type — discriminates queries / mutations / actions. */
@@ -16,26 +17,26 @@ type ArgsOf<F extends FunctionReference> = F extends {
16
17
  _args?: infer A;
17
18
  } ? A : Record<string, unknown>;
18
19
  /**
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
- */
20
+ * Typed reference to a Lunora durable workflow — either the generated
21
+ * `workflows.<name>` reference object (`_generated/api.ts`, which carries the
22
+ * `WORKFLOW_*` binding + export name) or, structurally, a `defineWorkflow()`
23
+ * result imported directly. Both are matched by the `isLunoraWorkflow` brand and
24
+ * carry the workflow's `params` in the phantom `__params`, so a `cronJobs()`
25
+ * registration infers them.
26
+ *
27
+ * Declared structurally here so `@lunora/scheduler` can let a `cronJobs()`
28
+ * builder target a workflow without depending on `@lunora/workflow` (and so the
29
+ * generated `workflows.*` object needs no `@lunora/scheduler` import — it
30
+ * matches structurally). A cron whose target is a {@link WorkflowReference}
31
+ * starts a new workflow INSTANCE on each fire (the args become its `params`)
32
+ * instead of dispatching a one-shot function. `@lunora/codegen` resolves the
33
+ * concrete `lunora/workflows.ts` export statically; the runtime brand here is
34
+ * the authoring-time guard.
35
+ */
35
36
  interface WorkflowReference<Params = Record<string, unknown>> {
36
37
  /** Phantom carrier for the workflow's `params` type — drives `cronJobs()` arg inference. Never read at runtime. */
37
38
  readonly __params?: Params;
38
- /** The `WORKFLOW_*` binding name (present on a generated `workflows.&lt;name>` ref). */
39
+ /** The `WORKFLOW_*` binding name (present on a generated `workflows.<name>` ref). */
39
40
  readonly binding?: string;
40
41
  readonly isLunoraWorkflow: true;
41
42
  /** The workflow's export/stable name (present on a generated ref; a `defineWorkflow({ name })` override otherwise). */
@@ -45,23 +46,32 @@ interface WorkflowReference<Params = Record<string, unknown>> {
45
46
  type CronTarget = FunctionReference | WorkflowReference;
46
47
  /** The arguments a cron's target accepts: a workflow's inferred `params`, else an open record (function args aren't inferred). */
47
48
  type CronTargetArgs<T extends CronTarget> = T extends WorkflowReference<infer Params> ? Params : Record<string, unknown>;
49
+ /**
50
+ * The arguments a one-shot schedule target ({@link Scheduler.runAfter} /
51
+ * {@link Scheduler.runAt}) accepts. Unlike {@link CronTargetArgs} it preserves a
52
+ * {@link FunctionReference}'s inferred `args` (via {@link ArgsOf}) as well as a
53
+ * {@link WorkflowReference}'s inferred `params`, so scheduling a plain function
54
+ * keeps its today's arg checking while scheduling a workflow/agent infers its
55
+ * `params`.
56
+ */
57
+ type ScheduleTargetArgs<T extends CronTarget> = T extends WorkflowReference<infer Params> ? Params : T extends FunctionReference ? ArgsOf<T> : Record<string, unknown>;
48
58
  /** Narrow a {@link CronTarget} to a {@link WorkflowReference} by its runtime brand. */
49
59
  declare const isWorkflowReference: (target: unknown) => target is WorkflowReference;
50
60
  /**
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
- */
61
+ * Per-job retry policy. Wired into the SchedulerDO's existing attempts/backoff
62
+ * machinery. When omitted, the DO falls back to its built-in defaults
63
+ * (`maxAttempts: 5`, `backoff: "exponential"`, `baseMs: 30_000`) so existing
64
+ * `runAfter`/`runAt` callers keep today's behaviour unchanged.
65
+ *
66
+ * On exhaustion (attempts > `maxAttempts`) the record is parked under the
67
+ * `dead:` dead-letter key for inspection — never silently dropped.
68
+ */
59
69
  interface RetryPolicy {
60
70
  /**
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
- */
71
+ * Backoff growth across attempts. `"exponential"` doubles the delay each
72
+ * attempt (`baseMs * 2 ** (attempt - 1)`); `"linear"` grows it linearly
73
+ * (`baseMs * attempt`). Default `"exponential"`.
74
+ */
65
75
  backoff?: "exponential" | "linear";
66
76
  /** Base delay in milliseconds for the first retry. Default `30_000`. */
67
77
  baseMs?: number;
@@ -72,11 +82,11 @@ interface RetryPolicy {
72
82
  }
73
83
  interface RunOptions {
74
84
  /**
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
- */
85
+ * Logical workpool this job belongs to. When set, the SchedulerDO gates the
86
+ * job behind the pool's `maxConcurrency` (see {@link WorkpoolOptions}).
87
+ * Usually populated by {@link Workpool.enqueue}; callers rarely set it on a
88
+ * bare `runAfter`/`runAt`.
89
+ */
80
90
  pool?: string;
81
91
  /** Per-job retry policy. Falls back to the DO's built-in defaults when omitted. */
82
92
  retry?: RetryPolicy;
@@ -86,65 +96,100 @@ interface RunOptions {
86
96
  interface ScheduleRecord {
87
97
  args: Record<string, unknown>;
88
98
  /**
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
- */
99
+ * Number of dispatch attempts already made. Absent (treated as 0) until the
100
+ * first failure, after which `recordRetry()` persists it on both the
101
+ * `retry:` row and the `id:` header. Surfaced here so `/list` consumers and
102
+ * the studio see the field the storage layer actually writes.
103
+ */
94
104
  attempts?: number;
95
105
  enqueuedAt: number;
96
- functionPath: string;
106
+ /**
107
+ * The `ns:fn` path of the function to dispatch on fire. Absent when the job
108
+ * targets a durable workflow/agent instead — see {@link ScheduleRecord.workflow}.
109
+ * Exactly one of `functionPath` / `workflow` is set.
110
+ */
111
+ functionPath?: string;
97
112
  id: string;
98
113
  /**
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
- */
114
+ * Scheduler/workpool instance name the job was enqueued through. Echoed in
115
+ * the dispatch payload so the runtime can call back the SAME DO instance's
116
+ * `/complete` to release a pooled slot. Absent for the default instance.
117
+ */
103
118
  instanceName?: string;
104
119
  /**
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
- */
120
+ * Logical workpool this job belongs to (set by {@link Workpool.enqueue}).
121
+ * When present, the SchedulerDO only dispatches the job while the pool's
122
+ * in-flight count is below its `maxConcurrency`; otherwise it stays queued
123
+ * and drains as slots free. Absent for plain `runAfter`/`runAt` jobs, which
124
+ * are never concurrency-gated.
125
+ */
111
126
  pool?: string;
112
127
  /** Per-job retry policy (see {@link RetryPolicy}); absent means DO defaults. */
113
128
  retry?: RetryPolicy;
114
129
  scheduledFor: number;
115
130
  shardKey?: string;
131
+ /**
132
+ * The `WORKFLOW_*`/`AGENT_*` binding name to start a fresh durable instance
133
+ * of on fire (the {@link ScheduleRecord.args} become its `params`). Set
134
+ * instead of {@link ScheduleRecord.functionPath} when the job targets a
135
+ * workflow/agent {@link WorkflowReference}. The runtime — not the DO — owns
136
+ * the binding, so the dispatch payload carries this through to the Worker.
137
+ */
138
+ workflow?: string;
116
139
  }
117
140
  interface Scheduler {
118
141
  cancel: (id: string) => Promise<{
119
142
  cancelled: boolean;
120
143
  }>;
144
+ /**
145
+ * Jobs that exhausted their retry budget and were parked under `dead:`
146
+ * (the DO's `/dead` view). Deliberately absent from {@link Scheduler.list} —
147
+ * the park deletes the `id:` header — so this is the only view of a job
148
+ * that failed permanently rather than being silently dropped.
149
+ */
150
+ dead: () => Promise<ScheduleRecord[]>;
151
+ /**
152
+ * Resurrect a parked job with a fresh attempt budget (the DO's
153
+ * `POST /dead/retry`). `false` when the id is not parked; a racing double
154
+ * recover is a no-op rather than an error.
155
+ */
156
+ deadRetry: (id: string) => Promise<boolean>;
121
157
  /** Resolve a single pending job by id, or `null` when absent (derived from {@link Scheduler.list}). */
122
158
  get: (id: string) => Promise<ScheduleRecord | null>;
123
159
  /** All pending scheduled jobs (the DO's `/list` view). */
124
160
  list: () => Promise<ScheduleRecord[]>;
125
- runAfter: <F extends FunctionReference>(delayMs: number, function_: F, args: ArgsOf<F>, options?: RunOptions) => Promise<{
161
+ /**
162
+ * Schedule `target` to run once, `delayMs` from now. `target` is a function
163
+ * {@link FunctionReference} (dispatched as a one-shot) or a durable
164
+ * {@link WorkflowReference} — the generated `workflows.<name>` /
165
+ * `agents.<name>` ref — which starts a fresh instance on fire (args become
166
+ * its `params`). {@link ScheduleTargetArgs} infers the accepted args from
167
+ * whichever target was passed.
168
+ */
169
+ runAfter: <T extends CronTarget>(delayMs: number, target: T, args: ScheduleTargetArgs<T>, options?: RunOptions) => Promise<{
126
170
  id: string;
127
171
  scheduledFor: number;
128
172
  }>;
129
- runAt: <F extends FunctionReference>(date: Date | number, function_: F, args: ArgsOf<F>, options?: RunOptions) => Promise<{
173
+ /** Like {@link Scheduler.runAfter} but fires at an absolute `date`/timestamp. */
174
+ runAt: <T extends CronTarget>(date: Date | number, target: T, args: ScheduleTargetArgs<T>, options?: RunOptions) => Promise<{
130
175
  id: string;
131
176
  scheduledFor: number;
132
177
  }>;
133
178
  }
134
179
  /**
135
- * Cloudflare Durable Object data-residency jurisdiction. Widening union —
136
- * Cloudflare adds values over time.
137
- * @see https://developers.cloudflare.com/durable-objects/reference/data-location/
138
- */
180
+ * Cloudflare Durable Object data-residency jurisdiction. Widening union —
181
+ * Cloudflare adds values over time.
182
+ * @see https://developers.cloudflare.com/durable-objects/reference/data-location/
183
+ */
139
184
  type DurableObjectJurisdiction = "eu" | "fedramp" | "us";
140
185
  /** Subset of `DurableObjectNamespace` the package consumes. */
141
186
  interface DurableObjectNamespaceLike {
142
187
  get: (id: DurableObjectIdLike) => DurableObjectStubLike;
143
188
  idFromName: (name: string) => DurableObjectIdLike;
144
189
  /**
145
- * Derive a jurisdiction-restricted subnamespace. Optional because older
146
- * workers-types releases (and test doubles) may not expose it.
147
- */
190
+ * Derive a jurisdiction-restricted subnamespace. Optional because older
191
+ * workers-types releases (and test doubles) may not expose it.
192
+ */
148
193
  jurisdiction?: (jurisdiction: DurableObjectJurisdiction) => DurableObjectNamespaceLike;
149
194
  }
150
195
  interface DurableObjectIdLike {
@@ -157,18 +202,18 @@ interface LunoraSchedulerOptions {
157
202
  /** Optional named instance — useful for tenant isolation. Default `default`. */
158
203
  instanceName?: string;
159
204
  /**
160
- * Pin the SchedulerDO (durable timers + cron state) to a Cloudflare
161
- * data-residency jurisdiction. Pass the same value as the worker's
162
- * `jurisdiction` so scheduled state co-resides with app data. Omit for the
163
- * un-pinned global namespace.
164
- */
205
+ * Pin the SchedulerDO (durable timers + cron state) to a Cloudflare
206
+ * data-residency jurisdiction. Pass the same value as the worker's
207
+ * `jurisdiction` so scheduled state co-resides with app data. Omit for the
208
+ * un-pinned global namespace.
209
+ */
165
210
  jurisdiction?: DurableObjectJurisdiction;
166
211
  /** Binding to the `SchedulerDO` durable object namespace. */
167
212
  namespace: DurableObjectNamespaceLike;
168
213
  /**
169
- * Origin where the Worker is mounted. SchedulerDO uses this base URL when
170
- * dispatching scheduled functions back to the Worker on alarm fire.
171
- */
214
+ * Origin where the Worker is mounted. SchedulerDO uses this base URL when
215
+ * dispatching scheduled functions back to the Worker on alarm fire.
216
+ */
172
217
  originUrl: string;
173
218
  }
174
219
  /** Per-enqueue options for a {@link Workpool}. Extends {@link RunOptions} minus the implicit `pool` (the pool sets that). */
@@ -181,46 +226,46 @@ interface EnqueueOptions {
181
226
  shardKey?: string;
182
227
  }
183
228
  /**
184
- * Options for `createWorkpool`. Mirrors {@link LunoraSchedulerOptions}
185
- * (same `namespace` / `originUrl` / `instanceName`) plus the bounded-concurrency
186
- * controls. A workpool is a NAMED logical pool inside the existing SchedulerDO —
187
- * it needs no extra Durable Object or wrangler binding beyond the SchedulerDO
188
- * the scheduler already uses.
189
- */
229
+ * Options for `createWorkpool`. Mirrors {@link LunoraSchedulerOptions}
230
+ * (same `namespace` / `originUrl` / `instanceName`) plus the bounded-concurrency
231
+ * controls. A workpool is a NAMED logical pool inside the existing SchedulerDO —
232
+ * it needs no extra Durable Object or wrangler binding beyond the SchedulerDO
233
+ * the scheduler already uses.
234
+ */
190
235
  interface WorkpoolOptions extends LunoraSchedulerOptions {
191
236
  /**
192
- * Maximum number of jobs from this pool that may be in flight at once.
193
- * Excess enqueues are persisted and drain as slots free. Must be a positive
194
- * integer.
195
- */
237
+ * Maximum number of jobs from this pool that may be in flight at once.
238
+ * Excess enqueues are persisted and drain as slots free. Must be a positive
239
+ * integer.
240
+ */
196
241
  maxConcurrency: number;
197
242
  /**
198
- * Pool name — the concurrency counter is keyed by this inside the
199
- * SchedulerDO storage (`pool:&lt;name>`). Default `default`.
200
- */
243
+ * Pool name — the concurrency counter is keyed by this inside the
244
+ * SchedulerDO storage (`pool:<name>`). Default `default`.
245
+ */
201
246
  name?: string;
202
247
  }
203
248
  /**
204
- * Bounded-concurrency action queue (Lunora equivalent of `@convex-dev/workpool`).
205
- * Built on the existing SchedulerDO: `enqueue` schedules a job tagged with this
206
- * pool's name; the DO caps simultaneous dispatch at `maxConcurrency` and queues
207
- * the rest durably.
208
- */
249
+ * Bounded-concurrency action queue (Lunora equivalent of `@convex-dev/workpool`).
250
+ * Built on the existing SchedulerDO: `enqueue` schedules a job tagged with this
251
+ * pool's name; the DO caps simultaneous dispatch at `maxConcurrency` and queues
252
+ * the rest durably.
253
+ */
209
254
  interface Workpool {
210
255
  /** Cancel a queued/in-flight pool job by id. */
211
256
  cancel: (id: string) => Promise<{
212
257
  cancelled: boolean;
213
258
  }>;
214
259
  /**
215
- * Enqueue `function_(args)` into the pool. Resolves with the durable job id
216
- * and the time it was scheduled for (it may not run immediately if the pool
217
- * is at capacity).
218
- */
260
+ * Enqueue `function_(args)` into the pool. Resolves with the durable job id
261
+ * and the time it was scheduled for (it may not run immediately if the pool
262
+ * is at capacity).
263
+ */
219
264
  enqueue: <F extends FunctionReference>(function_: F, args: ArgsOf<F>, options?: EnqueueOptions) => Promise<{
220
265
  id: string;
221
266
  scheduledFor: number;
222
267
  }>;
223
- /** The pool's name (the `pool:&lt;name>` storage key suffix). */
268
+ /** The pool's name (the `pool:<name>` storage key suffix). */
224
269
  readonly name: string;
225
270
  /** Inspect the pool's current state — `inFlight` slots used and the configured `maxConcurrency`. */
226
271
  status: () => Promise<{
@@ -289,11 +334,11 @@ interface QueueWorkpoolOptions {
289
334
  queue: QueueLike<QueueJob>;
290
335
  }
291
336
  /**
292
- * Queues-backed producer: enqueue function dispatches onto a Cloudflare Queue.
293
- * Concurrency, retries, and dead-lettering are configured on the queue consumer
294
- * in `wrangler.jsonc` (`max_concurrency` / `max_retries` / `dead_letter_queue`),
295
- * not here — that's the whole point of using Queues over the DO workpool.
296
- */
337
+ * Queues-backed producer: enqueue function dispatches onto a Cloudflare Queue.
338
+ * Concurrency, retries, and dead-lettering are configured on the queue consumer
339
+ * in `wrangler.jsonc` (`max_concurrency` / `max_retries` / `dead_letter_queue`),
340
+ * not here — that's the whole point of using Queues over the DO workpool.
341
+ */
297
342
  interface QueueWorkpool {
298
343
  /** Enqueue a single `fn(args)` dispatch. */
299
344
  enqueue: <F extends FunctionReference>(function_: F, args: ArgsOf<F>, options?: QueueEnqueueOptions) => Promise<void>;
@@ -304,8 +349,12 @@ interface QueueWorkpool {
304
349
  shardKey?: string;
305
350
  }>, options?: QueueSendOptionsLike) => Promise<void>;
306
351
  }
307
- /** Dispatches a single {@link QueueJob} — the consumer's per-message worker. */
308
- type QueueDispatch = (job: QueueJob) => Promise<void>;
352
+ /**
353
+ * Dispatches a single {@link QueueJob} — the consumer's per-message worker.
354
+ * `messageId` is the queue message's native id, threaded through so the
355
+ * dispatcher can attribute a failure to the exact message that caused it.
356
+ */
357
+ type QueueDispatch = (job: QueueJob, messageId?: string) => Promise<void>;
309
358
  /** Options for `createQueueConsumer`. */
310
359
  interface QueueConsumerOptions {
311
360
  /** How each job is executed; e.g. the `httpDispatcher`. */
@@ -319,43 +368,50 @@ interface HttpDispatcherOptions {
319
368
  fetchImpl?: typeof fetch;
320
369
  /** Origin where the Worker is mounted (the `/_lunora/scheduler/dispatch` endpoint). */
321
370
  originUrl: string;
371
+ /**
372
+ * Abort a job's dispatch after this many ms; the abort is retryable, so the
373
+ * consumer retries the message. Defaults to 5 minutes — raise it for a
374
+ * workpool running jobs that legitimately run longer, lower it to fail a
375
+ * stuck origin faster.
376
+ */
377
+ timeoutMs?: number;
322
378
  }
323
379
  /**
324
- * Client-side scheduler — forwards `runAfter` / `runAt` / `cancel` calls to a
325
- * `SchedulerDO` over HTTP. The DO owns the alarm and the storage; this is a
326
- * thin RPC wrapper.
327
- */
380
+ * Client-side scheduler — forwards `runAfter` / `runAt` / `cancel` calls to a
381
+ * `SchedulerDO` over HTTP. The DO owns the alarm and the storage; this is a
382
+ * thin RPC wrapper.
383
+ */
328
384
  declare const createScheduler: (options: LunoraSchedulerOptions) => Scheduler;
329
385
  /**
330
- * Bounded-concurrency action queue — the Lunora equivalent of
331
- * `@convex-dev/workpool`. Mirrors `createScheduler`'s `namespace` /
332
- * `originUrl` / `instanceName` options and is built on the SAME `SchedulerDO`:
333
- * a workpool is just a NAMED logical pool inside that DO (concurrency counter
334
- * keyed by {@link WorkpoolOptions.name} under the `pool:&lt;name>` storage key).
335
- * It needs no extra Durable Object or wrangler binding beyond the SchedulerDO
336
- * the scheduler already uses.
337
- *
338
- * `enqueue` schedules a job tagged with this pool; the DO dispatches at most
339
- * `maxConcurrency` of the pool's jobs at once and queues the rest durably,
340
- * draining them as the runtime reports completions (`POST /complete`).
341
- *
342
- * ```ts
343
- * const pool = createWorkpool({ namespace: env.SCHEDULER, originUrl, maxConcurrency: 5 });
344
- * await pool.enqueue(internal.stripe.sync, { invoiceId }, { retry: { maxAttempts: 3 } });
345
- * ```
346
- *
347
- * Why not Cloudflare Queues? Queues natively cover concurrency-capped, retried,
348
- * dead-lettered, delayed dispatch (`max_concurrency`, `max_retries`,
349
- * `retry({ delaySeconds })`, `dead_letter_queue`), and are the right tool when
350
- * you just want to rate-limit fire-and-forget background work. This workpool
351
- * deliberately stays on `SchedulerDO` because it offers what a queue can't: a
352
- * hard concurrency cap (the DO is the single serialization point — no
353
- * cross-consumer overshoot), per-job cancellation, and per-job status
354
- * introspection, all keyed by a stable job id. Reach for Queues when you don't
355
- * need those; reach for this when you do. Either way, do NOT grow multi-step
356
- * orchestration on top of this — that's Cloudflare **Workflows** (`step.do` /
357
- * `step.sleep` / `step.waitForEvent`).
358
- */
386
+ * Bounded-concurrency action queue — the Lunora equivalent of
387
+ * `@convex-dev/workpool`. Mirrors `createScheduler`'s `namespace` /
388
+ * `originUrl` / `instanceName` options and is built on the SAME `SchedulerDO`:
389
+ * a workpool is just a NAMED logical pool inside that DO (concurrency counter
390
+ * keyed by {@link WorkpoolOptions.name} under the `pool:<name>` storage key).
391
+ * It needs no extra Durable Object or wrangler binding beyond the SchedulerDO
392
+ * the scheduler already uses.
393
+ *
394
+ * `enqueue` schedules a job tagged with this pool; the DO dispatches at most
395
+ * `maxConcurrency` of the pool's jobs at once and queues the rest durably,
396
+ * draining them as the runtime reports completions (`POST /complete`).
397
+ *
398
+ * ```ts
399
+ * const pool = createWorkpool({ namespace: env.SCHEDULER, originUrl, maxConcurrency: 5 });
400
+ * await pool.enqueue(internal.stripe.sync, { invoiceId }, { retry: { maxAttempts: 3 } });
401
+ * ```
402
+ *
403
+ * Why not Cloudflare Queues? Queues natively cover concurrency-capped, retried,
404
+ * dead-lettered, delayed dispatch (`max_concurrency`, `max_retries`,
405
+ * `retry({ delaySeconds })`, `dead_letter_queue`), and are the right tool when
406
+ * you just want to rate-limit fire-and-forget background work. This workpool
407
+ * deliberately stays on `SchedulerDO` because it offers what a queue can't: a
408
+ * hard concurrency cap (the DO is the single serialization point — no
409
+ * cross-consumer overshoot), per-job cancellation, and per-job status
410
+ * introspection, all keyed by a stable job id. Reach for Queues when you don't
411
+ * need those; reach for this when you do. Either way, do NOT grow multi-step
412
+ * orchestration on top of this — that's Cloudflare **Workflows** (`step.do` /
413
+ * `step.sleep` / `step.waitForEvent`).
414
+ */
359
415
  declare const createWorkpool: (options: WorkpoolOptions) => Workpool;
360
416
  interface CronTriggerOptions {
361
417
  /** Args passed to the function. */
@@ -377,10 +433,10 @@ interface CronTriggerSnippet {
377
433
  wranglerJsonc: string;
378
434
  }
379
435
  /**
380
- * Produces the wrangler.jsonc fragment + dispatcher metadata for a recurring
381
- * function. The actual cron handler is mounted by `@lunora/runtime` — we only
382
- * emit the configuration here.
383
- */
436
+ * Produces the wrangler.jsonc fragment + dispatcher metadata for a recurring
437
+ * function. The actual cron handler is mounted by `@lunora/runtime` — we only
438
+ * emit the configuration here.
439
+ */
384
440
  declare const createCronTrigger: (options: CronTriggerOptions) => CronTriggerSnippet;
385
441
  /** Sub-day recurrence. Exactly one unit must be provided. */
386
442
  interface IntervalSchedule {
@@ -395,6 +451,19 @@ interface DailySchedule {
395
451
  /** 0–59. */
396
452
  minuteUTC: number;
397
453
  }
454
+ /**
455
+ * Hourly recurrence at a fixed minute past the hour.
456
+ *
457
+ * `crons.interval({ hours: 1 })` compiles to the same expression, but the
458
+ * asymmetry of having `daily`/`weekly`/`monthly` and no `hourly` is its own
459
+ * papercut — and unlike the interval form this one lets
460
+ * the caller place the job off the hour boundary, which is how you stop a
461
+ * dozen hourly jobs from stampeding at `:00`.
462
+ */
463
+ interface HourlySchedule {
464
+ /** 0–59. */
465
+ minuteUTC: number;
466
+ }
398
467
  /** Weekly recurrence at a fixed UTC time on a given weekday. */
399
468
  interface WeeklySchedule extends DailySchedule {
400
469
  /** Long weekday name, case-insensitive (e.g. `"monday"`). */
@@ -406,106 +475,125 @@ interface MonthlySchedule extends DailySchedule {
406
475
  day: number;
407
476
  }
408
477
  /**
409
- * One registered cron job, normalized to a compiled cron expression. Shared
410
- * verbatim with `@lunora/codegen` (which lifts the same fields out of the AST)
411
- * and the runtime dispatcher — keep the shape stable across all three.
412
- */
478
+ * One registered cron job, normalized to a compiled cron expression. Shared
479
+ * verbatim with `@lunora/codegen` (which lifts the same fields out of the AST)
480
+ * and the runtime dispatcher — keep the shape stable across all three.
481
+ */
413
482
  interface CronJob {
414
483
  /** Args forwarded to the function (or, for a workflow target, used as its `params`) on each fire. */
415
484
  args: Record<string, unknown>;
416
485
  /** Compiled standard cron expression, e.g. `"0 9 * * *"`. */
417
486
  cron: string;
418
487
  /**
419
- * `__lunoraRef` of the target function. Present for a function target;
420
- * absent when the job targets a workflow ({@link CronJob.workflow} instead).
421
- */
488
+ * `__lunoraRef` of the target function. Present for a function target;
489
+ * absent when the job targets a workflow ({@link CronJob.workflow} instead).
490
+ */
422
491
  functionPath?: string;
423
492
  /** Human-readable identifier — must be unique within one `cronJobs()`. */
424
493
  name: string;
425
494
  /**
426
- * Set when the job targets a durable workflow rather than a function: the
427
- * workflow's stable name (`defineWorkflow({ name })`) when one was declared,
428
- * otherwise `""`. `@lunora/codegen` statically resolves the concrete
429
- * `lunora/workflows.ts` export + its `WORKFLOW_*` binding for the emitted
430
- * dispatch map, so this authoring-time value is informational only.
431
- */
495
+ * Set when the job targets a durable workflow rather than a function: the
496
+ * workflow's stable name (`defineWorkflow({ name })`) when one was declared,
497
+ * otherwise `""`. `@lunora/codegen` statically resolves the concrete
498
+ * `lunora/workflows.ts` export + its `WORKFLOW_*` binding for the emitted
499
+ * dispatch map, so this authoring-time value is informational only.
500
+ */
432
501
  workflow?: string;
433
502
  }
434
503
  /** The ergonomic builder methods, excluding the raw `.cron` escape hatch. */
435
- type CronScheduleKind = "daily" | "interval" | "monthly" | "weekly";
504
+ type CronScheduleKind = "daily" | "hourly" | "interval" | "monthly" | "weekly";
436
505
  /** The ergonomic schedule kinds as a runtime set (codegen reads this to detect cron builder methods). */
437
506
  declare const CRON_SCHEDULE_KINDS: ReadonlySet<CronScheduleKind>;
438
507
  /**
439
- * Compile one of the ergonomic schedule forms into a standard cron expression.
440
- * Exposed as a pure function so `@lunora/codegen` can reuse the exact same
441
- * compilation when it statically lifts a `crons.{kind}(...)` call out of the
442
- * AST — codegen imports this directly (no duplicated mirror).
443
- */
444
- declare const compileCronSchedule: (kind: CronScheduleKind, schedule: DailySchedule | IntervalSchedule | MonthlySchedule | WeeklySchedule) => string;
508
+ * Compile one of the ergonomic schedule forms into a standard cron expression.
509
+ * Exposed as a pure function so `@lunora/codegen` can reuse the exact same
510
+ * compilation when it statically lifts a `crons.{kind}(...)` call out of the
511
+ * AST — codegen imports this directly (no duplicated mirror). `jobName` is
512
+ * optional (see {@link compileInterval}) so codegen's existing 2-arg call site
513
+ * keeps working unchanged.
514
+ */
515
+ declare const compileCronSchedule: (kind: CronScheduleKind, schedule: DailySchedule | HourlySchedule | IntervalSchedule | MonthlySchedule | WeeklySchedule, jobName?: string) => string;
445
516
  /**
446
- * Builder returned by {@link cronJobs}. Each method registers one recurring
447
- * job; the compiled expression is validated immediately so authoring mistakes
448
- * surface at definition time rather than at codegen.
449
- */
517
+ * Builder returned by {@link cronJobs}. Each method registers one recurring
518
+ * job; the compiled expression is validated immediately so authoring mistakes
519
+ * surface at definition time rather than at codegen.
520
+ */
450
521
  interface CronJobsBuilder {
451
522
  /**
452
- * Raw cron expression escape hatch (5- or 6-field, full cron-parser grammar).
453
- * The target may be a function (`internal.file.fn`) or a durable workflow
454
- * (`workflows.&lt;name>`); a workflow's `args` are inferred from its `params`.
455
- */
523
+ * Raw cron expression escape hatch (5- or 6-field, full cron-parser grammar).
524
+ * The target may be a function (`internal.file.fn`) or a durable workflow
525
+ * (`workflows.<name>`); a workflow's `args` are inferred from its `params`.
526
+ */
456
527
  cron: <T extends CronTarget>(name: string, cronExpr: string, target: T, args?: CronTargetArgs<T>) => CronJobsBuilder;
457
- /** Daily at `hourUTC:minuteUTC` (UTC). The target may be a function or a durable workflow (`workflows.&lt;name>`). */
528
+ /** Daily at `hourUTC:minuteUTC` (UTC). The target may be a function or a durable workflow (`workflows.<name>`). */
458
529
  daily: <T extends CronTarget>(name: string, schedule: DailySchedule, target: T, args?: CronTargetArgs<T>) => CronJobsBuilder;
459
- /** Every `{ seconds | minutes | hours }`. The target may be a function or a durable workflow (`workflows.&lt;name>`). */
530
+ /** Hourly at `minuteUTC` past the hour. The target may be a function or a durable workflow (`workflows.<name>`). */
531
+ hourly: <T extends CronTarget>(name: string, schedule: HourlySchedule, target: T, args?: CronTargetArgs<T>) => CronJobsBuilder;
532
+ /** Every `{ seconds | minutes | hours }`. The target may be a function or a durable workflow (`workflows.<name>`). */
460
533
  interval: <T extends CronTarget>(name: string, schedule: IntervalSchedule, target: T, args?: CronTargetArgs<T>) => CronJobsBuilder;
461
534
  /** Snapshot of the registered jobs, in declaration order. */
462
535
  jobs: () => ReadonlyArray<CronJob>;
463
- /** Monthly on `day` at `hourUTC:minuteUTC` (UTC). The target may be a function or a durable workflow (`workflows.&lt;name>`). */
536
+ /** Monthly on `day` at `hourUTC:minuteUTC` (UTC). The target may be a function or a durable workflow (`workflows.<name>`). */
464
537
  monthly: <T extends CronTarget>(name: string, schedule: MonthlySchedule, target: T, args?: CronTargetArgs<T>) => CronJobsBuilder;
465
- /** Weekly on `dayOfWeek` at `hourUTC:minuteUTC` (UTC). The target may be a function or a durable workflow (`workflows.&lt;name>`). */
538
+ /** Weekly on `dayOfWeek` at `hourUTC:minuteUTC` (UTC). The target may be a function or a durable workflow (`workflows.<name>`). */
466
539
  weekly: <T extends CronTarget>(name: string, schedule: WeeklySchedule, target: T, args?: CronTargetArgs<T>) => CronJobsBuilder;
467
540
  }
468
541
  /**
469
- * Create a code-first cron registry. The returned builder is chainable;
470
- * codegen discovers a `lunora/crons.ts` default export by AST, not a runtime
471
- * brand.
472
- */
542
+ * Create a code-first cron registry. The returned builder is chainable;
543
+ * codegen discovers a `lunora/crons.ts` default export by AST, not a runtime
544
+ * brand.
545
+ */
473
546
  declare const cronJobs: () => CronJobsBuilder;
474
547
  /**
475
- * Build a Queues producer that enqueues Lunora function dispatches. Concurrency
476
- * and retry policy live on the consumer's `wrangler.jsonc` config, not here.
477
- */
548
+ * Build a Queues producer that enqueues Lunora function dispatches. Concurrency
549
+ * and retry policy live on the consumer's `wrangler.jsonc` config, not here.
550
+ */
478
551
  declare const createQueueWorkpool: (options: QueueWorkpoolOptions) => QueueWorkpool;
479
552
  /**
480
- * Wrap a {@link QueueDispatch} into a Cloudflare `queue()` consumer handler.
481
- *
482
- * Each message is dispatched independently (concurrently across the batch). On
483
- * success the message is `ack()`-ed; on any failure — a thrown dispatcher or a
484
- * structurally-invalid body — it is `retry()`-ed, so Queues' own `max_retries`
485
- * + `dead_letter_queue` settings decide when to give up. Nothing is silently
486
- * dropped: a permanently-bad message rides retries into the dead-letter queue
487
- * where you can inspect it.
488
- */
553
+ * Wrap a {@link QueueDispatch} into a Cloudflare `queue()` consumer handler.
554
+ *
555
+ * Each message is dispatched independently (concurrently across the batch). On
556
+ * success the message is `ack()`-ed; on any failure — a thrown dispatcher or a
557
+ * structurally-invalid body — it is `retry()`-ed, so Queues' own `max_retries`
558
+ * + `dead_letter_queue` settings decide when to give up. Nothing is silently
559
+ * dropped: a permanently-bad message rides retries into the dead-letter queue
560
+ * where you can inspect it.
561
+ */
489
562
  declare const createQueueConsumer: (options: QueueConsumerOptions) => ((batch: MessageBatchLike) => Promise<void>);
490
563
  /**
491
- * Default {@link QueueDispatch}: POST each job to the Worker's
492
- * `/_lunora/scheduler/dispatch` endpoint (the same path SchedulerDO dispatches
493
- * through), authenticated with the admin bearer. A non-2xx response throws so
494
- * the consumer retries the message.
495
- */
564
+ * Default {@link QueueDispatch}: dispatch each job to the Worker's
565
+ * `/_lunora/scheduler/dispatch` endpoint (the same path SchedulerDO dispatches
566
+ * through) via `@lunora/dispatch`'s `createDispatchRunner` — which bounds each
567
+ * job with {@link HttpDispatcherOptions.timeoutMs} (default
568
+ * {@link DEFAULT_JOB_TIMEOUT_MS}), so a hung origin no longer holds the whole
569
+ * `queue()` invocation open, and threads the queue message id through for
570
+ * failure attribution. Any dispatch failure throws so the consumer retries the
571
+ * message — including a 2xx carrying a non-empty non-JSON body, which is an
572
+ * intermediary's page rather than a function's return value and therefore no
573
+ * evidence the job ran. An empty 2xx is a normal success (a `void` function).
574
+ */
496
575
  declare const httpDispatcher: (options: HttpDispatcherOptions) => QueueDispatch;
497
576
  /**
498
- * Minimal projection of `DurableObjectState` for the SchedulerDO. Declared
499
- * structurally so unit tests can pass a fake state without booting the
500
- * workers runtime. The WebSocket methods are optional: they back the live
501
- * `/ws` subscription (push the job list on every change) and are absent in the
502
- * storage-only fakes, in which case the DO simply serves no live sockets.
503
- */
577
+ * Minimal projection of `DurableObjectState` for the SchedulerDO. Declared
578
+ * structurally so unit tests can pass a fake state without booting the
579
+ * workers runtime. The WebSocket methods are optional: they back the live
580
+ * `/ws` subscription (push the job list on every change) and are absent in the
581
+ * storage-only fakes, in which case the DO simply serves no live sockets.
582
+ */
504
583
  interface SchedulerDOState {
505
584
  /** Accept a hibernatable server WebSocket (workers `state.acceptWebSocket`). */
506
585
  acceptWebSocket?: (ws: WebSocket) => void;
507
586
  /** Every accepted server WebSocket (workers `state.getWebSockets`). */
508
587
  getWebSockets?: () => WebSocket[];
588
+ /**
589
+ * Register a constant ping/pong auto-response so the runtime answers a
590
+ * known keepalive frame on a hibernated socket WITHOUT waking this DO (no
591
+ * billable request, no dispatch). Optional: absent in the unit harness and
592
+ * older runtimes, present on the real `DurableObjectState`. Mirrors
593
+ * `@lunora/do`'s `ShardDOState.setWebSocketAutoResponse` — see
594
+ * {@link SchedulerDO.armWebSocketKeepalive}.
595
+ */
596
+ setWebSocketAutoResponse?: (pair: WebSocketRequestResponsePair) => void;
509
597
  storage: {
510
598
  delete: (key: string | string[]) => Promise<number | boolean>;
511
599
  deleteAlarm: () => Promise<void> | void;
@@ -515,6 +603,7 @@ interface SchedulerDOState {
515
603
  end?: string;
516
604
  limit?: number;
517
605
  prefix?: string;
606
+ startAfter?: string;
518
607
  }) => Promise<Map<string, T>>;
519
608
  put: <T = unknown>(entries: Record<string, T> | string, value?: T) => Promise<void>;
520
609
  setAlarm: (scheduledTime: number | Date) => Promise<void> | void;
@@ -523,100 +612,122 @@ interface SchedulerDOState {
523
612
  interface SchedulerEnv {
524
613
  [key: string]: unknown;
525
614
  /**
526
- * Fallback bearer token attached to the dispatch when
527
- * {@link SchedulerEnv.LUNORA_SCHEDULER_SECRET} is not configured. Sent as
528
- * `authorization: Bearer &lt;token>`.
529
- */
615
+ * Fallback bearer token attached to the dispatch when
616
+ * {@link SchedulerEnv.LUNORA_SCHEDULER_SECRET} is not configured. Sent as
617
+ * `authorization: Bearer <token>`.
618
+ */
530
619
  LUNORA_ADMIN_TOKEN?: string;
531
620
  /**
532
- * Base URL where the Worker is mounted. SchedulerDO uses this at dispatch
533
- * time to call back into the Worker. Read at fire time (NOT taken from the
534
- * request body) to prevent SSRF via a forged `originUrl` field.
535
- */
621
+ * Base URL where the Worker is mounted. SchedulerDO uses this at dispatch
622
+ * time to call back into the Worker. Read at fire time (NOT taken from the
623
+ * request body) to prevent SSRF via a forged `originUrl` field.
624
+ */
536
625
  LUNORA_ORIGIN_URL?: string;
537
626
  /**
538
- * Shared secret used to HMAC-sign the dispatch body so the runtime receiver
539
- * can authenticate the call (header `x-lunora-scheduler-signature`). Without
540
- * it the dispatch is sent unsigned (optionally bearer-authenticated via
541
- * {@link SchedulerEnv.LUNORA_ADMIN_TOKEN}).
542
- */
627
+ * Shared secret used to HMAC-sign the dispatch body so the runtime receiver
628
+ * can authenticate the call (header `x-lunora-scheduler-signature`). Without
629
+ * it the dispatch is sent unsigned (optionally bearer-authenticated via
630
+ * {@link SchedulerEnv.LUNORA_ADMIN_TOKEN}).
631
+ */
543
632
  LUNORA_SCHEDULER_SECRET?: string;
544
633
  }
545
634
  /**
546
- * One pool's live backlog, as surfaced by `GET /status`. `inFlight`/
547
- * `maxConcurrency` mirror the durable {@link PoolState} semaphore; `queued`
548
- * is the number of pending (not-yet-dispatched) jobs routed to this pool.
549
- */
635
+ * Retries allowed after the original attempt before {@link SchedulerDO.recordRetry}
636
+ * parks a record in the dead-letter (`dead:`) prefix. Overridable per job via
637
+ * {@link RetryPolicy.maxAttempts}. Exported so test doubles of the scheduler
638
+ * (`@lunora/testing`'s fake scheduler) model the same budget instead of
639
+ * duplicating the number.
640
+ */
641
+ declare const MAX_RETRY_ATTEMPTS = 5;
642
+ /**
643
+ * Backoff before the first retry, in milliseconds; doubles on each subsequent
644
+ * retry under the default `"exponential"` backoff (`baseMs * 2 ** (attempts - 1)`).
645
+ * Overridable per job via {@link RetryPolicy.baseMs}. Exported alongside
646
+ * {@link MAX_RETRY_ATTEMPTS} for the same reason.
647
+ */
648
+ declare const RETRY_BASE_DELAY_MS = 3e4;
649
+ /**
650
+ * One pool's live backlog, as surfaced by `GET /status`. `inFlight`/
651
+ * `maxConcurrency` mirror the durable {@link PoolState} semaphore; `queued`
652
+ * is the number of pending (not-yet-dispatched) jobs routed to this pool.
653
+ */
550
654
  interface SchedulerPoolStatus {
551
655
  /** Jobs currently dispatched-but-not-yet-completed (the held slots). */
552
656
  inFlight: number;
553
657
  /** The pool's concurrency cap. */
554
658
  maxConcurrency: number;
555
- /** The logical workpool name (the `pool:&lt;name>` suffix). */
659
+ /** The logical workpool name (the `pool:<name>` suffix). */
556
660
  name: string;
557
661
  /** Pending jobs routed to this pool but not yet dispatched. */
558
662
  queued: number;
559
663
  }
560
664
  /**
561
- * App-level scheduler backlog, as returned by `GET /status`. `pools` carries
562
- * the per-pool breakdown; `backlog` and `inFlight` are the app-wide sums of
563
- * `queued` and `inFlight` across every pool — the SLO view's headline numbers.
564
- */
665
+ * App-level scheduler backlog, as returned by `GET /status`. `pools` carries
666
+ * the per-pool breakdown; `backlog` and `inFlight` are the app-wide sums of
667
+ * `queued` and `inFlight` across every pool — the SLO view's headline numbers.
668
+ */
565
669
  interface SchedulerStatus {
566
670
  /** Sum of every pool's `queued` count — the total pending backlog. */
567
671
  backlog: number;
568
672
  /** Sum of every pool's `inFlight` count — the total held concurrency slots. */
569
673
  inFlight: number;
570
- /** Per-pool backlog breakdown, one entry per `pool:&lt;name>` record. */
674
+ /** Per-pool backlog breakdown, one entry per `pool:<name>` record. */
571
675
  pools: SchedulerPoolStatus[];
572
676
  }
573
677
  /**
574
- * Durable Object that stores pending scheduled invocations sorted by their
575
- * `scheduledFor` time and fires them via HTTP on alarm. Storage layout:
576
- * `id:&lt;id>` maps to {@link ScheduleRecord}; `t:&lt;paddedTime>:&lt;id>` maps to the
577
- * id (used as a sorted index).
578
- *
579
- * On every mutation the DO recomputes the earliest pending task and updates
580
- * the alarm via `state.storage.setAlarm(time)`.
581
- */
678
+ * Durable Object that stores pending scheduled invocations sorted by their
679
+ * `scheduledFor` time and fires them via HTTP on alarm. Storage layout:
680
+ * `id:<id>` maps to {@link ScheduleRecord}; `t:<paddedTime>:<id>` maps to the
681
+ * id (used as a sorted index).
682
+ *
683
+ * On every mutation the DO recomputes the earliest pending task and updates
684
+ * the alarm via `state.storage.setAlarm(time)`.
685
+ */
582
686
  declare class SchedulerDO {
583
687
  private static indexKey;
584
688
  private static json;
585
689
  private static error;
586
690
  /**
587
- * Resolve the effective retry parameters for a record: its per-job
588
- * {@link RetryPolicy} merged over the DO's built-in defaults. Callers that
589
- * never set `record.retry` get today's behaviour verbatim
590
- * (`maxAttempts: 5`, exponential, `baseMs: 30_000`, no ceiling).
591
- */
691
+ * Resolve the effective retry parameters for a record: its per-job
692
+ * {@link RetryPolicy} merged over the DO's built-in defaults. Callers that
693
+ * never set `record.retry` get today's behaviour verbatim
694
+ * (`maxAttempts: 5`, exponential, `baseMs: 30_000`, no ceiling).
695
+ */
592
696
  private static resolveRetry;
593
697
  /** Clamp an untrusted `maxConcurrency` to a positive integer, else fall back. */
594
698
  private static normalizeConcurrency;
595
699
  /**
596
- * Sanitize an untrusted retry policy from the wire into a `RetryPolicy` (or
597
- * `undefined` when nothing valid was provided). Keeps obviously-bad values
598
- * out of storage so {@link SchedulerDO.resolveRetry} never has to re-guard.
599
- * @returns The normalized policy, or `undefined` if no valid policy was found.
600
- */
700
+ * Sanitize an untrusted retry policy from the wire into a `RetryPolicy` (or
701
+ * `undefined` when nothing valid was provided). Keeps obviously-bad values
702
+ * out of storage so {@link SchedulerDO.resolveRetry} never has to re-guard.
703
+ * @returns The normalized policy, or `undefined` if no valid policy was found.
704
+ */
601
705
  private static normalizeRetry;
602
706
  /**
603
- * Idempotently release the slot held by `jobId`, returning the updated
604
- * {@link PoolState} (pure — the caller persists it). A duplicate release for
605
- * an id that no longer holds a slot is a no-op, so an at-least-once
606
- * `/complete` (or a complete racing a failed-kick release) can never push
607
- * `inFlight` below the true number of running jobs and oversubscribe the
608
- * pool. Pools persisted before `inFlightIds` existed fall back to a clamped
609
- * counter decrement.
610
- */
707
+ * Idempotently release the slot held by `jobId`, returning the updated
708
+ * {@link PoolState} (pure — the caller persists it). A duplicate release for
709
+ * an id that no longer holds a slot is a no-op, so an at-least-once
710
+ * `/complete` (or a complete racing a failed-kick release) can never push
711
+ * `inFlight` below the true number of running jobs and oversubscribe the
712
+ * pool. Pools persisted before `inFlightIds` existed fall back to a clamped
713
+ * counter decrement.
714
+ */
611
715
  private static releaseSlot;
612
716
  /**
613
- * Best-effort release with no job id (legacy `/complete` payloads). Drops one
614
- * tracked id if the set exists, else clamps the counter. Less precise than
615
- * {@link SchedulerDO.releaseSlot} — a duplicate id-less complete CAN
616
- * over-release — but every current client sends the id, so this is the
617
- * compatibility shim, not the hot path.
618
- */
717
+ * Best-effort release with no job id (legacy `/complete` payloads). Drops one
718
+ * tracked id if the set exists, else clamps the counter. Less precise than
719
+ * {@link SchedulerDO.releaseSlot} — a duplicate id-less complete CAN
720
+ * over-release — but every current client sends the id, so this is the
721
+ * compatibility shim, not the hot path.
722
+ */
619
723
  private static releaseFirstSlot;
724
+ /**
725
+ * Normalize the mutually-exclusive dispatch target off an untrusted body: a
726
+ * one-shot function path (`functionPath`) or a durable workflow/agent
727
+ * instance (`workflow`, a `WORKFLOW_*`/`AGENT_*` binding). Returns `undefined`
728
+ * when neither is present so the caller can reject the schedule.
729
+ */
730
+ private static resolveScheduleTarget;
620
731
  protected readonly state: SchedulerDOState;
621
732
  protected readonly env: SchedulerEnv;
622
733
  constructor(state: SchedulerDOState, env: SchedulerEnv);
@@ -624,180 +735,267 @@ declare class SchedulerDO {
624
735
  /** Called by the Workers runtime when the alarm previously set by `_rescheduleAlarm()` fires. */
625
736
  alarm(): Promise<void>;
626
737
  /**
627
- * Internal dispatch hook; overridden in unit tests to capture the outgoing
628
- * request. Returns `true` ONLY on an explicit 2xx response (`response.ok`).
629
- * Anything else — a network failure, a 5xx, OR a non-2xx such as 404
630
- * (receiver route not mounted) / 401 / 403 / 4xx — returns `false` and
631
- * enters the retry pipeline via {@link recordRetry}. Treating 4xx as
632
- * success used to permanently delete the job; since the receiver may simply
633
- * be missing (404) or transiently failing, we retry rather than silently
634
- * drop. After {@link MAX_RETRY_ATTEMPTS} the record is parked under a
635
- * `dead:` key for inspection — never silently deleted.
636
- *
637
- * The dispatch target is taken from `env.LUNORA_ORIGIN_URL` (NOT from the
638
- * stored record) to prevent SSRF via a forged `originUrl` on the schedule
639
- * request. If that env var is missing at fire time (a deploy/binding
640
- * regression — schedule time already enforced its presence) we return
641
- * `false` so the record is retried rather than silently dropped.
642
- */
738
+ * Internal dispatch hook; overridden in unit tests to capture the outgoing
739
+ * request. Returns `true` ONLY on an explicit 2xx response (`response.ok`).
740
+ * Anything else — a network failure, a 5xx, OR a non-2xx such as 404
741
+ * (receiver route not mounted) / 401 / 403 / 4xx — returns `false` and
742
+ * enters the retry pipeline via {@link recordRetry}. Treating 4xx as
743
+ * success used to permanently delete the job; since the receiver may simply
744
+ * be missing (404) or transiently failing, we retry rather than silently
745
+ * drop. After {@link MAX_RETRY_ATTEMPTS} the record is parked under a
746
+ * `dead:` key for inspection — never silently deleted.
747
+ *
748
+ * The dispatch target is taken from `env.LUNORA_ORIGIN_URL` (NOT from the
749
+ * stored record) to prevent SSRF via a forged `originUrl` on the schedule
750
+ * request. If that env var is missing at fire time (a deploy/binding
751
+ * regression — schedule time already enforced its presence) we return
752
+ * `false` so the record is retried rather than silently dropped.
753
+ */
643
754
  protected dispatch(record: ScheduleRecord): Promise<boolean>;
644
755
  /**
645
- * Claim + drain one due record with per-record fault isolation, so a storage
646
- * throw can never abort the whole alarm pass (which would skip the remaining
647
- * due records and the `rescheduleAlarm()` that re-arms the clock).
648
- *
649
- * Claims the job by deleting its time-index entry BEFORE dispatch (an alarm
650
- * re-fire then won't pick it up again), runs {@link drainRecord}, and on a
651
- * thrown storage op decides whether the job stays re-fireable.
652
- *
653
- * When the record was NOT successfully dispatched, re-assert the time-index
654
- * claim so a later alarm re-attempts it (at-least-once): the claim delete may
655
- * have removed it and recordRetry()/requeuePooled() may not have re-armed it
656
- * before throwing, and re-inserting the same key is idempotent, so a
657
- * surviving claim is simply rewritten to its prior value. When the record WAS
658
- * dispatched (the throw came from post-dispatch cleanup), leave the index
659
- * deleted so the already-kicked, idempotent job is not re-fired.
660
- */
756
+ * Register the hibernation-safe ping/pong keepalive. The runtime answers a
757
+ * {@link WS_KEEPALIVE_PING} text frame with {@link WS_KEEPALIVE_PONG}
758
+ * WITHOUT waking this Durable Object, keeping an idle `/ws` subscription
759
+ * alive across hibernation with no billable wakeup and no dispatch. Without
760
+ * this, a client's heartbeat ping goes unanswered and its watchdog force-
761
+ * closes the socket every ~90s, defeating hibernation (each unanswered ping
762
+ * wakes the DO to reconnect) — mirrors `@lunora/do`'s
763
+ * `ShardDO.armWebSocketKeepalive`. The auto-response is per-instance, so
764
+ * this re-runs on every construction (including a post-hibernation wake).
765
+ * Guarded: the API and the `WebSocketRequestResponsePair` global are absent
766
+ * in the unit harness and on older runtimes, where it degrades to a no-op.
767
+ */
768
+ private armWebSocketKeepalive;
769
+ /**
770
+ * Claim + drain one due record with per-record fault isolation, so a storage
771
+ * throw can never abort the whole alarm pass (which would skip the remaining
772
+ * due records and the `rescheduleAlarm()` that re-arms the clock).
773
+ *
774
+ * Claims the job by deleting its time-index entry BEFORE dispatch (an alarm
775
+ * re-fire then won't pick it up again), runs {@link drainRecord}, and on a
776
+ * thrown storage op re-asserts the claim so the job stays re-fireable.
777
+ *
778
+ * A throw reaching here always means the job was NOT dispatched:
779
+ * {@link drainRecord} swallows its own post-dispatch cleanup errors and
780
+ * returns instead of throwing once a kick succeeds, so every escaping throw
781
+ * comes from the pre-dispatch or failed-dispatch paths. We therefore always
782
+ * re-assert the time-index claim so a later alarm re-attempts it
783
+ * (at-least-once): the claim delete may have removed it and
784
+ * recordRetry()/requeuePooled() may not have re-armed it before throwing, and
785
+ * re-inserting the same key is idempotent, so a surviving claim is simply
786
+ * rewritten to its prior value.
787
+ */
661
788
  private drainRecordGuarded;
662
789
  /**
663
- * Process one due (already index-claimed) record within an alarm drain:
664
- * apply the workpool concurrency gate, dispatch, and settle the result.
665
- * A saturated pool re-arms the job (backpressure, no attempt charged); a
666
- * free slot is reserved durably before dispatch and released immediately if
667
- * the kick fails (success holds it until the runtime reports completion).
668
- * Success clears the `id:`/`retry:` rows; failure routes to
669
- * {@link recordRetry}. `pools` caches each pool's {@link PoolState} for the
670
- * lifetime of the drain so the budget decrements without re-reading storage.
671
- * @returns `true` only when the record was successfully dispatched (a 2xx
672
- * kick). The caller ({@link drainRecordGuarded}) uses this in its per-record
673
- * error guard: a record that returns `true` (or whose post-dispatch cleanup
674
- * later throws) must NOT have its time-index claim restored, since re-firing
675
- * an already-kicked job would break idempotency. A `false` return (pool
676
- * backpressure or a failed dispatch) means the job is still re-fireable —
677
- * either already re-armed here, or, if a throw escapes, re-claimed by the
678
- * guard's catch.
679
- */
790
+ * Process one due (already index-claimed) record within an alarm drain:
791
+ * apply the workpool concurrency gate, dispatch, and settle the result.
792
+ * A saturated pool re-arms the job (backpressure, no attempt charged); a
793
+ * free slot is reserved durably before dispatch and released immediately if
794
+ * the kick fails (success holds it until the runtime reports completion).
795
+ * Success clears the `id:`/`retry:` rows; failure routes to
796
+ * {@link recordRetry}. Pool state is read FRESH from storage per record (see
797
+ * {@link reservePoolSlot}) and never held across the dispatch() await, so a
798
+ * concurrent /complete landing mid-dispatch can't be clobbered.
799
+ * Once a kick succeeds, post-dispatch cleanup (clearing the `id:`/`retry:`
800
+ * rows) is swallowed rather than allowed to throw, so a successful dispatch
801
+ * NEVER propagates an error to {@link drainRecordGuarded}: every throw that
802
+ * escapes comes from the pre-dispatch or failed-dispatch paths, where the job
803
+ * is still re-fireable and the guard safely re-claims the time index.
804
+ * @returns `true` only when the record was successfully dispatched (a 2xx
805
+ * kick); `false` on pool backpressure or a failed dispatch (the job is still
806
+ * re-fireable — already re-armed here). The value is informational (the guard
807
+ * branches on throw/no-throw, not on this boolean).
808
+ */
680
809
  private drainRecord;
681
810
  /**
682
- * Concurrency gate for a pooled record. Returns `false` (and re-arms the
683
- * job via {@link requeuePooled}) when the pool is at `maxConcurrency`;
684
- * otherwise reserves a slot durably and returns `true`. Non-pooled records
685
- * always return `true` without touching any pool state.
686
- */
811
+ * Concurrency gate for a pooled record. Returns `false` (and re-arms the
812
+ * job via {@link requeuePooled}) when the pool is at `maxConcurrency`;
813
+ * otherwise reserves a slot durably and returns `true`. Non-pooled records
814
+ * always return `true` without touching any pool state.
815
+ *
816
+ * The pool row is read FRESH from storage on every call — never cached
817
+ * across the drain. Each reservation durably `savePool()`s before the next
818
+ * record runs, so a same-pass reservation is still visible to the next
819
+ * record's fresh read (the budget carries forward); and because dispatch()
820
+ * awaits an outbound fetch between records, a concurrent /complete that
821
+ * decrements the row mid-drain IS reflected here instead of being clobbered
822
+ * by a stale in-memory copy (which would leak a slot permanently).
823
+ */
687
824
  private reservePoolSlot;
688
825
  /**
689
- * Accept a hibernatable live subscription to the job list. The scheduler has
690
- * exactly one subscription shape (the whole list), so there's no per-socket
691
- * registry or dependency tracking — every accepted socket gets the full list
692
- * on connect and on every change. The worker is responsible for gating the
693
- * upgrade behind the admin token before it reaches here.
694
- */
826
+ * Accept a hibernatable live subscription to the job list. The scheduler has
827
+ * exactly one subscription shape (the whole list), so there's no per-socket
828
+ * registry or dependency tracking — every accepted socket gets the full list
829
+ * on connect and on every change. The worker is responsible for gating the
830
+ * upgrade behind the admin token before it reaches here.
831
+ */
695
832
  private handleWebSocketUpgrade;
696
833
  /**
697
- * Re-list the jobs and push them to every connected subscriber. Called after
698
- * any change (schedule / cancel / alarm-fire) so live studios reflect it
699
- * immediately. A no-op when the runtime doesn't support hibernated sockets.
700
- */
834
+ * Re-list the jobs (bounded — see {@link listRecords}) and push them to
835
+ * every connected subscriber. Called after any change (schedule / cancel /
836
+ * alarm-fire) so live studios reflect it immediately. A no-op when the
837
+ * runtime doesn't support hibernated sockets.
838
+ */
701
839
  private broadcastChange;
702
- /** The current pending job records (shared by `/list` and the live channel). */
840
+ /**
841
+ * The current pending job records (shared by `/list` and the live channel),
842
+ * bounded to `limit` (default {@link DEFAULT_LIST_LIMIT}) so a large backlog
843
+ * can't be JSON-serialized and fanned out to every socket in one shot. Lists
844
+ * `limit + 1` and slices back down so `truncated` reflects whether there was
845
+ * a next row, without a second round-trip.
846
+ */
703
847
  private listRecords;
704
848
  /**
705
- * HMAC-SHA-256 sign the dispatch body with `env.LUNORA_SCHEDULER_SECRET`,
706
- * returning a base64url signature, or `undefined` when no secret is
707
- * configured. Mirrors `@lunora/storage`'s signed-URL HMAC pattern (WebCrypto
708
- * `crypto.subtle`, available in workerd).
709
- */
849
+ * Page through every `id:` header exactly once with bounded per-page memory
850
+ * (a `limit`+`startAfter` cursor loop), invoking `visit` for each record.
851
+ * Unlike {@link listRecords}, which intentionally truncates for the studio's
852
+ * live view, `/status` and `/pool` need EXACT counts — this walks the full
853
+ * set, but never materializes more than one page at a time.
854
+ */
855
+ private countHeaders;
856
+ /**
857
+ * HMAC-SHA-256 sign the dispatch body with `env.LUNORA_SCHEDULER_SECRET`,
858
+ * returning a base64url signature, or `undefined` when no secret is
859
+ * configured. Mirrors `@lunora/storage`'s signed-URL HMAC pattern (WebCrypto
860
+ * `crypto.subtle`, available in workerd).
861
+ */
710
862
  private signDispatch;
711
863
  /**
712
- * Move a failed record into the retry pipeline with configurable backoff.
713
- * The retry budget/backoff comes from the record's {@link RetryPolicy}
714
- * (falling back to the DO defaults); on exhaustion the record is parked
715
- * under a `dead:` key for manual inspection.
716
- */
864
+ * Move a failed record into the retry pipeline with configurable backoff.
865
+ * The retry budget/backoff comes from the record's {@link RetryPolicy}
866
+ * (falling back to the DO defaults); on exhaustion the record is parked
867
+ * under a `dead:` key for manual inspection.
868
+ */
717
869
  private recordRetry;
718
- /** Read the durable `pool:&lt;name>` row, defaulting to a fresh `inFlight: 0` pool. */
870
+ /** Read the durable `pool:<name>` row, defaulting to a fresh `inFlight: 0` pool. */
719
871
  private loadPool;
720
872
  private savePool;
721
873
  /**
722
- * Re-arm a pooled job that couldn't run because its pool was at capacity.
723
- * No attempt is charged (this is backpressure, not a failure): the job is
724
- * pushed `POOL_BACKPRESSURE_DELAY_MS` into the future so a later alarm
725
- * drains it once a slot frees, keeping its `id:` header and retry policy.
726
- */
874
+ * Re-arm a pooled job that couldn't run because its pool was at capacity.
875
+ * No attempt is charged (this is backpressure, not a failure): the job is
876
+ * pushed `POOL_BACKPRESSURE_DELAY_MS` into the future so a later alarm
877
+ * drains it once a slot frees, keeping its `id:` header and retry policy.
878
+ */
727
879
  private requeuePooled;
728
880
  /**
729
- * Release a pool slot when the runtime reports an action finished. This is
730
- * the durable-semaphore decrement: dispatch() only KICKS the action and
731
- * holds the slot; the runtime calls back here (`POST /complete { id }`) once
732
- * the action settles, freeing the slot for the next queued job. Idempotent
733
- * and safe if the job/pool is already gone.
734
- */
881
+ * Release a pool slot when the runtime reports an action finished. This is
882
+ * the durable-semaphore decrement: dispatch() only KICKS the action and
883
+ * holds the slot; the runtime calls back here (`POST /complete { id }`) once
884
+ * the action settles, freeing the slot for the next queued job. Idempotent
885
+ * and safe if the job/pool is already gone.
886
+ */
735
887
  private handleComplete;
736
888
  /** `GET /pool?name=` — inspect a pool's slot usage + queued count. */
737
889
  private handlePoolStatus;
738
890
  /**
739
- * `GET /status` — the app-level backlog signal that powers the studio's
740
- * SLO view. Enumerates every durable `pool:&lt;name>` row for its `inFlight`/
741
- * `maxConcurrency` semaphore, counts the pending (not-yet-dispatched) jobs
742
- * routed to each pool with the same single-pass scan {@link handlePoolStatus}
743
- * uses, and rolls those up into app-wide `backlog` (sum of `queued`) and
744
- * `inFlight` (sum of held slots) totals.
745
- *
746
- * Pools that have rows but no queued jobs still appear (with `queued: 0`) so
747
- * a saturated-but-idle pool stays visible; a pool that only ever existed as
748
- * queued jobs without a persisted row is unreachable here (the schedule path
749
- * always writes a `pool:&lt;name>` row before the job's header), so a single
750
- * scan over `pool:`/`id:` is sufficient.
751
- */
891
+ * `GET /status` — the app-level backlog signal that powers the studio's
892
+ * SLO view. Enumerates every durable `pool:<name>` row for its `inFlight`/
893
+ * `maxConcurrency` semaphore, counts the pending (not-yet-dispatched) jobs
894
+ * routed to each pool with the same single-pass scan {@link handlePoolStatus}
895
+ * uses, and rolls those up into app-wide `backlog` (sum of `queued`) and
896
+ * `inFlight` (sum of held slots) totals.
897
+ *
898
+ * Pools that have rows but no queued jobs still appear (with `queued: 0`) so
899
+ * a saturated-but-idle pool stays visible; a pool that only ever existed as
900
+ * queued jobs without a persisted row is unreachable here (the schedule path
901
+ * always writes a `pool:<name>` row before the job's header), so a single
902
+ * scan over `pool:` plus a cursor loop over `id:` is sufficient.
903
+ */
752
904
  private handleStatus;
753
905
  private handleSchedule;
754
906
  private handleCancel;
755
907
  private handleList;
756
908
  /**
757
- * `GET /dead` — list the dead-letter records: jobs that exhausted their
758
- * retry budget ({@link recordRetry}) and were parked under `dead:&lt;id>`
759
- * instead of being silently dropped. These never appear in `/list` (their
760
- * `id:` header is deleted on park), so this is the ONLY way the studio can
761
- * surface — and recover — a permanently-failed job.
762
- */
909
+ * `GET /dead` — list the dead-letter records: jobs that exhausted their
910
+ * retry budget ({@link recordRetry}) and were parked under `dead:<id>`
911
+ * instead of being silently dropped. These never appear in `/list` (their
912
+ * `id:` header is deleted on park), so this is the ONLY way the studio can
913
+ * surface — and recover — a permanently-failed job.
914
+ */
763
915
  private handleDeadList;
764
916
  /**
765
- * `POST /dead/retry { id }` — resurrect a dead-letter record: reset its
766
- * exhausted attempt count to 0 (a fresh retry budget), re-arm it for
767
- * immediate dispatch via the standard time index, and drop the `dead:` row.
768
- * The new `id:` header makes it visible to `/list` and the live `/ws`
769
- * subscription again. A miss is a no-op (`{ retried: false }`).
770
- */
917
+ * `POST /dead/retry { id }` — resurrect a dead-letter record: reset its
918
+ * exhausted attempt count to 0 (a fresh retry budget), re-arm it for
919
+ * immediate dispatch via the standard time index, and drop the `dead:` row.
920
+ * The new `id:` header makes it visible to `/list` and the live `/ws`
921
+ * subscription again. A miss is a no-op (`{ retried: false }`).
922
+ */
771
923
  private handleDeadRetry;
772
924
  /**
773
- * `POST /dead/cancel { id }` — permanently drop a dead-letter record the
774
- * operator has decided not to recover. Returns `{ removed }` (false when
775
- * nothing matched). Idempotent: a repeated purge is a harmless no-op.
776
- */
925
+ * `POST /dead/cancel { id }` — permanently drop a dead-letter record the
926
+ * operator has decided not to recover. Returns `{ removed }` (false when
927
+ * nothing matched). Idempotent: a repeated purge is a harmless no-op.
928
+ */
777
929
  private handleDeadCancel;
778
930
  /**
779
- * Resolve a single pending job by id via a direct `id:&lt;id>` storage read —
780
- * O(1), versus scanning the whole `/list` view. Responds `{ record }` on a
781
- * hit and `{}` on a miss (an absent `record` field — JSON has no `undefined`
782
- * — which the client reads back as `null`).
783
- */
931
+ * Resolve a single pending job by id via a direct `id:<id>` storage read —
932
+ * O(1), versus scanning the whole `/list` view. Responds `{ record }` on a
933
+ * hit and `{}` on a miss (an absent `record` field — JSON has no `undefined`
934
+ * — which the client reads back as `null`).
935
+ */
784
936
  private handleGet;
785
937
  private removeRecord;
786
938
  /**
787
- * Arm the alarm for `scheduledFor` only if it is sooner than the currently
788
- * set alarm (or none is set). Used on the schedule path: inserting a job
789
- * can only ever pull the earliest-pending time *earlier*, never later, so a
790
- * full `t:` rescan is unnecessary unless the new job is the new earliest.
791
- */
939
+ * Arm the alarm for `scheduledFor` only if it is sooner than the currently
940
+ * set alarm (or none is set). Used on the schedule path: inserting a job
941
+ * can only ever pull the earliest-pending time *earlier*, never later, so a
942
+ * full `t:` rescan is unnecessary unless the new job is the new earliest.
943
+ */
792
944
  private armAlarmIfEarlier;
793
945
  private rescheduleAlarm;
794
946
  }
947
+ /** What the Cloudflare scheduler host needs from the Worker's environment. */
948
+ interface SchedulerHostOptions {
949
+ /**
950
+ * Named scheduler instance — one `SchedulerDO` per name, useful for tenant
951
+ * isolation. Defaults to `"default"`.
952
+ */
953
+ instanceName?: string;
954
+ /**
955
+ * Data-residency jurisdiction for the `SchedulerDO`. Pass the same value as
956
+ * the worker's own jurisdiction so scheduled state co-resides with app data.
957
+ */
958
+ jurisdiction?: "eu" | "fedramp" | "us";
959
+ /** The `SchedulerDO` namespace binding. */
960
+ namespace: Parameters<typeof createScheduler>[0]["namespace"];
961
+ /**
962
+ * Public origin the Worker is mounted at. `SchedulerDO` dispatches back to
963
+ * this base URL when an alarm fires, so a wrong value means jobs fire into
964
+ * nothing.
965
+ */
966
+ originUrl: string;
967
+ }
968
+ /**
969
+ * Build the Cloudflare {@link SchedulerHost}.
970
+ *
971
+ * The returned host has no `cron` member — see the module docstring.
972
+ */
973
+ declare const createSchedulerHost: (options: SchedulerHostOptions) => SchedulerHost;
795
974
  /** Standard cron expression (5- or 6-field) or a supported `@macro`, per `cron-parser`. */
796
975
  declare const isValidCronExpression: (schedule: string) => boolean;
797
976
  /**
798
- * Assert a raw cron expression is well-formed, throwing the same shaped error
799
- * both cron surfaces use. The `context` prefix lets callers name the offending
800
- * job (`cron job "send digest"`) vs. the bare trigger.
801
- */
977
+ * A 6-field (seconds-leading) cron expression is legal generic cron grammar
978
+ * but not a Cloudflare Cron Trigger — the platform only understands the
979
+ * 5-field, minute-granularity form and rejects the rest at `wrangler deploy`
980
+ * with a message naming neither the job nor the file. Warn here instead of
981
+ * staying silent, without throwing: unlike the ergonomic `.interval()` form,
982
+ * the raw `.cron()` escape hatch is meant to accept cron grammar this module
983
+ * doesn't otherwise second-guess.
984
+ *
985
+ * Exported (not module-private) because it is the ONE place this advisory is
986
+ * written: the runtime `cronJobs()` builder reaches it via
987
+ * {@link assertValidCronExpression}, and `@lunora/codegen`'s static
988
+ * `discover-crons.ts` calls it directly after its own `isValidCronExpression`
989
+ * check — so a hand-authored 6-field `.cron()` warns whether it's discovered
990
+ * from source at build time or registered at runtime, with one shared message.
991
+ */
992
+ declare const warnIfSecondsLeading: (schedule: string, context: string) => void;
993
+ /**
994
+ * Assert a raw cron expression is well-formed, throwing the same shaped error
995
+ * both cron surfaces use. The `context` prefix lets callers name the offending
996
+ * job (`cron job "send digest"`) vs. the bare trigger. Well-formed but
997
+ * Cloudflare-incompatible (6-field) expressions pass but log a warning — see
998
+ * {@link warnIfSecondsLeading}.
999
+ */
802
1000
  declare const assertValidCronExpression: (schedule: string, context?: string) => void;
803
- export { type ArgsOf, CRON_SCHEDULE_KINDS, type CronJob, type CronJobsBuilder, type CronScheduleKind, type CronTarget, type CronTriggerOptions, type CronTriggerSnippet, type DailySchedule, type DurableObjectIdLike, type DurableObjectJurisdiction, 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 };
1001
+ export { type ArgsOf, CRON_SCHEDULE_KINDS, type CronJob, type CronJobsBuilder, type CronScheduleKind, type CronTarget, type CronTriggerOptions, type CronTriggerSnippet, type DailySchedule, type DurableObjectIdLike, type DurableObjectJurisdiction, type DurableObjectNamespaceLike, type DurableObjectStubLike, type EnqueueOptions, type FunctionReference, type HourlySchedule, type HttpDispatcherOptions, type IntervalSchedule, type LunoraSchedulerOptions, MAX_RETRY_ATTEMPTS, type MessageBatchLike, type MonthlySchedule, type QueueConsumerOptions, type QueueDispatch, type QueueEnqueueOptions, type QueueJob, type QueueLike, type QueueMessageLike, type QueueSendOptionsLike, type QueueSendRequestLike, type QueueWorkpool, type QueueWorkpoolOptions, RETRY_BASE_DELAY_MS, type RetryPolicy, type RunOptions, type ScheduleRecord, type Scheduler, SchedulerDO, type SchedulerDOState, type SchedulerEnv, type SchedulerHostOptions, type SchedulerPoolStatus, type SchedulerStatus, type WeeklySchedule, type WorkflowReference, type Workpool, type WorkpoolOptions, assertValidCronExpression, compileCronSchedule, createCronTrigger, createQueueConsumer, createQueueWorkpool, createScheduler, createSchedulerHost, createWorkpool, cronJobs, httpDispatcher, isValidCronExpression, isWorkflowReference, warnIfSecondsLeading };