@lunora/scheduler 1.0.0-alpha.9 → 1.0.0-alpha.90

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.
Files changed (29) hide show
  1. package/README.md +4 -3
  2. package/dist/index.d.mts +1046 -456
  3. package/dist/index.d.ts +1046 -456
  4. package/dist/index.mjs +1 -8
  5. package/dist/packem_shared/CRON_SCHEDULE_KINDS-xyFk800s.mjs +1 -0
  6. package/dist/packem_shared/MAX_RETRY_ATTEMPTS-BFBSSPrs.mjs +1 -0
  7. package/dist/packem_shared/assertScheduleDelay-BgA4K1WB.mjs +1 -0
  8. package/dist/packem_shared/assertScheduleInstant-BzESPqyw.mjs +1 -0
  9. package/dist/packem_shared/assertValidCronExpression-DnBtukpq.mjs +1 -0
  10. package/dist/packem_shared/base64-BFBvYeZM.mjs +1 -0
  11. package/dist/packem_shared/createCronTrigger-DSuDZtqj.mjs +1 -0
  12. package/dist/packem_shared/createQueueConsumer--7v7nhxZ.mjs +4 -0
  13. package/dist/packem_shared/createScheduler-BW6wVD57.mjs +1 -0
  14. package/dist/packem_shared/createSchedulerHost-CO6BlgVY.mjs +1 -0
  15. package/dist/packem_shared/createWorkpool-CQs6z3Y0.mjs +1 -0
  16. package/dist/packem_shared/do-client-XQayNStI.mjs +1 -0
  17. package/dist/packem_shared/isWorkflowReference-CT3tdefh.mjs +1 -0
  18. package/dist/packem_shared/resolveScheduleId-DgysKSrN.mjs +1 -0
  19. package/dist/packem_shared/wire-codec-TIVAE0Ds.mjs +1 -0
  20. package/package.json +4 -3
  21. package/dist/packem_shared/CRON_SCHEDULE_KINDS-C2FflEnq.mjs +0 -140
  22. package/dist/packem_shared/SchedulerDO-DeJHI379.mjs +0 -705
  23. package/dist/packem_shared/assertValidCronExpression-B9m75qU0.mjs +0 -24
  24. package/dist/packem_shared/createCronTrigger-Dh4VLxD9.mjs +0 -28
  25. package/dist/packem_shared/createQueueConsumer-Cy-Mp-El.mjs +0 -61
  26. package/dist/packem_shared/createScheduler-Df9JUjQb.mjs +0 -52
  27. package/dist/packem_shared/createWorkpool-Ce0kNM2p.mjs +0 -37
  28. package/dist/packem_shared/do-client-CMJtLoHO.mjs +0 -42
  29. package/dist/packem_shared/isWorkflowReference-C9mQkMXt.mjs +0 -3
package/dist/index.d.ts CHANGED
@@ -1,91 +1,162 @@
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
- */
10
- interface FunctionReference {
3
+ * The generated function-reference type, shared by every package that needs to
4
+ * infer a call's args or return from `api.<file>.<fn>`.
5
+ *
6
+ * This lives in `shared/` rather than in one package because the consumers span
7
+ * a dependency boundary they must not cross: `@lunora/scheduler` and
8
+ * `@lunora/workflow` accept a reference in `runAfter`/`runAt`/`step.run*` but
9
+ * cannot depend on `@lunora/client`, which is a browser package. Each of them
10
+ * previously hand-copied this declaration, and both copies silently rotted when
11
+ * the phantom carrier was renamed — the conditional matched an OPTIONAL property
12
+ * that no longer existed, so `ArgsOf<F>` quietly resolved to `unknown` and every
13
+ * `step.run(ref, args)` in the repo lost its arg checking without a single error.
14
+ *
15
+ * `shared/` is the repo's answer to exactly that shape: bundler-inlined,
16
+ * zero-dependency source imported by relative path, so it creates no runtime
17
+ * edge between the packages that inline it. Being ONE declaration, there is
18
+ * nothing to keep in lockstep and no drift test to write.
19
+ */
20
+ /** The registered function kinds a {@link FunctionReference} can describe. `stream` is a query that yields multiple frames over the WS. */
21
+ type FunctionKind = "action" | "mutation" | "query" | "stream";
22
+ /**
23
+ * Opaque reference to a registered function emitted by `@lunora/codegen`.
24
+ *
25
+ * At runtime it carries the `<file>:<function>` identifier in `__lunoraRef`.
26
+ * Generated declarations decorate this with phantom type parameters so callers
27
+ * can infer args / return values per call site.
28
+ */
29
+ interface FunctionReference<Kind extends FunctionKind = FunctionKind, Args = unknown, Return = unknown> {
30
+ /**
31
+ * Phantom marker carrying the `Kind`/`Args`/`Return` type parameters for
32
+ * inference. Never present at runtime; declared as a covariant (output)
33
+ * position so a concrete reference stays assignable to a widened one.
34
+ */
35
+ readonly __lunoraPhantom?: {
36
+ args: Args;
37
+ kind: Kind;
38
+ returns: Return;
39
+ };
11
40
  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
- */
41
+ }
42
+ /** Extract the args type from a {@link FunctionReference}. */
43
+ type ArgsOf<F> = F extends FunctionReference<infer _K, infer A, infer _R> ? A : never;
44
+ /**
45
+ * Typed reference to a Lunora durable workflow — either the generated
46
+ * `workflows.<name>` reference object (`_generated/api.ts`, which carries the
47
+ * `WORKFLOW_*` binding + export name) or, structurally, a `defineWorkflow()`
48
+ * result imported directly. Both are matched by the `isLunoraWorkflow` brand and
49
+ * carry the workflow's `params` in the phantom `__params`, so a `cronJobs()`
50
+ * registration infers them.
51
+ *
52
+ * Declared structurally here so `@lunora/scheduler` can let a `cronJobs()`
53
+ * builder target a workflow without depending on `@lunora/workflow` (and so the
54
+ * generated `workflows.*` object needs no `@lunora/scheduler` import — it
55
+ * matches structurally). A cron whose target is a {@link WorkflowReference}
56
+ * starts a new workflow INSTANCE on each fire (the args become its `params`)
57
+ * instead of dispatching a one-shot function. `@lunora/codegen` resolves the
58
+ * concrete `lunora/workflows.ts` export statically; the runtime brand here is
59
+ * the authoring-time guard.
60
+ */
35
61
  interface WorkflowReference<Params = Record<string, unknown>> {
36
62
  /** Phantom carrier for the workflow's `params` type — drives `cronJobs()` arg inference. Never read at runtime. */
37
63
  readonly __params?: Params;
38
- /** The `WORKFLOW_*` binding name (present on a generated `workflows.&lt;name>` ref). */
64
+ /** The `WORKFLOW_*` binding name (present on a generated `workflows.<name>` ref). */
39
65
  readonly binding?: string;
40
66
  readonly isLunoraWorkflow: true;
41
67
  /** The workflow's export/stable name (present on a generated ref; a `defineWorkflow({ name })` override otherwise). */
42
68
  readonly name?: string;
43
69
  }
70
+ /**
71
+ * A function reference a scheduler or workpool may target.
72
+ *
73
+ * `stream` is excluded deliberately, and the exclusion is load-bearing rather
74
+ * than tidiness: a scheduled job is dispatched as an ordinary `/rpc` call, and
75
+ * the function runner cannot execute a stream function (see
76
+ * `create-worker.ts`'s registry note). Accepting one compiles a job that is
77
+ * guaranteed to fail when its alarm fires, long after the call site that
78
+ * scheduled it.
79
+ */
80
+ type SchedulableReference<Args = unknown, Return = unknown> = FunctionReference<Exclude<FunctionKind, "stream">, Args, Return>;
44
81
  /** A cron job's target: either a one-shot function dispatch or a durable workflow start. */
45
- type CronTarget = FunctionReference | WorkflowReference;
82
+ type CronTarget = SchedulableReference | WorkflowReference;
46
83
  /** The arguments a cron's target accepts: a workflow's inferred `params`, else an open record (function args aren't inferred). */
47
84
  type CronTargetArgs<T extends CronTarget> = T extends WorkflowReference<infer Params> ? Params : Record<string, unknown>;
48
85
  /**
49
- * The arguments a one-shot schedule target ({@link Scheduler.runAfter} /
50
- * {@link Scheduler.runAt}) accepts. Unlike {@link CronTargetArgs} it preserves a
51
- * {@link FunctionReference}'s inferred `args` (via {@link ArgsOf}) as well as a
52
- * {@link WorkflowReference}'s inferred `params`, so scheduling a plain function
53
- * keeps its today's arg checking while scheduling a workflow/agent infers its
54
- * `params`.
55
- */
56
- type ScheduleTargetArgs<T extends CronTarget> = T extends WorkflowReference<infer Params> ? Params : T extends FunctionReference ? ArgsOf<T> : Record<string, unknown>;
86
+ * The arguments a one-shot schedule target ({@link Scheduler.runAfter} /
87
+ * {@link Scheduler.runAt}) accepts. Unlike {@link CronTargetArgs} it resolves a
88
+ * {@link FunctionReference}'s `args` through {@link ArgsOf} as well as a
89
+ * {@link WorkflowReference}'s `params`, so scheduling a generated function
90
+ * reference is arg-checked against that function's validator while scheduling a
91
+ * workflow/agent is checked against its `params`.
92
+ */
93
+ type ScheduleTargetArgs<T extends CronTarget> = T extends WorkflowReference<infer Params> ? Params : T extends SchedulableReference ? ArgsOf<T> : Record<string, unknown>;
57
94
  /** Narrow a {@link CronTarget} to a {@link WorkflowReference} by its runtime brand. */
58
95
  declare const isWorkflowReference: (target: unknown) => target is WorkflowReference;
59
96
  /**
60
- * Per-job retry policy. Wired into the SchedulerDO's existing attempts/backoff
61
- * machinery. When omitted, the DO falls back to its built-in defaults
62
- * (`maxAttempts: 5`, `backoff: "exponential"`, `baseMs: 30_000`) so existing
63
- * `runAfter`/`runAt` callers keep today's behaviour unchanged.
64
- *
65
- * On exhaustion (attempts > `maxAttempts`) the record is parked under the
66
- * `dead:` dead-letter key for inspection — never silently dropped.
67
- */
97
+ * Per-job retry policy. Wired into the SchedulerDO's existing attempts/backoff
98
+ * machinery. When omitted, the DO falls back to its built-in defaults
99
+ * (`maxAttempts: 5`, `backoff: "exponential"`, `baseMs: 30_000`) so existing
100
+ * `runAfter`/`runAt` callers keep today's behaviour unchanged.
101
+ *
102
+ * On exhaustion (attempts > `maxAttempts`) the record is parked under the
103
+ * `dead:` dead-letter key for inspection — never silently dropped.
104
+ */
68
105
  interface RetryPolicy {
69
106
  /**
70
- * Backoff growth across attempts. `"exponential"` doubles the delay each
71
- * attempt (`baseMs * 2 ** (attempt - 1)`); `"linear"` grows it linearly
72
- * (`baseMs * attempt`). Default `"exponential"`.
73
- */
107
+ * Backoff growth across attempts. `"exponential"` doubles the delay each
108
+ * attempt (`baseMs * 2 ** (attempt - 1)`); `"linear"` grows it linearly
109
+ * (`baseMs * attempt`). Default `"exponential"`.
110
+ */
74
111
  backoff?: "exponential" | "linear";
75
112
  /** Base delay in milliseconds for the first retry. Default `30_000`. */
76
113
  baseMs?: number;
77
- /** Maximum number of dispatch attempts before dead-lettering. Default `5`. */
114
+ /**
115
+ * Maximum number of **retries** after the initial dispatch. Default `5`, so
116
+ * a job that keeps failing is dispatched 6 times in total before it is
117
+ * dead-lettered (the park happens once `attempts > maxAttempts`).
118
+ */
78
119
  maxAttempts?: number;
79
120
  /** Optional ceiling clamping the computed backoff delay. */
80
121
  maxMs?: number;
81
122
  }
82
123
  interface RunOptions {
83
124
  /**
84
- * Logical workpool this job belongs to. When set, the SchedulerDO gates the
85
- * job behind the pool's `maxConcurrency` (see {@link WorkpoolOptions}).
86
- * Usually populated by {@link Workpool.enqueue}; callers rarely set it on a
87
- * bare `runAfter`/`runAt`.
88
- */
125
+ * Job id to store the record under, instead of one the SchedulerDO mints.
126
+ *
127
+ * Exists for `@lunora/server`'s deferred-schedule facade: inside a mutation a
128
+ * `runAfter`/`runAt` is buffered until the transaction commits, but the
129
+ * handler is handed the id synchronously, so the id has to be decided before
130
+ * the call is made. Callers that are not deferring should leave it unset and
131
+ * take the minted id from the return value. Anything that is not a plain
132
+ * `[A-Za-z0-9_-]` id of at most 64 characters, or that LEADS with `-`, is
133
+ * REFUSED (`400 INVALID_SCHEDULE_ID`) rather than replaced: the id is handed
134
+ * to `WorkflowBinding.create({ id })` verbatim for a workflow target, and the
135
+ * engine's instance-id grammar (`^[a-zA-Z0-9_][a-zA-Z0-9-_]*$`) refuses that
136
+ * first character. Minting over it would mean two calls naming the same bad
137
+ * id ran the job twice instead of the second answering `409`.
138
+ *
139
+ * **Not an idempotency key.** An id that is already scheduled is REFUSED
140
+ * (`409 DUPLICATE_SCHEDULE_ID`), not replaced or de-duplicated: the time
141
+ * index is keyed by time as well as id, so an overwrite would fire the new
142
+ * job at the old job's instant and drop the slot it was actually scheduled
143
+ * for. Cancel the existing job first if you mean to reschedule it. The id
144
+ * is free again once the job has fired or been cancelled.
145
+ */
146
+ id?: string;
147
+ /**
148
+ * Cap for the {@link RunOptions.pool} this job joins, applied when the pool
149
+ * is first created and refreshed on every enqueue that carries one. Ignored
150
+ * without `pool`. A pool created by a `runAfter`/`runAt` that omits it caps
151
+ * at 1 — {@link Workpool} is the usual way to set it.
152
+ */
153
+ maxConcurrency?: number;
154
+ /**
155
+ * Logical workpool this job belongs to. When set, the SchedulerDO gates the
156
+ * job behind the pool's `maxConcurrency` (see {@link WorkpoolOptions}).
157
+ * Usually populated by {@link Workpool.enqueue}; callers rarely set it on a
158
+ * bare `runAfter`/`runAt`.
159
+ */
89
160
  pool?: string;
90
161
  /** Per-job retry policy. Falls back to the DO's built-in defaults when omitted. */
91
162
  retry?: RetryPolicy;
@@ -95,87 +166,108 @@ interface RunOptions {
95
166
  interface ScheduleRecord {
96
167
  args: Record<string, unknown>;
97
168
  /**
98
- * Number of dispatch attempts already made. Absent (treated as 0) until the
99
- * first failure, after which `recordRetry()` persists it on both the
100
- * `retry:` row and the `id:` header. Surfaced here so `/list` consumers and
101
- * the studio see the field the storage layer actually writes.
102
- */
169
+ * Number of dispatch attempts already made. Absent (treated as 0) until the
170
+ * first failure, after which `recordRetry()` persists it on both the
171
+ * `retry:` row and the `id:` header. Surfaced here so `/list` consumers and
172
+ * the studio see the field the storage layer actually writes.
173
+ */
103
174
  attempts?: number;
104
175
  enqueuedAt: number;
105
176
  /**
106
- * The `ns:fn` path of the function to dispatch on fire. Absent when the job
107
- * targets a durable workflow/agent instead — see {@link ScheduleRecord.workflow}.
108
- * Exactly one of `functionPath` / `workflow` is set.
109
- */
177
+ * The `ns:fn` path of the function to dispatch on fire. Absent when the job
178
+ * targets a durable workflow/agent instead — see {@link ScheduleRecord.workflow}.
179
+ * Exactly one of `functionPath` / `workflow` is set.
180
+ */
110
181
  functionPath?: string;
111
182
  id: string;
112
183
  /**
113
- * Scheduler/workpool instance name the job was enqueued through. Echoed in
114
- * the dispatch payload so the runtime can call back the SAME DO instance's
115
- * `/complete` to release a pooled slot. Absent for the default instance.
116
- */
184
+ * Scheduler/workpool instance name the job was enqueued through. Echoed in
185
+ * the dispatch payload so the runtime can call back the SAME DO instance's
186
+ * `/complete` to release a pooled slot. Absent for the default instance.
187
+ */
117
188
  instanceName?: string;
118
189
  /**
119
- * Logical workpool this job belongs to (set by {@link Workpool.enqueue}).
120
- * When present, the SchedulerDO only dispatches the job while the pool's
121
- * in-flight count is below its `maxConcurrency`; otherwise it stays queued
122
- * and drains as slots free. Absent for plain `runAfter`/`runAt` jobs, which
123
- * are never concurrency-gated.
124
- */
190
+ * Logical workpool this job belongs to (set by {@link Workpool.enqueue}).
191
+ * When present, the SchedulerDO only dispatches the job while the pool's
192
+ * in-flight count is below its `maxConcurrency`; otherwise it stays queued
193
+ * and drains as slots free. Absent for plain `runAfter`/`runAt` jobs, which
194
+ * are never concurrency-gated.
195
+ */
125
196
  pool?: string;
126
197
  /** Per-job retry policy (see {@link RetryPolicy}); absent means DO defaults. */
127
198
  retry?: RetryPolicy;
128
199
  scheduledFor: number;
129
200
  shardKey?: string;
130
201
  /**
131
- * The `WORKFLOW_*`/`AGENT_*` binding name to start a fresh durable instance
132
- * of on fire (the {@link ScheduleRecord.args} become its `params`). Set
133
- * instead of {@link ScheduleRecord.functionPath} when the job targets a
134
- * workflow/agent {@link WorkflowReference}. The runtime — not the DO — owns
135
- * the binding, so the dispatch payload carries this through to the Worker.
136
- */
202
+ * The `WORKFLOW_*`/`AGENT_*` binding name to start a fresh durable instance
203
+ * of on fire (the {@link ScheduleRecord.args} become its `params`). Set
204
+ * instead of {@link ScheduleRecord.functionPath} when the job targets a
205
+ * workflow/agent {@link WorkflowReference}. The runtime — not the DO — owns
206
+ * the binding, so the dispatch payload carries this through to the Worker.
207
+ */
137
208
  workflow?: string;
138
209
  }
139
210
  interface Scheduler {
140
211
  cancel: (id: string) => Promise<{
141
212
  cancelled: boolean;
142
213
  }>;
214
+ /**
215
+ * Jobs that exhausted their retry budget and were parked under `dead:`
216
+ * (the DO's `/dead` view). Deliberately absent from {@link Scheduler.list} —
217
+ * the park deletes the `id:` header — so this is the only view of a job
218
+ * that failed permanently rather than being silently dropped.
219
+ */
220
+ dead: () => Promise<ScheduleRecord[]>;
221
+ /**
222
+ * Resurrect a parked job with a fresh attempt budget (the DO's
223
+ * `POST /dead/retry`). `false` when the id is not parked; a racing double
224
+ * recover is a no-op rather than an error.
225
+ */
226
+ deadRetry: (id: string) => Promise<boolean>;
143
227
  /** Resolve a single pending job by id, or `null` when absent (derived from {@link Scheduler.list}). */
144
228
  get: (id: string) => Promise<ScheduleRecord | null>;
145
229
  /** All pending scheduled jobs (the DO's `/list` view). */
146
230
  list: () => Promise<ScheduleRecord[]>;
147
231
  /**
148
- * Schedule `target` to run once, `delayMs` from now. `target` is a function
149
- * {@link FunctionReference} (dispatched as a one-shot) or a durable
150
- * {@link WorkflowReference} — the generated `workflows.&lt;name>` /
151
- * `agents.&lt;name>` ref — which starts a fresh instance on fire (args become
152
- * its `params`). {@link ScheduleTargetArgs} infers the accepted args from
153
- * whichever target was passed.
154
- */
155
- runAfter: <T extends CronTarget>(delayMs: number, target: T, args: ScheduleTargetArgs<T>, options?: RunOptions) => Promise<{
156
- id: string;
157
- scheduledFor: number;
158
- }>;
159
- /** Like {@link Scheduler.runAfter} but fires at an absolute `date`/timestamp. */
160
- runAt: <T extends CronTarget>(date: Date | number, target: T, args: ScheduleTargetArgs<T>, options?: RunOptions) => Promise<{
161
- id: string;
162
- scheduledFor: number;
163
- }>;
232
+ * Schedule `target` to run once, `delayMs` from now. `target` is a function
233
+ * {@link FunctionReference} (dispatched as a one-shot) or a durable
234
+ * {@link WorkflowReference} — the generated `workflows.<name>` /
235
+ * `agents.<name>` ref — which starts a fresh instance on fire (args become
236
+ * its `params`). {@link ScheduleTargetArgs} infers the accepted args from
237
+ * whichever target was passed.
238
+ *
239
+ * **Resolves the job id, a bare string** — the same value `cancel`/`get`
240
+ * take, and the same value the `ctx.scheduler` surface promises. This object
241
+ * IS `ctx.scheduler` on the shard side (codegen installs it behind
242
+ * `SchedulerLike`, whose `runAfter`/`runAt` are declared `Promise<string>`),
243
+ * so resolving a `{ id, scheduledFor }` record here handed mutations an
244
+ * object where every other gate — `@lunora/server`'s `Scheduler`,
245
+ * `@lunora/shard-engine`'s `SchedulerLike`, `@lunora/runtime`'s httpAction
246
+ * ctx, and the docs — said string. Nothing caught it, because the install is
247
+ * a cast: apps wrote the object into a string column and `cancel(id)`
248
+ * answered `{ cancelled: false }` with no error anywhere.
249
+ *
250
+ * The fire instant is not lost: `runAt` was handed it, and a caller that
251
+ * needs it back reads `scheduledFor` off {@link Scheduler.get}.
252
+ */
253
+ runAfter: <T extends CronTarget>(delayMs: number, target: T, args: ScheduleTargetArgs<T>, options?: RunOptions) => Promise<string>;
254
+ /** Like {@link Scheduler.runAfter} but fires at an absolute `date`/timestamp. Resolves the job id. */
255
+ runAt: <T extends CronTarget>(date: Date | number, target: T, args: ScheduleTargetArgs<T>, options?: RunOptions) => Promise<string>;
164
256
  }
165
257
  /**
166
- * Cloudflare Durable Object data-residency jurisdiction. Widening union —
167
- * Cloudflare adds values over time.
168
- * @see https://developers.cloudflare.com/durable-objects/reference/data-location/
169
- */
258
+ * Cloudflare Durable Object data-residency jurisdiction. Widening union —
259
+ * Cloudflare adds values over time.
260
+ * @see https://developers.cloudflare.com/durable-objects/reference/data-location/
261
+ */
170
262
  type DurableObjectJurisdiction = "eu" | "fedramp" | "us";
171
263
  /** Subset of `DurableObjectNamespace` the package consumes. */
172
264
  interface DurableObjectNamespaceLike {
173
265
  get: (id: DurableObjectIdLike) => DurableObjectStubLike;
174
266
  idFromName: (name: string) => DurableObjectIdLike;
175
267
  /**
176
- * Derive a jurisdiction-restricted subnamespace. Optional because older
177
- * workers-types releases (and test doubles) may not expose it.
178
- */
268
+ * Derive a jurisdiction-restricted subnamespace. Optional because older
269
+ * workers-types releases (and test doubles) may not expose it.
270
+ */
179
271
  jurisdiction?: (jurisdiction: DurableObjectJurisdiction) => DurableObjectNamespaceLike;
180
272
  }
181
273
  interface DurableObjectIdLike {
@@ -188,19 +280,20 @@ interface LunoraSchedulerOptions {
188
280
  /** Optional named instance — useful for tenant isolation. Default `default`. */
189
281
  instanceName?: string;
190
282
  /**
191
- * Pin the SchedulerDO (durable timers + cron state) to a Cloudflare
192
- * data-residency jurisdiction. Pass the same value as the worker's
193
- * `jurisdiction` so scheduled state co-resides with app data. Omit for the
194
- * un-pinned global namespace.
195
- */
283
+ * Pin the SchedulerDO (durable timers + cron state) to a Cloudflare
284
+ * data-residency jurisdiction. Pass the same value as the worker's
285
+ * `jurisdiction` so scheduled state co-resides with app data. Omit for the
286
+ * un-pinned global namespace.
287
+ */
196
288
  jurisdiction?: DurableObjectJurisdiction;
197
- /** Binding to the `SchedulerDO` durable object namespace. */
198
- namespace: DurableObjectNamespaceLike;
199
289
  /**
200
- * Origin where the Worker is mounted. SchedulerDO uses this base URL when
201
- * dispatching scheduled functions back to the Worker on alarm fire.
202
- */
203
- originUrl: string;
290
+ * Binding to the `SchedulerDO` durable object namespace.
291
+ *
292
+ * The origin the DO dispatches back to is NOT passed here: it reads
293
+ * `env.LUNORA_ORIGIN_URL` off its own binding at fire time, because a
294
+ * caller-supplied dispatch target would be an SSRF vector.
295
+ */
296
+ namespace: DurableObjectNamespaceLike;
204
297
  }
205
298
  /** Per-enqueue options for a {@link Workpool}. Extends {@link RunOptions} minus the implicit `pool` (the pool sets that). */
206
299
  interface EnqueueOptions {
@@ -212,46 +305,46 @@ interface EnqueueOptions {
212
305
  shardKey?: string;
213
306
  }
214
307
  /**
215
- * Options for `createWorkpool`. Mirrors {@link LunoraSchedulerOptions}
216
- * (same `namespace` / `originUrl` / `instanceName`) plus the bounded-concurrency
217
- * controls. A workpool is a NAMED logical pool inside the existing SchedulerDO —
218
- * it needs no extra Durable Object or wrangler binding beyond the SchedulerDO
219
- * the scheduler already uses.
220
- */
308
+ * Options for `createWorkpool`. Mirrors {@link LunoraSchedulerOptions}
309
+ * (same `namespace` / `instanceName`) plus the bounded-concurrency
310
+ * controls. A workpool is a NAMED logical pool inside the existing SchedulerDO —
311
+ * it needs no extra Durable Object or wrangler binding beyond the SchedulerDO
312
+ * the scheduler already uses.
313
+ */
221
314
  interface WorkpoolOptions extends LunoraSchedulerOptions {
222
315
  /**
223
- * Maximum number of jobs from this pool that may be in flight at once.
224
- * Excess enqueues are persisted and drain as slots free. Must be a positive
225
- * integer.
226
- */
316
+ * Maximum number of jobs from this pool that may be in flight at once.
317
+ * Excess enqueues are persisted and drain as slots free. Must be a positive
318
+ * integer.
319
+ */
227
320
  maxConcurrency: number;
228
321
  /**
229
- * Pool name — the concurrency counter is keyed by this inside the
230
- * SchedulerDO storage (`pool:&lt;name>`). Default `default`.
231
- */
322
+ * Pool name — the concurrency counter is keyed by this inside the
323
+ * SchedulerDO storage (`pool:<name>`). Default `default`.
324
+ */
232
325
  name?: string;
233
326
  }
234
327
  /**
235
- * Bounded-concurrency action queue (Lunora equivalent of `@convex-dev/workpool`).
236
- * Built on the existing SchedulerDO: `enqueue` schedules a job tagged with this
237
- * pool's name; the DO caps simultaneous dispatch at `maxConcurrency` and queues
238
- * the rest durably.
239
- */
328
+ * Bounded-concurrency action queue (Lunora equivalent of `@convex-dev/workpool`).
329
+ * Built on the existing SchedulerDO: `enqueue` schedules a job tagged with this
330
+ * pool's name; the DO caps simultaneous dispatch at `maxConcurrency` and queues
331
+ * the rest durably.
332
+ */
240
333
  interface Workpool {
241
334
  /** Cancel a queued/in-flight pool job by id. */
242
335
  cancel: (id: string) => Promise<{
243
336
  cancelled: boolean;
244
337
  }>;
245
338
  /**
246
- * Enqueue `function_(args)` into the pool. Resolves with the durable job id
247
- * and the time it was scheduled for (it may not run immediately if the pool
248
- * is at capacity).
249
- */
250
- enqueue: <F extends FunctionReference>(function_: F, args: ArgsOf<F>, options?: EnqueueOptions) => Promise<{
339
+ * Enqueue `function_(args)` into the pool. Resolves with the durable job id
340
+ * and the time it was scheduled for (it may not run immediately if the pool
341
+ * is at capacity).
342
+ */
343
+ enqueue: <F extends SchedulableReference>(function_: F, args: ArgsOf<F>, options?: EnqueueOptions) => Promise<{
251
344
  id: string;
252
345
  scheduledFor: number;
253
346
  }>;
254
- /** The pool's name (the `pool:&lt;name>` storage key suffix). */
347
+ /** The pool's name (the `pool:<name>` storage key suffix). */
255
348
  readonly name: string;
256
349
  /** Inspect the pool's current state — `inFlight` slots used and the configured `maxConcurrency`. */
257
350
  status: () => Promise<{
@@ -302,6 +395,13 @@ interface MessageBatchLike<Body = unknown> {
302
395
  }
303
396
  /** The wire payload Lunora puts on the queue: a function dispatch. */
304
397
  interface QueueJob {
398
+ /**
399
+ * The call's arguments in WIRE form (`shared/wire-codec`), so a `bigint`,
400
+ * `Date` or bytes survives the queue's own JSON serialisation. The producers
401
+ * encode; the shard's dispatch loop is the single decoder. A custom
402
+ * {@link QueueDispatch} must forward this untouched — decoding it here and
403
+ * letting the shard decode again flattens a `Date` to `{}`.
404
+ */
305
405
  args?: Record<string, unknown>;
306
406
  functionPath: string;
307
407
  /** Routing hint forwarded to the Worker so the call lands on the right shard. */
@@ -320,14 +420,14 @@ interface QueueWorkpoolOptions {
320
420
  queue: QueueLike<QueueJob>;
321
421
  }
322
422
  /**
323
- * Queues-backed producer: enqueue function dispatches onto a Cloudflare Queue.
324
- * Concurrency, retries, and dead-lettering are configured on the queue consumer
325
- * in `wrangler.jsonc` (`max_concurrency` / `max_retries` / `dead_letter_queue`),
326
- * not here — that's the whole point of using Queues over the DO workpool.
327
- */
423
+ * Queues-backed producer: enqueue function dispatches onto a Cloudflare Queue.
424
+ * Concurrency, retries, and dead-lettering are configured on the queue consumer
425
+ * in `wrangler.jsonc` (`max_concurrency` / `max_retries` / `dead_letter_queue`),
426
+ * not here — that's the whole point of using Queues over the DO workpool.
427
+ */
328
428
  interface QueueWorkpool {
329
429
  /** Enqueue a single `fn(args)` dispatch. */
330
- enqueue: <F extends FunctionReference>(function_: F, args: ArgsOf<F>, options?: QueueEnqueueOptions) => Promise<void>;
430
+ enqueue: <F extends SchedulableReference>(function_: F, args: ArgsOf<F>, options?: QueueEnqueueOptions) => Promise<void>;
331
431
  /** Enqueue many dispatches in one `sendBatch`. Each job names its function `ref`. */
332
432
  enqueueBatch: (jobs: ReadonlyArray<{
333
433
  args?: Record<string, unknown>;
@@ -335,12 +435,40 @@ interface QueueWorkpool {
335
435
  shardKey?: string;
336
436
  }>, options?: QueueSendOptionsLike) => Promise<void>;
337
437
  }
338
- /** Dispatches a single {@link QueueJob} — the consumer's per-message worker. */
339
- type QueueDispatch = (job: QueueJob) => Promise<void>;
438
+ /**
439
+ * Dispatches a single {@link QueueJob} — the consumer's per-message worker.
440
+ * `messageId` is the queue message's native id, threaded through so the
441
+ * dispatcher can attribute a failure to the exact message that caused it.
442
+ */
443
+ type QueueDispatch = (job: QueueJob, messageId?: string) => Promise<void>;
340
444
  /** Options for `createQueueConsumer`. */
341
445
  interface QueueConsumerOptions {
342
446
  /** How each job is executed; e.g. the `httpDispatcher`. */
343
447
  dispatch: QueueDispatch;
448
+ /**
449
+ * The consumer's `max_retries` as deployed in `wrangler.jsonc`, which a
450
+ * consumer cannot read at runtime. Used only to recognise a message's last
451
+ * delivery, where a declined dispatch is re-enqueued through `requeue` (or
452
+ * logged as the drop or dead-letter it becomes without one). Defaults to
453
+ * Cloudflare's default of 3.
454
+ */
455
+ maxRetries?: number;
456
+ /**
457
+ * Re-enqueue a job whose dispatch is declined on its last delivery as a
458
+ * copy delayed past the in-flight claim, with a fresh retry budget, instead
459
+ * of letting it be dead-lettered or dropped while its call is still
460
+ * running. Without it that loss is only logged.
461
+ *
462
+ * `queue` is the producer binding of the queue this consumer reads (the
463
+ * one handed to `createQueueWorkpool`). `secret` keys the MAC the copy
464
+ * carries — pass the admin token (`LUNORA_ADMIN_TOKEN`). The copy
465
+ * dispatches under the replaced message's id so the shard serves the
466
+ * declined call's result, and without a valid MAC no body can claim an id.
467
+ */
468
+ requeue?: {
469
+ queue: QueueLike<QueueJob>;
470
+ secret: string;
471
+ };
344
472
  }
345
473
  /** Options for the `httpDispatcher` — the default HTTP dispatcher. */
346
474
  interface HttpDispatcherOptions {
@@ -350,43 +478,66 @@ interface HttpDispatcherOptions {
350
478
  fetchImpl?: typeof fetch;
351
479
  /** Origin where the Worker is mounted (the `/_lunora/scheduler/dispatch` endpoint). */
352
480
  originUrl: string;
481
+ /**
482
+ * Abort a job's dispatch after this many ms; the abort is retryable, so the
483
+ * consumer retries the message. Defaults to 5 minutes — raise it for a
484
+ * workpool running jobs that legitimately run longer, lower it to fail a
485
+ * stuck origin faster.
486
+ */
487
+ timeoutMs?: number;
353
488
  }
354
489
  /**
355
- * Client-side scheduler — forwards `runAfter` / `runAt` / `cancel` calls to a
356
- * `SchedulerDO` over HTTP. The DO owns the alarm and the storage; this is a
357
- * thin RPC wrapper.
358
- */
490
+ * Client-side scheduler — forwards `runAfter` / `runAt` / `cancel` calls to a
491
+ * `SchedulerDO` over HTTP. The DO owns the alarm and the storage; this is a
492
+ * thin RPC wrapper.
493
+ */
359
494
  declare const createScheduler: (options: LunoraSchedulerOptions) => Scheduler;
360
495
  /**
361
- * Bounded-concurrency action queue — the Lunora equivalent of
362
- * `@convex-dev/workpool`. Mirrors `createScheduler`'s `namespace` /
363
- * `originUrl` / `instanceName` options and is built on the SAME `SchedulerDO`:
364
- * a workpool is just a NAMED logical pool inside that DO (concurrency counter
365
- * keyed by {@link WorkpoolOptions.name} under the `pool:&lt;name>` storage key).
366
- * It needs no extra Durable Object or wrangler binding beyond the SchedulerDO
367
- * the scheduler already uses.
368
- *
369
- * `enqueue` schedules a job tagged with this pool; the DO dispatches at most
370
- * `maxConcurrency` of the pool's jobs at once and queues the rest durably,
371
- * draining them as the runtime reports completions (`POST /complete`).
372
- *
373
- * ```ts
374
- * const pool = createWorkpool({ namespace: env.SCHEDULER, originUrl, maxConcurrency: 5 });
375
- * await pool.enqueue(internal.stripe.sync, { invoiceId }, { retry: { maxAttempts: 3 } });
376
- * ```
377
- *
378
- * Why not Cloudflare Queues? Queues natively cover concurrency-capped, retried,
379
- * dead-lettered, delayed dispatch (`max_concurrency`, `max_retries`,
380
- * `retry({ delaySeconds })`, `dead_letter_queue`), and are the right tool when
381
- * you just want to rate-limit fire-and-forget background work. This workpool
382
- * deliberately stays on `SchedulerDO` because it offers what a queue can't: a
383
- * hard concurrency cap (the DO is the single serialization point — no
384
- * cross-consumer overshoot), per-job cancellation, and per-job status
385
- * introspection, all keyed by a stable job id. Reach for Queues when you don't
386
- * need those; reach for this when you do. Either way, do NOT grow multi-step
387
- * orchestration on top of this — that's Cloudflare **Workflows** (`step.do` /
388
- * `step.sleep` / `step.waitForEvent`).
389
- */
496
+ * Bounded-concurrency action queue — the Lunora equivalent of
497
+ * `@convex-dev/workpool`. Mirrors `createScheduler`'s `namespace` /
498
+ * `instanceName` options and is built on the SAME `SchedulerDO`:
499
+ * a workpool is just a NAMED logical pool inside that DO (concurrency counter
500
+ * keyed by {@link WorkpoolOptions.name} under the `pool:<name>` storage key).
501
+ * It needs no extra Durable Object or wrangler binding beyond the SchedulerDO
502
+ * the scheduler already uses.
503
+ *
504
+ * `enqueue` schedules a job tagged with this pool; the DO dispatches at most
505
+ * `maxConcurrency` of the pool's jobs at once and queues the rest durably,
506
+ * draining them as the runtime reports completions (`POST /complete`).
507
+ *
508
+ * The ceiling the platform adds: one alarm drain keeps at most SIX dispatches in
509
+ * flight, because a Durable Object may have only six connections simultaneously
510
+ * waiting for response headers. A `maxConcurrency` above six is therefore not
511
+ * wrong, just not reachable within a single drain — the surplus drains on the
512
+ * following alarms. The same invocation is bounded by the 15-minute alarm wall
513
+ * clock, which covers the WHOLE drain rather than one job; work that does not
514
+ * fit is deferred to the next alarm, never dropped.
515
+ *
516
+ * For jobs that are individually long — an LLM call, an export, a payment
517
+ * round-trip — prefer `createQueueWorkpool`: the dispatch this DO awaits
518
+ * only returns once the function has finished running, so a long job occupies
519
+ * one of those six slots and one slice of that 15-minute budget for its whole
520
+ * duration. A Queues consumer gets a fresh Worker invocation per batch, with its
521
+ * own connection budget and its own clock, and its `max_concurrency` is not
522
+ * capped at six. You give up the hard cap, per-job cancel, and per-job status.
523
+ *
524
+ * ```ts
525
+ * const pool = createWorkpool({ namespace: env.SCHEDULER, maxConcurrency: 5 });
526
+ * await pool.enqueue(internal.stripe.sync, { invoiceId }, { retry: { maxAttempts: 3 } });
527
+ * ```
528
+ *
529
+ * Why not Cloudflare Queues? Queues natively cover concurrency-capped, retried,
530
+ * dead-lettered, delayed dispatch (`max_concurrency`, `max_retries`,
531
+ * `retry({ delaySeconds })`, `dead_letter_queue`), and are the right tool when
532
+ * you just want to rate-limit fire-and-forget background work. This workpool
533
+ * deliberately stays on `SchedulerDO` because it offers what a queue can't: a
534
+ * hard concurrency cap (the DO is the single serialization point — no
535
+ * cross-consumer overshoot), per-job cancellation, and per-job status
536
+ * introspection, all keyed by a stable job id. Reach for Queues when you don't
537
+ * need those; reach for this when you do. Either way, do NOT grow multi-step
538
+ * orchestration on top of this — that's Cloudflare **Workflows** (`step.do` /
539
+ * `step.sleep` / `step.waitForEvent`).
540
+ */
390
541
  declare const createWorkpool: (options: WorkpoolOptions) => Workpool;
391
542
  interface CronTriggerOptions {
392
543
  /** Args passed to the function. */
@@ -408,15 +559,25 @@ interface CronTriggerSnippet {
408
559
  wranglerJsonc: string;
409
560
  }
410
561
  /**
411
- * Produces the wrangler.jsonc fragment + dispatcher metadata for a recurring
412
- * function. The actual cron handler is mounted by `@lunora/runtime` — we only
413
- * emit the configuration here.
414
- */
562
+ * Produces the wrangler.jsonc fragment + dispatcher metadata for a recurring
563
+ * function. The actual cron handler is mounted by `@lunora/runtime` — we only
564
+ * emit the configuration here.
565
+ */
415
566
  declare const createCronTrigger: (options: CronTriggerOptions) => CronTriggerSnippet;
416
567
  /** Sub-day recurrence. Exactly one unit must be provided. */
417
568
  interface IntervalSchedule {
569
+ /** 1–23, and must divide 24 (an interval repeats *within* a day). */
418
570
  hours?: number;
571
+ /** 1–59, and must divide 60. */
419
572
  minutes?: number;
573
+ /**
574
+ * Accepted by the type, **rejected at definition time**: Cloudflare Cron
575
+ * Triggers have a one-minute floor, so the 6-field expression this compiles
576
+ * to would survive codegen and land in the committed `wrangler.jsonc`, then
577
+ * fail at `wrangler deploy` naming neither the job nor the file. Declared so
578
+ * the rejection can name the job instead. Use `ctx.scheduler.runAfter`/
579
+ * `runAt` for sub-minute recurrence, or `{ minutes: 1 }`.
580
+ */
420
581
  seconds?: number;
421
582
  }
422
583
  /** Daily recurrence at a fixed UTC wall-clock time. */
@@ -426,6 +587,19 @@ interface DailySchedule {
426
587
  /** 0–59. */
427
588
  minuteUTC: number;
428
589
  }
590
+ /**
591
+ * Hourly recurrence at a fixed minute past the hour.
592
+ *
593
+ * `crons.interval({ hours: 1 })` compiles to the same expression, but the
594
+ * asymmetry of having `daily`/`weekly`/`monthly` and no `hourly` is its own
595
+ * papercut — and unlike the interval form this one lets
596
+ * the caller place the job off the hour boundary, which is how you stop a
597
+ * dozen hourly jobs from stampeding at `:00`.
598
+ */
599
+ interface HourlySchedule {
600
+ /** 0–59. */
601
+ minuteUTC: number;
602
+ }
429
603
  /** Weekly recurrence at a fixed UTC time on a given weekday. */
430
604
  interface WeeklySchedule extends DailySchedule {
431
605
  /** Long weekday name, case-insensitive (e.g. `"monday"`). */
@@ -437,106 +611,156 @@ interface MonthlySchedule extends DailySchedule {
437
611
  day: number;
438
612
  }
439
613
  /**
440
- * One registered cron job, normalized to a compiled cron expression. Shared
441
- * verbatim with `@lunora/codegen` (which lifts the same fields out of the AST)
442
- * and the runtime dispatcher — keep the shape stable across all three.
443
- */
614
+ * One registered cron job, normalized to a compiled cron expression. Shared
615
+ * verbatim with `@lunora/codegen` (which lifts the same fields out of the AST)
616
+ * and the runtime dispatcher — keep the shape stable across all three.
617
+ */
444
618
  interface CronJob {
445
619
  /** Args forwarded to the function (or, for a workflow target, used as its `params`) on each fire. */
446
620
  args: Record<string, unknown>;
447
621
  /** Compiled standard cron expression, e.g. `"0 9 * * *"`. */
448
622
  cron: string;
449
623
  /**
450
- * `__lunoraRef` of the target function. Present for a function target;
451
- * absent when the job targets a workflow ({@link CronJob.workflow} instead).
452
- */
624
+ * `__lunoraRef` of the target function. Present for a function target;
625
+ * absent when the job targets a workflow ({@link CronJob.workflow} instead).
626
+ */
453
627
  functionPath?: string;
454
628
  /** Human-readable identifier — must be unique within one `cronJobs()`. */
455
629
  name: string;
456
630
  /**
457
- * Set when the job targets a durable workflow rather than a function: the
458
- * workflow's stable name (`defineWorkflow({ name })`) when one was declared,
459
- * otherwise `""`. `@lunora/codegen` statically resolves the concrete
460
- * `lunora/workflows.ts` export + its `WORKFLOW_*` binding for the emitted
461
- * dispatch map, so this authoring-time value is informational only.
462
- */
631
+ * Set when the job targets a durable workflow rather than a function: the
632
+ * workflow's stable name (`defineWorkflow({ name })`) when one was declared,
633
+ * otherwise `""`. `@lunora/codegen` statically resolves the concrete
634
+ * `lunora/workflows.ts` export + its `WORKFLOW_*` binding for the emitted
635
+ * dispatch map, so this authoring-time value is informational only.
636
+ */
463
637
  workflow?: string;
464
638
  }
465
639
  /** The ergonomic builder methods, excluding the raw `.cron` escape hatch. */
466
- type CronScheduleKind = "daily" | "interval" | "monthly" | "weekly";
640
+ type CronScheduleKind = "daily" | "hourly" | "interval" | "monthly" | "weekly";
467
641
  /** The ergonomic schedule kinds as a runtime set (codegen reads this to detect cron builder methods). */
468
642
  declare const CRON_SCHEDULE_KINDS: ReadonlySet<CronScheduleKind>;
469
643
  /**
470
- * Compile one of the ergonomic schedule forms into a standard cron expression.
471
- * Exposed as a pure function so `@lunora/codegen` can reuse the exact same
472
- * compilation when it statically lifts a `crons.{kind}(...)` call out of the
473
- * AST — codegen imports this directly (no duplicated mirror).
474
- */
475
- declare const compileCronSchedule: (kind: CronScheduleKind, schedule: DailySchedule | IntervalSchedule | MonthlySchedule | WeeklySchedule) => string;
476
- /**
477
- * Builder returned by {@link cronJobs}. Each method registers one recurring
478
- * job; the compiled expression is validated immediately so authoring mistakes
479
- * surface at definition time rather than at codegen.
480
- */
644
+ * Compile one of the ergonomic schedule forms into a standard cron expression.
645
+ * Exposed as a pure function so `@lunora/codegen` can reuse the exact same
646
+ * compilation when it statically lifts a `crons.{kind}(...)` call out of the
647
+ * AST — codegen imports this directly (no duplicated mirror). `jobName` is
648
+ * optional (see {@link compileInterval}) so codegen's existing 2-arg call site
649
+ * keeps working unchanged.
650
+ */
651
+ declare const compileCronSchedule: (kind: CronScheduleKind, schedule: DailySchedule | HourlySchedule | IntervalSchedule | MonthlySchedule | WeeklySchedule, jobName?: string) => string;
652
+ /**
653
+ * Builder returned by {@link cronJobs}. Each method registers one recurring
654
+ * job; the compiled expression is validated immediately so authoring mistakes
655
+ * surface at definition time rather than at codegen.
656
+ */
481
657
  interface CronJobsBuilder {
482
658
  /**
483
- * Raw cron expression escape hatch (5- or 6-field, full cron-parser grammar).
484
- * The target may be a function (`internal.file.fn`) or a durable workflow
485
- * (`workflows.&lt;name>`); a workflow's `args` are inferred from its `params`.
486
- */
659
+ * Raw cron expression escape hatch (5- or 6-field, full cron-parser grammar).
660
+ * The target may be a function (`internal.file.fn`) or a durable workflow
661
+ * (`workflows.<name>`); a workflow's `args` are inferred from its `params`.
662
+ */
487
663
  cron: <T extends CronTarget>(name: string, cronExpr: string, target: T, args?: CronTargetArgs<T>) => CronJobsBuilder;
488
- /** Daily at `hourUTC:minuteUTC` (UTC). The target may be a function or a durable workflow (`workflows.&lt;name>`). */
664
+ /** Daily at `hourUTC:minuteUTC` (UTC). The target may be a function or a durable workflow (`workflows.<name>`). */
489
665
  daily: <T extends CronTarget>(name: string, schedule: DailySchedule, target: T, args?: CronTargetArgs<T>) => CronJobsBuilder;
490
- /** Every `{ seconds | minutes | hours }`. The target may be a function or a durable workflow (`workflows.&lt;name>`). */
666
+ /** Hourly at `minuteUTC` past the hour. The target may be a function or a durable workflow (`workflows.<name>`). */
667
+ hourly: <T extends CronTarget>(name: string, schedule: HourlySchedule, target: T, args?: CronTargetArgs<T>) => CronJobsBuilder;
668
+ /** Every `{ minutes | hours }` — `{ seconds }` throws (Cron Triggers have a one-minute floor). The target may be a function or a durable workflow (`workflows.<name>`). */
491
669
  interval: <T extends CronTarget>(name: string, schedule: IntervalSchedule, target: T, args?: CronTargetArgs<T>) => CronJobsBuilder;
492
670
  /** Snapshot of the registered jobs, in declaration order. */
493
671
  jobs: () => ReadonlyArray<CronJob>;
494
- /** Monthly on `day` at `hourUTC:minuteUTC` (UTC). The target may be a function or a durable workflow (`workflows.&lt;name>`). */
672
+ /** Monthly on `day` at `hourUTC:minuteUTC` (UTC). The target may be a function or a durable workflow (`workflows.<name>`). */
495
673
  monthly: <T extends CronTarget>(name: string, schedule: MonthlySchedule, target: T, args?: CronTargetArgs<T>) => CronJobsBuilder;
496
- /** Weekly on `dayOfWeek` at `hourUTC:minuteUTC` (UTC). The target may be a function or a durable workflow (`workflows.&lt;name>`). */
674
+ /** Weekly on `dayOfWeek` at `hourUTC:minuteUTC` (UTC). The target may be a function or a durable workflow (`workflows.<name>`). */
497
675
  weekly: <T extends CronTarget>(name: string, schedule: WeeklySchedule, target: T, args?: CronTargetArgs<T>) => CronJobsBuilder;
498
676
  }
499
677
  /**
500
- * Create a code-first cron registry. The returned builder is chainable;
501
- * codegen discovers a `lunora/crons.ts` default export by AST, not a runtime
502
- * brand.
503
- */
678
+ * Create a code-first cron registry. The returned builder is chainable;
679
+ * codegen discovers a `lunora/crons.ts` default export by AST, not a runtime
680
+ * brand.
681
+ */
504
682
  declare const cronJobs: () => CronJobsBuilder;
505
683
  /**
506
- * Build a Queues producer that enqueues Lunora function dispatches. Concurrency
507
- * and retry policy live on the consumer's `wrangler.jsonc` config, not here.
508
- */
684
+ * Build a Queues producer that enqueues Lunora function dispatches. Concurrency
685
+ * and retry policy live on the consumer's `wrangler.jsonc` config, not here.
686
+ */
509
687
  declare const createQueueWorkpool: (options: QueueWorkpoolOptions) => QueueWorkpool;
510
688
  /**
511
- * Wrap a {@link QueueDispatch} into a Cloudflare `queue()` consumer handler.
512
- *
513
- * Each message is dispatched independently (concurrently across the batch). On
514
- * success the message is `ack()`-ed; on any failure — a thrown dispatcher or a
515
- * structurally-invalid body — it is `retry()`-ed, so Queues' own `max_retries`
516
- * + `dead_letter_queue` settings decide when to give up. Nothing is silently
517
- * dropped: a permanently-bad message rides retries into the dead-letter queue
518
- * where you can inspect it.
519
- */
689
+ * Wrap a {@link QueueDispatch} into a Cloudflare `queue()` consumer handler.
690
+ *
691
+ * Each message is dispatched independently (concurrently across the batch). On
692
+ * success the message is `ack()`-ed; on any failure — a thrown dispatcher or a
693
+ * structurally-invalid body — it is `retry()`-ed, so Queues' own `max_retries`
694
+ * + `dead_letter_queue` settings decide when to give up. Nothing is silently
695
+ * dropped: a permanently-bad message rides retries into the dead-letter queue
696
+ * where you can inspect it.
697
+ */
520
698
  declare const createQueueConsumer: (options: QueueConsumerOptions) => ((batch: MessageBatchLike) => Promise<void>);
521
699
  /**
522
- * Default {@link QueueDispatch}: POST each job to the Worker's
523
- * `/_lunora/scheduler/dispatch` endpoint (the same path SchedulerDO dispatches
524
- * through), authenticated with the admin bearer. A non-2xx response throws so
525
- * the consumer retries the message.
526
- */
700
+ * Default {@link QueueDispatch}: dispatch each job to the Worker's
701
+ * `/_lunora/scheduler/dispatch` endpoint (the same path SchedulerDO dispatches
702
+ * through) via `@lunora/dispatch`'s `createDispatchRunner` — which bounds each
703
+ * job with {@link HttpDispatcherOptions.timeoutMs} (default
704
+ * {@link DEFAULT_JOB_TIMEOUT_MS}), so a hung origin no longer holds the whole
705
+ * `queue()` invocation open, and threads the queue message id through for
706
+ * failure attribution. Any dispatch failure throws so the consumer retries the
707
+ * message — including a 2xx carrying a non-empty non-JSON body, which is an
708
+ * intermediary's page rather than a function's return value and therefore no
709
+ * evidence the job ran. An empty 2xx is a normal success (a `void` function).
710
+ *
711
+ * `job.args` is forwarded VERBATIM: {@link encodeJobArgs} already put it in wire
712
+ * form at the producer, and the shard's dispatch loop is the single decoder.
713
+ * Encoding again here would leave the handler a tagged array.
714
+ */
527
715
  declare const httpDispatcher: (options: HttpDispatcherOptions) => QueueDispatch;
528
716
  /**
529
- * Minimal projection of `DurableObjectState` for the SchedulerDO. Declared
530
- * structurally so unit tests can pass a fake state without booting the
531
- * workers runtime. The WebSocket methods are optional: they back the live
532
- * `/ws` subscription (push the job list on every change) and are absent in the
533
- * storage-only fakes, in which case the DO simply serves no live sockets.
534
- */
717
+ * The id a new record is stored under: the caller's, or a freshly minted one when
718
+ * they did not name it.
719
+ *
720
+ * A caller id that is not a safe key segment is REFUSED, not replaced.
721
+ * `RunOptions.id` is not an idempotency key — an id already scheduled answers
722
+ * `409 DUPLICATE_SCHEDULE_ID` — so quietly swapping an invalid one for a random
723
+ * id made `runAt(ts, ref, args, { id: "-daily-2026-09-06" })` mint a fresh id on
724
+ * every call and run the job once per call, where naming it was the caller's way
725
+ * of saying "at most once".
726
+ *
727
+ * Minting swaps a leading `-` for `_` rather than re-rolling: 1 in 64 minted ids
728
+ * led with one and was refused by the workflow engine on dispatch. `_` is in the
729
+ * engine's leading class, the swap is a single pass, and 96 random bits stay 96
730
+ * random bits everywhere but that first character.
731
+ *
732
+ * Exported because two surfaces have to agree on it. The SchedulerDO applies it
733
+ * when the record is written (turning the refusal into a `400`);
734
+ * `@lunora/server`'s deferred-schedule facade applies it when the call is
735
+ * BUFFERED, because it answers the handler with the id synchronously — long
736
+ * before the DO sees the request. Restating the rule in the facade is how the two
737
+ * drift: an id the facade accepted and the DO replaced leaves the handler holding
738
+ * an id no job was ever stored under, so its later `cancel` silently misses.
739
+ * @param requested the caller's `RunOptions.id`, if any
740
+ * @throws LunoraError `INVALID_SCHEDULE_ID` when `requested` is supplied and is not a safe key segment
741
+ */
742
+ declare const resolveScheduleId: (requested: unknown) => string;
743
+ /**
744
+ * Minimal projection of `DurableObjectState` for the SchedulerDO. Declared
745
+ * structurally so unit tests can pass a fake state without booting the
746
+ * workers runtime. The WebSocket methods are optional: they back the live
747
+ * `/ws` subscription (push the job list on every change) and are absent in the
748
+ * storage-only fakes, in which case the DO simply serves no live sockets.
749
+ */
535
750
  interface SchedulerDOState {
536
751
  /** Accept a hibernatable server WebSocket (workers `state.acceptWebSocket`). */
537
752
  acceptWebSocket?: (ws: WebSocket) => void;
538
753
  /** Every accepted server WebSocket (workers `state.getWebSockets`). */
539
754
  getWebSockets?: () => WebSocket[];
755
+ /**
756
+ * Register a constant ping/pong auto-response so the runtime answers a
757
+ * known keepalive frame on a hibernated socket WITHOUT waking this DO (no
758
+ * billable request, no dispatch). Optional: absent in the unit harness and
759
+ * older runtimes, present on the real `DurableObjectState`. Mirrors
760
+ * `@lunora/do`'s `ShardDOState.setWebSocketAutoResponse` — see
761
+ * {@link SchedulerDO.armWebSocketKeepalive}.
762
+ */
763
+ setWebSocketAutoResponse?: (pair: WebSocketRequestResponsePair) => void;
540
764
  storage: {
541
765
  delete: (key: string | string[]) => Promise<number | boolean>;
542
766
  deleteAlarm: () => Promise<void> | void;
@@ -546,6 +770,7 @@ interface SchedulerDOState {
546
770
  end?: string;
547
771
  limit?: number;
548
772
  prefix?: string;
773
+ startAfter?: string;
549
774
  }) => Promise<Map<string, T>>;
550
775
  put: <T = unknown>(entries: Record<string, T> | string, value?: T) => Promise<void>;
551
776
  setAlarm: (scheduledTime: number | Date) => Promise<void> | void;
@@ -554,300 +779,665 @@ interface SchedulerDOState {
554
779
  interface SchedulerEnv {
555
780
  [key: string]: unknown;
556
781
  /**
557
- * Fallback bearer token attached to the dispatch when
558
- * {@link SchedulerEnv.LUNORA_SCHEDULER_SECRET} is not configured. Sent as
559
- * `authorization: Bearer &lt;token>`.
560
- */
782
+ * Fallback bearer token attached to the dispatch when
783
+ * {@link SchedulerEnv.LUNORA_SCHEDULER_SECRET} is not configured. Sent as
784
+ * `authorization: Bearer <token>`.
785
+ */
561
786
  LUNORA_ADMIN_TOKEN?: string;
562
787
  /**
563
- * Base URL where the Worker is mounted. SchedulerDO uses this at dispatch
564
- * time to call back into the Worker. Read at fire time (NOT taken from the
565
- * request body) to prevent SSRF via a forged `originUrl` field.
566
- */
788
+ * Base URL where the Worker is mounted. SchedulerDO uses this at dispatch
789
+ * time to call back into the Worker. Read at fire time (NOT taken from the
790
+ * request body, which carries no dispatch target at all) to prevent SSRF.
791
+ */
567
792
  LUNORA_ORIGIN_URL?: string;
568
793
  /**
569
- * Shared secret used to HMAC-sign the dispatch body so the runtime receiver
570
- * can authenticate the call (header `x-lunora-scheduler-signature`). Without
571
- * it the dispatch is sent unsigned (optionally bearer-authenticated via
572
- * {@link SchedulerEnv.LUNORA_ADMIN_TOKEN}).
573
- */
794
+ * Shared secret used to HMAC-sign the dispatch body so the runtime receiver
795
+ * can authenticate the call (header `x-lunora-scheduler-signature`). Without
796
+ * it the dispatch is sent unsigned (optionally bearer-authenticated via
797
+ * {@link SchedulerEnv.LUNORA_ADMIN_TOKEN}).
798
+ */
574
799
  LUNORA_SCHEDULER_SECRET?: string;
575
800
  }
576
801
  /**
577
- * One pool's live backlog, as surfaced by `GET /status`. `inFlight`/
578
- * `maxConcurrency` mirror the durable {@link PoolState} semaphore; `queued`
579
- * is the number of pending (not-yet-dispatched) jobs routed to this pool.
580
- */
802
+ * Retries allowed after the original attempt before {@link SchedulerDO.recordRetry}
803
+ * parks a record in the dead-letter (`dead:`) prefix. Overridable per job via
804
+ * {@link RetryPolicy.maxAttempts}. Exported so test doubles of the scheduler
805
+ * (`@lunora/testing`'s fake scheduler) model the same budget instead of
806
+ * duplicating the number.
807
+ */
808
+ declare const MAX_RETRY_ATTEMPTS = 5;
809
+ /**
810
+ * Backoff before the first retry, in milliseconds; doubles on each subsequent
811
+ * retry under the default `"exponential"` backoff (`baseMs * 2 ** (attempts - 1)`).
812
+ * Overridable per job via {@link RetryPolicy.baseMs}. Exported alongside
813
+ * {@link MAX_RETRY_ATTEMPTS} for the same reason.
814
+ */
815
+ declare const RETRY_BASE_DELAY_MS = 3e4;
816
+ /**
817
+ * One pool's live backlog, as surfaced by `GET /status`. `inFlight`/
818
+ * `maxConcurrency` mirror the durable {@link PoolState} semaphore; `queued`
819
+ * is the number of pending (not-yet-dispatched) jobs routed to this pool.
820
+ */
581
821
  interface SchedulerPoolStatus {
582
822
  /** Jobs currently dispatched-but-not-yet-completed (the held slots). */
583
823
  inFlight: number;
584
824
  /** The pool's concurrency cap. */
585
825
  maxConcurrency: number;
586
- /** The logical workpool name (the `pool:&lt;name>` suffix). */
826
+ /** The logical workpool name (the `pool:<name>` suffix). */
587
827
  name: string;
588
828
  /** Pending jobs routed to this pool but not yet dispatched. */
589
829
  queued: number;
590
830
  }
591
831
  /**
592
- * App-level scheduler backlog, as returned by `GET /status`. `pools` carries
593
- * the per-pool breakdown; `backlog` and `inFlight` are the app-wide sums of
594
- * `queued` and `inFlight` across every pool — the SLO view's headline numbers.
595
- */
832
+ * App-level scheduler backlog, as returned by `GET /status`. `pools` carries
833
+ * the per-pool breakdown; `backlog` and `inFlight` are the app-wide sums of
834
+ * `queued` and `inFlight` across every pool — the SLO view's headline numbers.
835
+ */
596
836
  interface SchedulerStatus {
597
837
  /** Sum of every pool's `queued` count — the total pending backlog. */
598
838
  backlog: number;
599
839
  /** Sum of every pool's `inFlight` count — the total held concurrency slots. */
600
840
  inFlight: number;
601
- /** Per-pool backlog breakdown, one entry per `pool:&lt;name>` record. */
841
+ /** Per-pool backlog breakdown, one entry per `pool:<name>` record. */
602
842
  pools: SchedulerPoolStatus[];
603
843
  }
604
844
  /**
605
- * Durable Object that stores pending scheduled invocations sorted by their
606
- * `scheduledFor` time and fires them via HTTP on alarm. Storage layout:
607
- * `id:&lt;id>` maps to {@link ScheduleRecord}; `t:&lt;paddedTime>:&lt;id>` maps to the
608
- * id (used as a sorted index).
609
- *
610
- * On every mutation the DO recomputes the earliest pending task and updates
611
- * the alarm via `state.storage.setAlarm(time)`.
612
- */
845
+ * Durable Object that stores pending scheduled invocations sorted by their
846
+ * `scheduledFor` time and fires them via HTTP on alarm. Storage layout:
847
+ * `id:<id>` maps to {@link ScheduleRecord}; `t:<paddedTime>:<id>` maps to the
848
+ * id (used as a sorted index).
849
+ *
850
+ * On every mutation the DO recomputes the earliest pending task and updates
851
+ * the alarm via `state.storage.setAlarm(time)`.
852
+ */
613
853
  declare class SchedulerDO {
614
854
  private static indexKey;
615
855
  private static json;
616
856
  private static error;
617
857
  /**
618
- * Resolve the effective retry parameters for a record: its per-job
619
- * {@link RetryPolicy} merged over the DO's built-in defaults. Callers that
620
- * never set `record.retry` get today's behaviour verbatim
621
- * (`maxAttempts: 5`, exponential, `baseMs: 30_000`, no ceiling).
622
- */
858
+ * Resolve the effective retry parameters for a record: its per-job
859
+ * {@link RetryPolicy} merged over the DO's built-in defaults. Callers that
860
+ * never set `record.retry` get today's behaviour verbatim
861
+ * (`maxAttempts: 5`, exponential, `baseMs: 30_000`, no ceiling).
862
+ */
623
863
  private static resolveRetry;
624
864
  /** Clamp an untrusted `maxConcurrency` to a positive integer, else fall back. */
625
865
  private static normalizeConcurrency;
626
866
  /**
627
- * Sanitize an untrusted retry policy from the wire into a `RetryPolicy` (or
628
- * `undefined` when nothing valid was provided). Keeps obviously-bad values
629
- * out of storage so {@link SchedulerDO.resolveRetry} never has to re-guard.
630
- * @returns The normalized policy, or `undefined` if no valid policy was found.
631
- */
867
+ * Sanitize an untrusted retry policy from the wire into a `RetryPolicy` (or
868
+ * `undefined` when nothing valid was provided). Keeps obviously-bad values
869
+ * out of storage so {@link SchedulerDO.resolveRetry} never has to re-guard.
870
+ * @returns The normalized policy, or `undefined` if no valid policy was found.
871
+ */
632
872
  private static normalizeRetry;
633
873
  /**
634
- * Idempotently release the slot held by `jobId`, returning the updated
635
- * {@link PoolState} (pure — the caller persists it). A duplicate release for
636
- * an id that no longer holds a slot is a no-op, so an at-least-once
637
- * `/complete` (or a complete racing a failed-kick release) can never push
638
- * `inFlight` below the true number of running jobs and oversubscribe the
639
- * pool. Pools persisted before `inFlightIds` existed fall back to a clamped
640
- * counter decrement.
641
- */
874
+ * Idempotently release the slot held by `jobId`, returning the updated
875
+ * {@link PoolState} (pure — the caller persists it). A duplicate release for
876
+ * an id that no longer holds a slot is a no-op, so an at-least-once
877
+ * `/complete` (or a complete racing a failed-kick release) can never push
878
+ * `inFlight` below the true number of running jobs and oversubscribe the
879
+ * pool. Pools persisted before `inFlightIds` existed fall back to a clamped
880
+ * counter decrement.
881
+ */
642
882
  private static releaseSlot;
643
883
  /**
644
- * Best-effort release with no job id (legacy `/complete` payloads). Drops one
645
- * tracked id if the set exists, else clamps the counter. Less precise than
646
- * {@link SchedulerDO.releaseSlot} — a duplicate id-less complete CAN
647
- * over-release — but every current client sends the id, so this is the
648
- * compatibility shim, not the hot path.
649
- */
884
+ * Best-effort release with no job id (legacy `/complete` payloads). Drops one
885
+ * tracked id if the set exists, else clamps the counter. Less precise than
886
+ * {@link SchedulerDO.releaseSlot} — a duplicate id-less complete CAN
887
+ * over-release — but every current client sends the id, so this is the
888
+ * compatibility shim, not the hot path.
889
+ */
650
890
  private static releaseFirstSlot;
651
891
  /**
652
- * Normalize the mutually-exclusive dispatch target off an untrusted body: a
653
- * one-shot function path (`functionPath`) or a durable workflow/agent
654
- * instance (`workflow`, a `WORKFLOW_*`/`AGENT_*` binding). Returns `undefined`
655
- * when neither is present so the caller can reject the schedule.
656
- */
892
+ * Normalize the mutually-exclusive dispatch target off an untrusted body: a
893
+ * one-shot function path (`functionPath`) or a durable workflow/agent
894
+ * instance (`workflow`, a `WORKFLOW_*`/`AGENT_*` binding). Returns `undefined`
895
+ * when neither is present so the caller can reject the schedule.
896
+ */
657
897
  private static resolveScheduleTarget;
658
898
  protected readonly state: SchedulerDOState;
659
899
  protected readonly env: SchedulerEnv;
900
+ /**
901
+ * Whether {@link SchedulerDO.reindexOrphanedRecords} has already run in THIS
902
+ * instance. Once is enough: an orphan can only be minted by an eviction, and
903
+ * an eviction ends the instance that minted it.
904
+ */
905
+ private reindexed;
906
+ /**
907
+ * Tail of the serialized `pool:<name>` critical section — see
908
+ * the pool lock (`withPoolLock`). Per-instance, which is the right scope: the pool rows
909
+ * live in this Durable Object's own storage and only this instance writes
910
+ * them.
911
+ */
912
+ private poolLock;
913
+ /**
914
+ * Record id → the `t:` index key its in-flight claim currently holds, for
915
+ * every dispatch this instance has open. See `drainRecordGuarded()`.
916
+ *
917
+ * A lease moves a record's index entry without rewriting its `scheduledFor`,
918
+ * so while a claim is held the live key is NOT the one derivable from the
919
+ * record. Anything that has to remove such a record — `removeRecord()`, on
920
+ * the `/cancel` path — would otherwise delete a key that no longer exists
921
+ * and strand the real one.
922
+ *
923
+ * In-memory and per-instance on purpose: it answers "is THIS instance
924
+ * dispatching that record right now", which is exactly when the divergence
925
+ * can be observed by another request. A lease left behind by an instance that
926
+ * died has no live dispatch to protect and is reconciled from storage
927
+ * instead — `alarm()` drops it as a dangling entry once the header is gone.
928
+ */
929
+ private readonly activeLeases;
660
930
  constructor(state: SchedulerDOState, env: SchedulerEnv);
661
931
  fetch(request: Request): Promise<Response>;
662
- /** Called by the Workers runtime when the alarm previously set by `_rescheduleAlarm()` fires. */
932
+ /** Called by the Workers runtime when the alarm previously set by `rescheduleAlarm()` fires. */
663
933
  alarm(): Promise<void>;
664
934
  /**
665
- * Internal dispatch hook; overridden in unit tests to capture the outgoing
666
- * request. Returns `true` ONLY on an explicit 2xx response (`response.ok`).
667
- * Anything else — a network failure, a 5xx, OR a non-2xx such as 404
668
- * (receiver route not mounted) / 401 / 403 / 4xx — returns `false` and
669
- * enters the retry pipeline via {@link recordRetry}. Treating 4xx as
670
- * success used to permanently delete the job; since the receiver may simply
671
- * be missing (404) or transiently failing, we retry rather than silently
672
- * drop. After {@link MAX_RETRY_ATTEMPTS} the record is parked under a
673
- * `dead:` key for inspection — never silently deleted.
674
- *
675
- * The dispatch target is taken from `env.LUNORA_ORIGIN_URL` (NOT from the
676
- * stored record) to prevent SSRF via a forged `originUrl` on the schedule
677
- * request. If that env var is missing at fire time (a deploy/binding
678
- * regression — schedule time already enforced its presence) we return
679
- * `false` so the record is retried rather than silently dropped.
680
- */
681
- protected dispatch(record: ScheduleRecord): Promise<boolean>;
682
- /**
683
- * Claim + drain one due record with per-record fault isolation, so a storage
684
- * throw can never abort the whole alarm pass (which would skip the remaining
685
- * due records and the `rescheduleAlarm()` that re-arms the clock).
686
- *
687
- * Claims the job by deleting its time-index entry BEFORE dispatch (an alarm
688
- * re-fire then won't pick it up again), runs {@link drainRecord}, and on a
689
- * thrown storage op re-asserts the claim so the job stays re-fireable.
690
- *
691
- * A throw reaching here always means the job was NOT dispatched:
692
- * {@link drainRecord} swallows its own post-dispatch cleanup errors and
693
- * returns instead of throwing once a kick succeeds, so every escaping throw
694
- * comes from the pre-dispatch or failed-dispatch paths. We therefore always
695
- * re-assert the time-index claim so a later alarm re-attempts it
696
- * (at-least-once): the claim delete may have removed it and
697
- * recordRetry()/requeuePooled() may not have re-armed it before throwing, and
698
- * re-inserting the same key is idempotent, so a surviving claim is simply
699
- * rewritten to its prior value.
700
- */
935
+ * Internal dispatch hook; overridden in unit tests to capture the outgoing
936
+ * request. Returns `true` ONLY on an explicit 2xx response (`response.ok`).
937
+ * Anything else — a network failure, a 5xx, OR a non-2xx such as 404
938
+ * (receiver route not mounted) / 401 / 403 / 4xx — returns `false` and
939
+ * enters the retry pipeline via {@link recordRetry}. Treating 4xx as
940
+ * success used to permanently delete the job; since the receiver may simply
941
+ * be missing (404) or transiently failing, we retry rather than silently
942
+ * drop. After {@link MAX_RETRY_ATTEMPTS} the record is parked under a
943
+ * `dead:` key for inspection — never silently deleted.
944
+ *
945
+ * One non-2xx is told apart: a `409` carrying `DISPATCH_IN_PROGRESS` returns
946
+ * `"in-progress"`. The shard is saying the first attempt of this very record
947
+ * is still running, so {@link recordRetry} re-arms it without charging an
948
+ * attempt — see there.
949
+ *
950
+ * The dispatch target is taken from `env.LUNORA_ORIGIN_URL` (NOT from the
951
+ * stored record) so a schedule request can never name where the DO calls
952
+ * back — that would be SSRF. If that env var is missing at fire time (a deploy/binding
953
+ * regression — schedule time already enforced its presence) we return
954
+ * `false` so the record is retried rather than silently dropped.
955
+ */
956
+ protected dispatch(record: ScheduleRecord): Promise<boolean | "in-progress">;
957
+ /**
958
+ * Register the hibernation-safe ping/pong keepalive. The runtime answers a
959
+ * {@link WS_KEEPALIVE_PING} text frame with {@link WS_KEEPALIVE_PONG}
960
+ * WITHOUT waking this Durable Object, keeping an idle `/ws` subscription
961
+ * alive across hibernation with no billable wakeup and no dispatch. Without
962
+ * this, a client's heartbeat ping goes unanswered and its watchdog force-
963
+ * closes the socket every ~90s, defeating hibernation (each unanswered ping
964
+ * wakes the DO to reconnect) — mirrors `@lunora/do`'s
965
+ * `ShardDO.armWebSocketKeepalive`. The auto-response is per-instance, so
966
+ * this re-runs on every construction (including a post-hibernation wake).
967
+ * Guarded: the API and the `WebSocketRequestResponsePair` global are absent
968
+ * in the unit harness and on older runtimes, where it degrades to a no-op.
969
+ */
970
+ private armWebSocketKeepalive;
971
+ /**
972
+ * Drain the due slice with up to {@link MAX_CONCURRENT_DISPATCHES} records in
973
+ * flight at once, so a slow job delays only its own lane instead of every
974
+ * other due job in the app (see the constant for why the drain used to
975
+ * serialise whole jobs, not just their kicks).
976
+ *
977
+ * Lanes pull from the head of the slice, so records still ENTER dispatch in
978
+ * the slice's order — the same `t:<paddedTime>:<id>` order the sequential
979
+ * drain used. What is no longer implied is that they FINISH in that order,
980
+ * which was never a guarantee worth relying on anyway: two jobs due at the
981
+ * same instant already ran in storage-key (id) order rather than arrival
982
+ * order, a saturated pooled job is pushed {@link POOL_BACKPRESSURE_DELAY_MS}
983
+ * into the future ahead of its queue-mates, and a failed job re-enters at the
984
+ * end of a backoff. Nothing in the public surface documents an ordering
985
+ * guarantee; jobs that must be ordered must chain themselves.
986
+ *
987
+ * {@link drainRecordGuarded} swallows every throw, so no lane can reject and
988
+ * abandon its siblings.
989
+ */
990
+ private drainDue;
991
+ /**
992
+ * Run `critical` with exclusive access to the `pool:<name>` rows.
993
+ *
994
+ * Every pool mutation is a read-modify-write (`loadPool` → mutate →
995
+ * `savePool`), and the two halves are separated by an `await`. That was safe
996
+ * while the drain ran one record at a time — the Durable Object input gate
997
+ * keeps a foreign event (a concurrent `/complete`) out while a storage
998
+ * operation is in flight, and nothing else in this instance could interleave.
999
+ * Concurrent lanes break exactly that assumption: lane A and lane B can both
1000
+ * issue their `get` before either `put` lands, both observe `inFlight: 0`,
1001
+ * and the second `put` then erases the first lane's reservation — the pool
1002
+ * oversubscribes and one holder's id is lost, so its slot is never released.
1003
+ *
1004
+ * The lock is a promise chain rather than anything cleverer because the
1005
+ * critical section is two local storage ops with no I/O in it. It is held
1006
+ * across NO outbound fetch: `dispatch()` runs outside it, which is the whole
1007
+ * point of draining concurrently.
1008
+ *
1009
+ * The chain is rebuilt from a swallowed copy so one rejecting section can
1010
+ * never wedge every later one.
1011
+ */
1012
+ private withPoolLock;
1013
+ /**
1014
+ * Claim + drain one due record with per-record fault isolation, so a storage
1015
+ * throw can never abort the whole alarm pass (which would skip the remaining
1016
+ * due records and the `rescheduleAlarm()` that re-arms the clock).
1017
+ *
1018
+ * The claim is a LEASE, not a deletion. The record's `t:` entry is moved
1019
+ * from its due time to `now + DISPATCH_LEASE_MS` before
1020
+ * {@link SchedulerDO.dispatch} is called, so this alarm pass (and the next)
1021
+ * will not pick it up again, while the record is never left WITHOUT an index
1022
+ * entry. That distinction is the whole point: deleting the entry outright
1023
+ * made an instance evicted mid-dispatch leave an `id:` header with no index,
1024
+ * which {@link SchedulerDO.reindexOrphanedRecords} re-armed and the
1025
+ * successor fired AGAIN on sight — concurrently with an attempt that could
1026
+ * still be running at the origin. A leased record is not an orphan, so the
1027
+ * successor leaves it alone until the lease lapses; see
1028
+ * {@link DISPATCH_LEASE_MS} for why that horizon is fifteen minutes and what
1029
+ * it does and does not bound.
1030
+ *
1031
+ * The lease is released as soon as this instance knows the attempt settled —
1032
+ * dispatched, re-armed for retry, backpressured, or dead-lettered — so the
1033
+ * horizon only ever governs the one case nobody is left to report: a lost
1034
+ * instance.
1035
+ *
1036
+ * A throw reaching here always means the job was NOT dispatched:
1037
+ * {@link drainRecord} swallows its own post-dispatch cleanup errors and
1038
+ * returns instead of throwing once a kick succeeds, so every escaping throw
1039
+ * comes from the pre-dispatch or failed-dispatch paths. Nothing is in flight,
1040
+ * so the lease is dropped and the due-time claim re-asserted — keeping the
1041
+ * job re-fireable on the very next alarm (at-least-once) rather than letting
1042
+ * a transient storage blip cost it a whole lease.
1043
+ *
1044
+ * With one exception, checked first: a record that already has a durable
1045
+ * `dead:` row is TERMINAL, and re-claiming it would re-dispatch a job the
1046
+ * dead-letter says is finished. See the comment on that branch.
1047
+ *
1048
+ * `claimKey` is the index key `alarm()` SELECTED this record under, passed
1049
+ * down rather than recomputed. That is load-bearing, not tidiness: a lease
1050
+ * moves the record's index entry and deliberately does NOT rewrite its
1051
+ * `scheduledFor` (that field is the job's real due time, which `/list`,
1052
+ * `/get`, `/dead` and the studio all show, and which `parkDead` preserves).
1053
+ * So from the moment a lease is taken the live key and the key derivable
1054
+ * from the record disagree — and a claim that recomputed it would delete a
1055
+ * key that no longer exists while adding a second one, leaving the record
1056
+ * indexed twice and dispatched twice. That is the same double-run the lease
1057
+ * exists to close, re-entering through the expiry path.
1058
+ */
701
1059
  private drainRecordGuarded;
702
1060
  /**
703
- * Process one due (already index-claimed) record within an alarm drain:
704
- * apply the workpool concurrency gate, dispatch, and settle the result.
705
- * A saturated pool re-arms the job (backpressure, no attempt charged); a
706
- * free slot is reserved durably before dispatch and released immediately if
707
- * the kick fails (success holds it until the runtime reports completion).
708
- * Success clears the `id:`/`retry:` rows; failure routes to
709
- * {@link recordRetry}. Pool state is read FRESH from storage per record (see
710
- * {@link reservePoolSlot}) and never held across the dispatch() await, so a
711
- * concurrent /complete landing mid-dispatch can't be clobbered.
712
- * Once a kick succeeds, post-dispatch cleanup (clearing the `id:`/`retry:`
713
- * rows) is swallowed rather than allowed to throw, so a successful dispatch
714
- * NEVER propagates an error to {@link drainRecordGuarded}: every throw that
715
- * escapes comes from the pre-dispatch or failed-dispatch paths, where the job
716
- * is still re-fireable and the guard safely re-claims the time index.
717
- * @returns `true` only when the record was successfully dispatched (a 2xx
718
- * kick); `false` on pool backpressure or a failed dispatch (the job is still
719
- * re-fireable — already re-armed here). The value is informational (the guard
720
- * branches on throw/no-throw, not on this boolean).
721
- */
1061
+ * Process one due (already index-claimed) record within an alarm drain:
1062
+ * apply the workpool concurrency gate, dispatch, and settle the result.
1063
+ * A saturated pool re-arms the job (backpressure, no attempt charged); a
1064
+ * free slot is reserved durably before dispatch and released immediately if
1065
+ * the kick fails (success holds it until the runtime reports completion).
1066
+ * Success clears the `id:`/`retry:` rows; failure routes to
1067
+ * {@link recordRetry}. Pool state is read FRESH from storage per record (see
1068
+ * {@link reservePoolSlot}) and never held across the dispatch() await, so a
1069
+ * concurrent /complete landing mid-dispatch can't be clobbered.
1070
+ * Once a kick succeeds, post-dispatch cleanup (clearing the `id:`/`retry:`
1071
+ * rows) is swallowed rather than allowed to throw, so a successful dispatch
1072
+ * NEVER propagates an error to {@link drainRecordGuarded}: every throw that
1073
+ * escapes comes from the pre-dispatch or failed-dispatch paths, where the job
1074
+ * is still re-fireable and the guard safely re-claims the time index.
1075
+ * @returns `true` only when the record was successfully dispatched (a 2xx
1076
+ * kick); `false` on pool backpressure or a failed dispatch (the job is still
1077
+ * re-fireable — already re-armed here). The value is informational (the guard
1078
+ * branches on throw/no-throw, not on this boolean).
1079
+ */
722
1080
  private drainRecord;
723
1081
  /**
724
- * Concurrency gate for a pooled record. Returns `false` (and re-arms the
725
- * job via {@link requeuePooled}) when the pool is at `maxConcurrency`;
726
- * otherwise reserves a slot durably and returns `true`. Non-pooled records
727
- * always return `true` without touching any pool state.
728
- *
729
- * The pool row is read FRESH from storage on every call — never cached
730
- * across the drain. Each reservation durably `savePool()`s before the next
731
- * record runs, so a same-pass reservation is still visible to the next
732
- * record's fresh read (the budget carries forward); and because dispatch()
733
- * awaits an outbound fetch between records, a concurrent /complete that
734
- * decrements the row mid-drain IS reflected here instead of being clobbered
735
- * by a stale in-memory copy (which would leak a slot permanently).
736
- */
1082
+ * Concurrency gate for a pooled record. Returns `false` (and re-arms the
1083
+ * job via {@link requeuePooled}) when the pool is at `maxConcurrency`;
1084
+ * otherwise reserves a slot durably and returns `true`. Non-pooled records
1085
+ * always return `true` without touching any pool state.
1086
+ *
1087
+ * The pool row is read FRESH from storage on every call — never cached
1088
+ * across the drain — and the read-modify-write runs under
1089
+ * the pool lock (`withPoolLock`). Both halves are load-bearing. Freshness is what
1090
+ * keeps a concurrent `/complete` landing during a dispatch from being
1091
+ * clobbered by a stale in-memory copy (which would leak a slot permanently: a
1092
+ * POOL SLOT has no expiry, unlike the dispatch claim's). The lock is what
1093
+ * keeps two drain lanes from both reading the same pre-reservation row and
1094
+ * both believing a slot was free — without it the pool oversubscribes past
1095
+ * `maxConcurrency` and one holder's id is dropped from `inFlightIds`, so its
1096
+ * slot is never released.
1097
+ */
737
1098
  private reservePoolSlot;
738
1099
  /**
739
- * Accept a hibernatable live subscription to the job list. The scheduler has
740
- * exactly one subscription shape (the whole list), so there's no per-socket
741
- * registry or dependency tracking — every accepted socket gets the full list
742
- * on connect and on every change. The worker is responsible for gating the
743
- * upgrade behind the admin token before it reaches here.
744
- */
1100
+ * Accept a hibernatable live subscription to the job list. The scheduler has
1101
+ * exactly one subscription shape (the whole list), so there's no per-socket
1102
+ * registry or dependency tracking — every accepted socket gets the full list
1103
+ * on connect and on every change. The worker is responsible for gating the
1104
+ * upgrade behind the admin token before it reaches here.
1105
+ */
745
1106
  private handleWebSocketUpgrade;
746
1107
  /**
747
- * Re-list the jobs and push them to every connected subscriber. Called after
748
- * any change (schedule / cancel / alarm-fire) so live studios reflect it
749
- * immediately. A no-op when the runtime doesn't support hibernated sockets.
750
- */
1108
+ * Re-list the jobs (bounded — see {@link listPage}) and push them to
1109
+ * every connected subscriber. Called after any change (schedule / cancel /
1110
+ * alarm-fire) so live studios reflect it immediately. A no-op when the
1111
+ * runtime doesn't support hibernated sockets.
1112
+ */
751
1113
  private broadcastChange;
752
- /** The current pending job records (shared by `/list` and the live channel). */
753
- private listRecords;
754
- /**
755
- * HMAC-SHA-256 sign the dispatch body with `env.LUNORA_SCHEDULER_SECRET`,
756
- * returning a base64url signature, or `undefined` when no secret is
757
- * configured. Mirrors `@lunora/storage`'s signed-URL HMAC pattern (WebCrypto
758
- * `crypto.subtle`, available in workerd).
759
- */
1114
+ /**
1115
+ * One bounded page of the rows under `prefix`, in key order, plus the
1116
+ * `cursor` a caller resumes from (the last key of the page) when `truncated`.
1117
+ * Lists `limit + 1` and slices back down so both facts are known without a
1118
+ * second round-trip.
1119
+ *
1120
+ * Shared by `/list` (pending headers) and `/dead` (dead-letter records) so
1121
+ * NEITHER can materialize an unbounded set into one JSON response: nothing
1122
+ * prunes `dead:`, so a workpool with a broken origin parks thousands of rows
1123
+ * and the studio's only view of them — and only way to requeue them — would
1124
+ * fail exactly when it is needed.
1125
+ */
1126
+ private listPage;
1127
+ /**
1128
+ * Page through every row under `prefix` exactly once with bounded per-page
1129
+ * memory (a `limit`+`startAfter` cursor loop), invoking `visit` for each.
1130
+ * Unlike {@link listPage}, which intentionally truncates for the studio's
1131
+ * live view, `/status` and `/pool` need EXACT counts — this walks the full
1132
+ * set, but never materializes more than one page at a time.
1133
+ */
1134
+ private forEachPage;
1135
+ /**
1136
+ * HMAC-SHA-256 sign the dispatch body with `env.LUNORA_SCHEDULER_SECRET`,
1137
+ * returning a base64url signature, or `undefined` when no secret is
1138
+ * configured. Mirrors `@lunora/storage`'s signed-URL HMAC pattern (WebCrypto
1139
+ * `crypto.subtle`, available in workerd).
1140
+ */
760
1141
  private signDispatch;
761
1142
  /**
762
- * Move a failed record into the retry pipeline with configurable backoff.
763
- * The retry budget/backoff comes from the record's {@link RetryPolicy}
764
- * (falling back to the DO defaults); on exhaustion the record is parked
765
- * under a `dead:` key for manual inspection.
766
- */
1143
+ * Move a failed record into the retry pipeline with configurable backoff.
1144
+ * The retry budget/backoff comes from the record's {@link RetryPolicy}
1145
+ * (falling back to the DO defaults); on exhaustion the record is parked
1146
+ * under a `dead:` key for manual inspection.
1147
+ *
1148
+ * **A `DISPATCH_IN_PROGRESS` decline (`inProgress`) is not charged.** It
1149
+ * means the record's first attempt is still running on the shard, not that
1150
+ * anything failed; charging it would spend the budget while that attempt is
1151
+ * doing the work, and if it then failed the job would dead-letter having
1152
+ * run once. It still re-arms at the backoff of the NEXT charged attempt, at
1153
+ * least {@link RETRY_BASE_DELAY_MS} apart — nothing else rate-limits an
1154
+ * uncharged retry, and polling a running attempt faster gains nothing.
1155
+ *
1156
+ * It is bounded, not forever, only because the shard's claim is: a claim
1157
+ * older than the shard's fifteen-minute ceiling is treated as stale and the
1158
+ * next delivery runs (and is charged as usual if it fails). A handler that
1159
+ * hangs therefore costs at most that long in declines before the ordinary
1160
+ * budget applies again. Without that ceiling, not charging would let one hung
1161
+ * handler keep its record retrying and never dead-letter.
1162
+ */
767
1163
  private recordRetry;
768
- /** Read the durable `pool:&lt;name>` row, defaulting to a fresh `inFlight: 0` pool. */
1164
+ /**
1165
+ * Terminal park into the dead-letter (`dead:`) prefix, with `reason` naming
1166
+ * why in the emitted warning. Shared by the two ways a retry ends for good:
1167
+ * an exhausted attempt budget, and a backoff that ran past the largest
1168
+ * schedulable time (see {@link isIndexableTime}).
1169
+ */
1170
+ private parkDead;
1171
+ /** Read the durable `pool:<name>` row, defaulting to a fresh `inFlight: 0` pool. */
769
1172
  private loadPool;
770
1173
  private savePool;
771
1174
  /**
772
- * Re-arm a pooled job that couldn't run because its pool was at capacity.
773
- * No attempt is charged (this is backpressure, not a failure): the job is
774
- * pushed `POOL_BACKPRESSURE_DELAY_MS` into the future so a later alarm
775
- * drains it once a slot frees, keeping its `id:` header and retry policy.
776
- */
1175
+ * Re-arm a pooled job that couldn't run because its pool was at capacity.
1176
+ * No attempt is charged (this is backpressure, not a failure): the job is
1177
+ * pushed `POOL_BACKPRESSURE_DELAY_MS` into the future so a later alarm
1178
+ * drains it once a slot frees, keeping its `id:` header and retry policy.
1179
+ */
777
1180
  private requeuePooled;
778
1181
  /**
779
- * Release a pool slot when the runtime reports an action finished. This is
780
- * the durable-semaphore decrement: dispatch() only KICKS the action and
781
- * holds the slot; the runtime calls back here (`POST /complete { id }`) once
782
- * the action settles, freeing the slot for the next queued job. Idempotent
783
- * and safe if the job/pool is already gone.
784
- */
1182
+ * Release a pool slot when the runtime reports an action finished. This is
1183
+ * the durable-semaphore decrement: dispatch() only KICKS the action and
1184
+ * holds the slot; the runtime calls back here (`POST /complete { id }`) once
1185
+ * the action settles, freeing the slot for the next queued job. Idempotent
1186
+ * and safe if the job/pool is already gone.
1187
+ */
785
1188
  private handleComplete;
786
1189
  /** `GET /pool?name=` — inspect a pool's slot usage + queued count. */
787
1190
  private handlePoolStatus;
788
1191
  /**
789
- * `GET /status` — the app-level backlog signal that powers the studio's
790
- * SLO view. Enumerates every durable `pool:&lt;name>` row for its `inFlight`/
791
- * `maxConcurrency` semaphore, counts the pending (not-yet-dispatched) jobs
792
- * routed to each pool with the same single-pass scan {@link handlePoolStatus}
793
- * uses, and rolls those up into app-wide `backlog` (sum of `queued`) and
794
- * `inFlight` (sum of held slots) totals.
795
- *
796
- * Pools that have rows but no queued jobs still appear (with `queued: 0`) so
797
- * a saturated-but-idle pool stays visible; a pool that only ever existed as
798
- * queued jobs without a persisted row is unreachable here (the schedule path
799
- * always writes a `pool:&lt;name>` row before the job's header), so a single
800
- * scan over `pool:`/`id:` is sufficient.
801
- */
1192
+ * `GET /status` — the app-level backlog signal that powers the studio's
1193
+ * SLO view. Enumerates every durable `pool:<name>` row for its `inFlight`/
1194
+ * `maxConcurrency` semaphore, counts the pending (not-yet-dispatched) jobs
1195
+ * routed to each pool with the same single-pass scan {@link handlePoolStatus}
1196
+ * uses, and rolls those up into app-wide `backlog` (sum of `queued`) and
1197
+ * `inFlight` (sum of held slots) totals.
1198
+ *
1199
+ * Pools that have rows but no queued jobs still appear (with `queued: 0`) so
1200
+ * a saturated-but-idle pool stays visible; a pool that only ever existed as
1201
+ * queued jobs without a persisted row is unreachable here (the schedule path
1202
+ * always writes a `pool:<name>` row before the job's header), so a single
1203
+ * scan over `pool:` plus a cursor loop over `id:` is sufficient.
1204
+ */
802
1205
  private handleStatus;
1206
+ /**
1207
+ * Persist (or refresh) a pool's concurrency cap, so the alarm-time gate has
1208
+ * a durable `maxConcurrency` even after the enqueuing client is gone.
1209
+ */
1210
+ private persistPoolCap;
1211
+ /**
1212
+ * The `409` a caller-supplied id earns when something durable already holds
1213
+ * it, or `undefined` when the id is free.
1214
+ *
1215
+ * A pending header is the obvious half: `put` on `id:<id>` overwrites, but
1216
+ * the `t:` index is keyed by TIME as well as id, so the OLD entry survives.
1217
+ * The drain then dispatches the NEW record at the OLD time and deletes the
1218
+ * entry it should have fired at — the job runs early and never runs again.
1219
+ * Refused rather than made a replace: `RunOptions.id` exists so a deferred
1220
+ * schedule can name its own job, and silently retiming someone else's is the
1221
+ * worse failure.
1222
+ *
1223
+ * The `dead:` row holds the id too, and for a worse reason. A dead record
1224
+ * keeps NO `id:` header, so a pending-only check leaves the id apparently
1225
+ * free — and a later `/dead/retry` writes the revived corpse straight over
1226
+ * the new job's header and adds a SECOND time index under the same id. The
1227
+ * new job is gone and the dead one fires in its place. Recovering a dead job
1228
+ * is an operator action taken minutes or days after the schedule, so nothing
1229
+ * at schedule time would ever have surfaced the collision.
1230
+ */
1231
+ private idConflict;
1232
+ /**
1233
+ * The id the record is stored under, or the `Response` refusing it.
1234
+ *
1235
+ * A caller id that is not a safe key segment is refused with a `400` rather
1236
+ * than minted over: `RunOptions.id` is not an idempotency key, so swapping an
1237
+ * invalid one for a random id made two calls naming it schedule two jobs
1238
+ * where the second should have answered `409`.
1239
+ *
1240
+ * Only an id the CALLER chose can collide — a minted one is 96 random bits —
1241
+ * so {@link idConflict} costs two `get`s on the deferred path and nothing on
1242
+ * the ordinary one.
1243
+ */
1244
+ private resolveId;
803
1245
  private handleSchedule;
804
1246
  private handleCancel;
1247
+ /**
1248
+ * `GET /list[?cursor=]` — one bounded page of pending jobs. `truncated` says
1249
+ * whether more rows follow and `cursor` is what a caller passes back to get
1250
+ * them (`createScheduler.list()` walks every page; the studio shows one).
1251
+ */
805
1252
  private handleList;
806
1253
  /**
807
- * `GET /dead` — list the dead-letter records: jobs that exhausted their
808
- * retry budget ({@link recordRetry}) and were parked under `dead:&lt;id>`
809
- * instead of being silently dropped. These never appear in `/list` (their
810
- * `id:` header is deleted on park), so this is the ONLY way the studio can
811
- * surface — and recover — a permanently-failed job.
812
- */
1254
+ * `GET /dead` — list the dead-letter records: jobs that exhausted their
1255
+ * retry budget ({@link recordRetry}) and were parked under `dead:<id>`
1256
+ * instead of being silently dropped. These never appear in `/list` (their
1257
+ * `id:` header is deleted on park), so this is the ONLY way the studio can
1258
+ * surface — and recover — a permanently-failed job. Bounded and cursored
1259
+ * like `/list`: nothing prunes `dead:`, so this set grows without limit.
1260
+ */
813
1261
  private handleDeadList;
814
1262
  /**
815
- * `POST /dead/retry { id }` — resurrect a dead-letter record: reset its
816
- * exhausted attempt count to 0 (a fresh retry budget), re-arm it for
817
- * immediate dispatch via the standard time index, and drop the `dead:` row.
818
- * The new `id:` header makes it visible to `/list` and the live `/ws`
819
- * subscription again. A miss is a no-op (`{ retried: false }`).
820
- */
1263
+ * `POST /dead/retry { id }` — resurrect a dead-letter record: reset its
1264
+ * exhausted attempt count to 0 (a fresh retry budget), re-arm it for
1265
+ * immediate dispatch via the standard time index, and drop the `dead:` row.
1266
+ * The new `id:` header makes it visible to `/list` and the live `/ws`
1267
+ * subscription again. A miss is a no-op (`{ retried: false }`).
1268
+ */
821
1269
  private handleDeadRetry;
822
1270
  /**
823
- * `POST /dead/cancel { id }` — permanently drop a dead-letter record the
824
- * operator has decided not to recover. Returns `{ removed }` (false when
825
- * nothing matched). Idempotent: a repeated purge is a harmless no-op.
826
- */
1271
+ * `POST /dead/cancel { id }` — permanently drop a dead-letter record the
1272
+ * operator has decided not to recover. Returns `{ removed }` (false when
1273
+ * nothing matched). Idempotent: a repeated purge is a harmless no-op.
1274
+ */
827
1275
  private handleDeadCancel;
828
1276
  /**
829
- * Resolve a single pending job by id via a direct `id:&lt;id>` storage read —
830
- * O(1), versus scanning the whole `/list` view. Responds `{ record }` on a
831
- * hit and `{}` on a miss (an absent `record` field — JSON has no `undefined`
832
- * — which the client reads back as `null`).
833
- */
1277
+ * Resolve a single pending job by id via a direct `id:<id>` storage read —
1278
+ * O(1), versus scanning the whole `/list` view. Responds `{ record }` on a
1279
+ * hit and `{}` on a miss (an absent `record` field — JSON has no `undefined`
1280
+ * — which the client reads back as `null`).
1281
+ */
834
1282
  private handleGet;
835
1283
  private removeRecord;
836
1284
  /**
837
- * Arm the alarm for `scheduledFor` only if it is sooner than the currently
838
- * set alarm (or none is set). Used on the schedule path: inserting a job
839
- * can only ever pull the earliest-pending time *earlier*, never later, so a
840
- * full `t:` rescan is unnecessary unless the new job is the new earliest.
841
- */
1285
+ * Arm the alarm for `scheduledFor` only if it is sooner than the currently
1286
+ * set alarm (or none is set). Used on the schedule path: inserting a job
1287
+ * can only ever pull the earliest-pending time *earlier*, never later, so a
1288
+ * full `t:` rescan is unnecessary unless the new job is the new earliest.
1289
+ */
842
1290
  private armAlarmIfEarlier;
1291
+ /**
1292
+ * Re-index every pending job whose time-index entry is gone, and fire it now.
1293
+ *
1294
+ * A record with an `id:` header but no `t:` entry is invisible to every
1295
+ * clock in this class: {@link SchedulerDO.rescheduleAlarm} derives the alarm
1296
+ * from `t:` alone, and `alarm()`'s inline reconciliation only handles the
1297
+ * INVERSE orphan (a `t:` entry whose header is gone). Left alone such a job
1298
+ * sits in `/list` and `/status.backlog` forever — never fires, never reaches
1299
+ * `/dead`.
1300
+ *
1301
+ * **This is no longer how a lost dispatch looks.**
1302
+ * {@link SchedulerDO.drainRecordGuarded} used to claim a record by deleting
1303
+ * its `t:` entry outright, so an instance evicted mid-dispatch minted an
1304
+ * orphan on every claimed record and this method re-fired each of them ON
1305
+ * SIGHT — concurrently with attempts that could still have been running at
1306
+ * the origin. The claim is a lease now (see {@link DISPATCH_LEASE_MS}): a
1307
+ * claimed record keeps a `t:` entry at the lease horizon, so it is not an
1308
+ * orphan, is not recovered here, and is re-fired by the ordinary alarm drain
1309
+ * only once the lease lapses.
1310
+ *
1311
+ * What still reaches this method is the residue of a storage failure — a
1312
+ * claim released with no lease written, or a pre-lease record written by an
1313
+ * older build — where nothing was dispatched and firing immediately is
1314
+ * exactly right.
1315
+ *
1316
+ * The dispatch is deduplicated by the record id, which the receiver spends
1317
+ * as `x-lunora-mutation-id` for a function target and as the workflow
1318
+ * INSTANCE id for a `workflow` target. A mutation's dedup read runs inside
1319
+ * the shard's single-writer gate, so a mutation is exactly-once even under a
1320
+ * genuinely concurrent re-fire; a workflow re-attaches to the running
1321
+ * instance rather than starting a second one. An ACTION takes no gate —
1322
+ * gating one would let any caller freeze a whole shard for the length of an
1323
+ * action's outbound I/O — and its dedup row is written only after the
1324
+ * handler returns, so the cache alone could not tell "already ran" from
1325
+ * "still running". The shard closes that itself (#803): it claims the dedup
1326
+ * key before entering the handler and answers a second delivery of a live
1327
+ * key `409 DISPATCH_IN_PROGRESS`, which is a non-2xx, so the record is
1328
+ * re-armed rather than cleared.
1329
+ *
1330
+ * Two bounded walks (all `t:` values, then all `id:` headers) rather than a
1331
+ * per-header `get`, so the cost is one pass over each prefix.
1332
+ */
1333
+ private reindexOrphanedRecords;
1334
+ /**
1335
+ * The time component of the earliest pending `t:` entry, or `undefined` when
1336
+ * there is no pending entry at all. Reads exactly one row — the index's
1337
+ * lexical order is its numeric order. A present-but-unreadable key yields
1338
+ * `NaN`, which callers must distinguish from "nothing pending": the two
1339
+ * answers mean opposite things for the alarm.
1340
+ */
1341
+ private earliestPendingTime;
843
1342
  private rescheduleAlarm;
844
1343
  }
1344
+ /** What the Cloudflare scheduler host needs from the Worker's environment. */
1345
+ interface SchedulerHostOptions {
1346
+ /**
1347
+ * Named scheduler instance — one `SchedulerDO` per name, useful for tenant
1348
+ * isolation. Defaults to `"default"`.
1349
+ */
1350
+ instanceName?: string;
1351
+ /**
1352
+ * Data-residency jurisdiction for the `SchedulerDO`. Pass the same value as
1353
+ * the worker's own jurisdiction so scheduled state co-resides with app data.
1354
+ */
1355
+ jurisdiction?: "eu" | "fedramp" | "us";
1356
+ /**
1357
+ * The `SchedulerDO` namespace binding.
1358
+ *
1359
+ * The origin the DO dispatches back to is not configured here — it reads
1360
+ * `env.LUNORA_ORIGIN_URL` off its own binding at fire time, so a wrong value
1361
+ * there (or none) is what makes jobs fire into nothing.
1362
+ */
1363
+ namespace: Parameters<typeof createScheduler>[0]["namespace"];
1364
+ }
1365
+ /**
1366
+ * Build the Cloudflare {@link SchedulerHost}.
1367
+ *
1368
+ * The returned host has no `cron` member — see the module docstring.
1369
+ */
1370
+ declare const createSchedulerHost: (options: SchedulerHostOptions) => SchedulerHost;
845
1371
  /** Standard cron expression (5- or 6-field) or a supported `@macro`, per `cron-parser`. */
846
1372
  declare const isValidCronExpression: (schedule: string) => boolean;
847
1373
  /**
848
- * Assert a raw cron expression is well-formed, throwing the same shaped error
849
- * both cron surfaces use. The `context` prefix lets callers name the offending
850
- * job (`cron job "send digest"`) vs. the bare trigger.
851
- */
1374
+ * A 6-field (seconds-leading) cron expression is legal generic cron grammar
1375
+ * but not a Cloudflare Cron Trigger — the platform only understands the
1376
+ * 5-field, minute-granularity form and rejects the rest at `wrangler deploy`
1377
+ * with a message naming neither the job nor the file. Warn here instead of
1378
+ * staying silent, without throwing: unlike the ergonomic `.interval()` form,
1379
+ * the raw `.cron()` escape hatch is meant to accept cron grammar this module
1380
+ * doesn't otherwise second-guess.
1381
+ *
1382
+ * Exported (not module-private) because it is the ONE place this advisory is
1383
+ * written: the runtime `cronJobs()` builder reaches it via
1384
+ * {@link assertValidCronExpression}, and `@lunora/codegen`'s static
1385
+ * `discover/crons.ts` calls it directly after its own `isValidCronExpression`
1386
+ * check — so a hand-authored 6-field `.cron()` warns whether it's discovered
1387
+ * from source at build time or registered at runtime, with one shared message.
1388
+ */
1389
+ declare const warnIfSecondsLeading: (schedule: string, context: string) => void;
1390
+ /**
1391
+ * Assert a raw cron expression is well-formed, throwing the same shaped error
1392
+ * both cron surfaces use. The `context` prefix lets callers name the offending
1393
+ * job (`cron job "send digest"`) vs. the bare trigger. Well-formed but
1394
+ * Cloudflare-incompatible (6-field) expressions pass but log a warning — see
1395
+ * {@link warnIfSecondsLeading}.
1396
+ */
852
1397
  declare const assertValidCronExpression: (schedule: string, context?: string) => void;
853
- 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 };
1398
+ /**
1399
+ * Reject a `delayMs` a scheduler cannot act on, before it reaches the
1400
+ * SchedulerDO.
1401
+ *
1402
+ * A `NaN`/`Infinity` delay serializes to `null` through JSON and lands as a
1403
+ * malformed `scheduledFor`; a negative one schedules into the past. Both are the
1404
+ * caller's argument, so the answer has to name the argument — which is why the
1405
+ * code is `INVALID_INPUT` (400) and not `INTERNAL`: `toErrorBody` replaces an
1406
+ * internal-coded message with "Internal error", redacting the one sentence that
1407
+ * says what to fix.
1408
+ *
1409
+ * Exported because four surfaces enforce it — `createScheduler().runAfter`,
1410
+ * `createWorkpool().enqueue`, `@lunora/server`'s deferred-schedule facade (which
1411
+ * must reject BEFORE the transaction commits, not at flush time) and
1412
+ * `@lunora/testing`'s fake scheduler. They used to restate it and threw three
1413
+ * different codes between them, so a test written against the harness caught one
1414
+ * code while production threw another.
1415
+ * @param delayMs the delay to validate
1416
+ * @param surface what to name in the message — the call the delay was passed to (e.g. `"ctx.scheduler.runAfter"`)
1417
+ * @param argument the caller's argument to name in the message; `runAt` converts its absolute `date` to a delay and passes that name through so the answer points at what was actually written
1418
+ */
1419
+ declare const assertScheduleDelay: (delayMs: number, surface: string, argument?: string) => void;
1420
+ /**
1421
+ * Reject a `runAt` instant a scheduler cannot act on — {@link assertScheduleDelay}'s
1422
+ * bound, restated for the absolute form by converting the instant to the delay it
1423
+ * implies.
1424
+ *
1425
+ * `runAfter` has refused a `NaN`/`Infinity` argument since the guard was written;
1426
+ * `runAt` took the same value through a different door and let it reach the DO,
1427
+ * where it serializes to `null` through JSON and lands as a `scheduledFor` no
1428
+ * alarm can ever fire. `new Date("2026-13-01")`, `runAt(row.dueAt + delay)` on a
1429
+ * row whose `dueAt` is absent — both arrive here as a number that is not one.
1430
+ *
1431
+ * An instant already in the PAST is not refused. It is an overdue job
1432
+ * (`runAt(row.dueAt)` on a row that came due while the request was in flight),
1433
+ * and `runAfter` itself reaches `runAt` a fraction of a millisecond after
1434
+ * capturing its own clock reading — so a strict sign check would fail the
1435
+ * documented `runAfter(0, …)` call at random. The delay is therefore clamped at
1436
+ * zero while it is still a finite number, which leaves the half that matters:
1437
+ * a value that is not a number at all.
1438
+ * @param timestampMs the absolute instant (epoch ms) the caller passed
1439
+ * @param nowMs the clock to measure it against — the wall clock in production, the harness's virtual clock in a test
1440
+ * @param surface what to name in the message — the call the instant was passed to (e.g. `"ctx.scheduler.runAt"`)
1441
+ */
1442
+ declare const assertScheduleInstant: (timestampMs: number, nowMs: number, surface: string) => void;
1443
+ 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 FunctionKind, 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, assertScheduleDelay, assertScheduleInstant, assertValidCronExpression, compileCronSchedule, createCronTrigger, createQueueConsumer, createQueueWorkpool, createScheduler, createSchedulerHost, createWorkpool, cronJobs, httpDispatcher, isValidCronExpression, isWorkflowReference, resolveScheduleId, warnIfSecondsLeading };