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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.d.mts CHANGED
@@ -45,6 +45,15 @@ interface WorkflowReference<Params = Record<string, unknown>> {
45
45
  type CronTarget = FunctionReference | WorkflowReference;
46
46
  /** The arguments a cron's target accepts: a workflow's inferred `params`, else an open record (function args aren't inferred). */
47
47
  type CronTargetArgs<T extends CronTarget> = T extends WorkflowReference<infer Params> ? Params : Record<string, unknown>;
48
+ /**
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>;
48
57
  /** Narrow a {@link CronTarget} to a {@link WorkflowReference} by its runtime brand. */
49
58
  declare const isWorkflowReference: (target: unknown) => target is WorkflowReference;
50
59
  /**
@@ -93,7 +102,12 @@ interface ScheduleRecord {
93
102
  */
94
103
  attempts?: number;
95
104
  enqueuedAt: number;
96
- functionPath: string;
105
+ /**
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
+ */
110
+ functionPath?: string;
97
111
  id: string;
98
112
  /**
99
113
  * Scheduler/workpool instance name the job was enqueued through. Echoed in
@@ -113,6 +127,14 @@ interface ScheduleRecord {
113
127
  retry?: RetryPolicy;
114
128
  scheduledFor: number;
115
129
  shardKey?: string;
130
+ /**
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
+ */
137
+ workflow?: string;
116
138
  }
117
139
  interface Scheduler {
118
140
  cancel: (id: string) => Promise<{
@@ -122,11 +144,20 @@ interface Scheduler {
122
144
  get: (id: string) => Promise<ScheduleRecord | null>;
123
145
  /** All pending scheduled jobs (the DO's `/list` view). */
124
146
  list: () => Promise<ScheduleRecord[]>;
125
- runAfter: <F extends FunctionReference>(delayMs: number, function_: F, args: ArgsOf<F>, options?: RunOptions) => Promise<{
147
+ /**
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<{
126
156
  id: string;
127
157
  scheduledFor: number;
128
158
  }>;
129
- runAt: <F extends FunctionReference>(date: Date | number, function_: F, args: ArgsOf<F>, options?: RunOptions) => Promise<{
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<{
130
161
  id: string;
131
162
  scheduledFor: number;
132
163
  }>;
@@ -617,6 +648,13 @@ declare class SchedulerDO {
617
648
  * compatibility shim, not the hot path.
618
649
  */
619
650
  private static releaseFirstSlot;
651
+ /**
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
+ */
657
+ private static resolveScheduleTarget;
620
658
  protected readonly state: SchedulerDOState;
621
659
  protected readonly env: SchedulerEnv;
622
660
  constructor(state: SchedulerDOState, env: SchedulerEnv);
@@ -648,15 +686,17 @@ declare class SchedulerDO {
648
686
  *
649
687
  * Claims the job by deleting its time-index entry BEFORE dispatch (an alarm
650
688
  * re-fire then won't pick it up again), runs {@link drainRecord}, and on a
651
- * thrown storage op decides whether the job stays re-fireable.
689
+ * thrown storage op re-asserts the claim so the job stays re-fireable.
652
690
  *
653
- * When the record was NOT successfully dispatched, re-assert the time-index
654
- * claim so a later alarm re-attempts it (at-least-once): the claim delete may
655
- * have removed it and recordRetry()/requeuePooled() may not have re-armed it
656
- * before throwing, and re-inserting the same key is idempotent, so a
657
- * surviving claim is simply rewritten to its prior value. When the record WAS
658
- * dispatched (the throw came from post-dispatch cleanup), leave the index
659
- * deleted so the already-kicked, idempotent job is not re-fired.
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.
660
700
  */
661
701
  private drainRecordGuarded;
662
702
  /**
@@ -666,16 +706,18 @@ declare class SchedulerDO {
666
706
  * free slot is reserved durably before dispatch and released immediately if
667
707
  * the kick fails (success holds it until the runtime reports completion).
668
708
  * Success clears the `id:`/`retry:` rows; failure routes to
669
- * {@link recordRetry}. `pools` caches each pool's {@link PoolState} for the
670
- * lifetime of the drain so the budget decrements without re-reading storage.
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.
671
717
  * @returns `true` only when the record was successfully dispatched (a 2xx
672
- * kick). The caller ({@link drainRecordGuarded}) uses this in its per-record
673
- * error guard: a record that returns `true` (or whose post-dispatch cleanup
674
- * later throws) must NOT have its time-index claim restored, since re-firing
675
- * an already-kicked job would break idempotency. A `false` return (pool
676
- * backpressure or a failed dispatch) means the job is still re-fireable —
677
- * either already re-armed here, or, if a throw escapes, re-claimed by the
678
- * guard's catch.
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).
679
721
  */
680
722
  private drainRecord;
681
723
  /**
@@ -683,6 +725,14 @@ declare class SchedulerDO {
683
725
  * job via {@link requeuePooled}) when the pool is at `maxConcurrency`;
684
726
  * otherwise reserves a slot durably and returns `true`. Non-pooled records
685
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).
686
736
  */
687
737
  private reservePoolSlot;
688
738
  /**
package/dist/index.d.ts CHANGED
@@ -45,6 +45,15 @@ interface WorkflowReference<Params = Record<string, unknown>> {
45
45
  type CronTarget = FunctionReference | WorkflowReference;
46
46
  /** The arguments a cron's target accepts: a workflow's inferred `params`, else an open record (function args aren't inferred). */
47
47
  type CronTargetArgs<T extends CronTarget> = T extends WorkflowReference<infer Params> ? Params : Record<string, unknown>;
48
+ /**
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>;
48
57
  /** Narrow a {@link CronTarget} to a {@link WorkflowReference} by its runtime brand. */
49
58
  declare const isWorkflowReference: (target: unknown) => target is WorkflowReference;
50
59
  /**
@@ -93,7 +102,12 @@ interface ScheduleRecord {
93
102
  */
94
103
  attempts?: number;
95
104
  enqueuedAt: number;
96
- functionPath: string;
105
+ /**
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
+ */
110
+ functionPath?: string;
97
111
  id: string;
98
112
  /**
99
113
  * Scheduler/workpool instance name the job was enqueued through. Echoed in
@@ -113,6 +127,14 @@ interface ScheduleRecord {
113
127
  retry?: RetryPolicy;
114
128
  scheduledFor: number;
115
129
  shardKey?: string;
130
+ /**
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
+ */
137
+ workflow?: string;
116
138
  }
117
139
  interface Scheduler {
118
140
  cancel: (id: string) => Promise<{
@@ -122,11 +144,20 @@ interface Scheduler {
122
144
  get: (id: string) => Promise<ScheduleRecord | null>;
123
145
  /** All pending scheduled jobs (the DO's `/list` view). */
124
146
  list: () => Promise<ScheduleRecord[]>;
125
- runAfter: <F extends FunctionReference>(delayMs: number, function_: F, args: ArgsOf<F>, options?: RunOptions) => Promise<{
147
+ /**
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<{
126
156
  id: string;
127
157
  scheduledFor: number;
128
158
  }>;
129
- runAt: <F extends FunctionReference>(date: Date | number, function_: F, args: ArgsOf<F>, options?: RunOptions) => Promise<{
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<{
130
161
  id: string;
131
162
  scheduledFor: number;
132
163
  }>;
@@ -617,6 +648,13 @@ declare class SchedulerDO {
617
648
  * compatibility shim, not the hot path.
618
649
  */
619
650
  private static releaseFirstSlot;
651
+ /**
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
+ */
657
+ private static resolveScheduleTarget;
620
658
  protected readonly state: SchedulerDOState;
621
659
  protected readonly env: SchedulerEnv;
622
660
  constructor(state: SchedulerDOState, env: SchedulerEnv);
@@ -648,15 +686,17 @@ declare class SchedulerDO {
648
686
  *
649
687
  * Claims the job by deleting its time-index entry BEFORE dispatch (an alarm
650
688
  * re-fire then won't pick it up again), runs {@link drainRecord}, and on a
651
- * thrown storage op decides whether the job stays re-fireable.
689
+ * thrown storage op re-asserts the claim so the job stays re-fireable.
652
690
  *
653
- * When the record was NOT successfully dispatched, re-assert the time-index
654
- * claim so a later alarm re-attempts it (at-least-once): the claim delete may
655
- * have removed it and recordRetry()/requeuePooled() may not have re-armed it
656
- * before throwing, and re-inserting the same key is idempotent, so a
657
- * surviving claim is simply rewritten to its prior value. When the record WAS
658
- * dispatched (the throw came from post-dispatch cleanup), leave the index
659
- * deleted so the already-kicked, idempotent job is not re-fired.
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.
660
700
  */
661
701
  private drainRecordGuarded;
662
702
  /**
@@ -666,16 +706,18 @@ declare class SchedulerDO {
666
706
  * free slot is reserved durably before dispatch and released immediately if
667
707
  * the kick fails (success holds it until the runtime reports completion).
668
708
  * Success clears the `id:`/`retry:` rows; failure routes to
669
- * {@link recordRetry}. `pools` caches each pool's {@link PoolState} for the
670
- * lifetime of the drain so the budget decrements without re-reading storage.
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.
671
717
  * @returns `true` only when the record was successfully dispatched (a 2xx
672
- * kick). The caller ({@link drainRecordGuarded}) uses this in its per-record
673
- * error guard: a record that returns `true` (or whose post-dispatch cleanup
674
- * later throws) must NOT have its time-index claim restored, since re-firing
675
- * an already-kicked job would break idempotency. A `false` return (pool
676
- * backpressure or a failed dispatch) means the job is still re-fireable —
677
- * either already re-armed here, or, if a throw escapes, re-claimed by the
678
- * guard's catch.
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).
679
721
  */
680
722
  private drainRecord;
681
723
  /**
@@ -683,6 +725,14 @@ declare class SchedulerDO {
683
725
  * job via {@link requeuePooled}) when the pool is at `maxConcurrency`;
684
726
  * otherwise reserves a slot durably and returns `true`. Non-pooled records
685
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).
686
736
  */
687
737
  private reservePoolSlot;
688
738
  /**
package/dist/index.mjs CHANGED
@@ -1,8 +1,8 @@
1
- export { default as createScheduler } from './packem_shared/createScheduler-CcD09oIm.mjs';
2
- export { default as createWorkpool } from './packem_shared/createWorkpool-C28trhTA.mjs';
1
+ export { default as createScheduler } from './packem_shared/createScheduler-Df9JUjQb.mjs';
2
+ export { default as createWorkpool } from './packem_shared/createWorkpool-Ce0kNM2p.mjs';
3
3
  export { createCronTrigger } from './packem_shared/createCronTrigger-Dh4VLxD9.mjs';
4
- export { CRON_SCHEDULE_KINDS, compileCronSchedule, cronJobs } from './packem_shared/CRON_SCHEDULE_KINDS-BMVeCHOu.mjs';
4
+ export { CRON_SCHEDULE_KINDS, compileCronSchedule, cronJobs } from './packem_shared/CRON_SCHEDULE_KINDS-C2FflEnq.mjs';
5
5
  export { createQueueConsumer, createQueueWorkpool, httpDispatcher } from './packem_shared/createQueueConsumer-Cy-Mp-El.mjs';
6
- export { SchedulerDO } from './packem_shared/SchedulerDO-DVeJrNbs.mjs';
6
+ export { SchedulerDO } from './packem_shared/SchedulerDO-DeJHI379.mjs';
7
7
  export { isWorkflowReference } from './packem_shared/isWorkflowReference-C9mQkMXt.mjs';
8
8
  export { assertValidCronExpression, isValidCronExpression } from './packem_shared/assertValidCronExpression-B9m75qU0.mjs';
@@ -20,6 +20,16 @@ const field = (value, label, min, max) => {
20
20
  }
21
21
  return value.toFixed(0);
22
22
  };
23
+ const stepField = (value, label, period) => {
24
+ const rendered = field(value, label, 1, period - 1);
25
+ if (period % value !== 0) {
26
+ throw new LunoraError(
27
+ "INTERNAL",
28
+ `@lunora/scheduler: ${label} must evenly divide ${period.toFixed(0)} for a fixed "every ${value.toFixed(0)}" interval — cron "*/${value.toFixed(0)}" means "at values divisible by ${value.toFixed(0)}", which wraps unevenly; pick a divisor of ${period.toFixed(0)}`
29
+ );
30
+ }
31
+ return rendered;
32
+ };
23
33
  const compileInterval = (schedule) => {
24
34
  const units = ["seconds", "minutes", "hours"].filter((unit2) => schedule[unit2] !== void 0);
25
35
  if (units.length !== 1) {
@@ -28,12 +38,12 @@ const compileInterval = (schedule) => {
28
38
  const unit = units[0];
29
39
  const value = schedule[unit];
30
40
  if (unit === "seconds") {
31
- return `*/${field(value, "interval.seconds", 1, 59)} * * * * *`;
41
+ return `*/${stepField(value, "interval.seconds", 60)} * * * * *`;
32
42
  }
33
43
  if (unit === "minutes") {
34
- return `*/${field(value, "interval.minutes", 1, 59)} * * * *`;
44
+ return `*/${stepField(value, "interval.minutes", 60)} * * * *`;
35
45
  }
36
- return `0 */${field(value, "interval.hours", 1, 23)} * * *`;
46
+ return `0 */${stepField(value, "interval.hours", 24)} * * *`;
37
47
  };
38
48
  const compileDaily = (schedule) => {
39
49
  const minute = field(schedule.minuteUTC, "daily.minuteUTC", 0, 59);
@@ -7,7 +7,7 @@ const POOL_PREFIX = "pool:";
7
7
  const MAX_RETRY_ATTEMPTS = 5;
8
8
  const RETRY_BASE_DELAY_MS = 3e4;
9
9
  const POOL_BACKPRESSURE_DELAY_MS = 1e3;
10
- const MAX_SCHEDULED_FOR_MS = 864e13;
10
+ const MAX_SCHEDULED_FOR_MS = 999999999999999;
11
11
  const TIME_PAD = 15;
12
12
  const padTime = (n) => String(n).padStart(TIME_PAD, "0");
13
13
  const generateId = () => {
@@ -102,6 +102,20 @@ class SchedulerDO {
102
102
  const next = pool.inFlightIds.slice(0, Math.max(0, pool.inFlightIds.length - 1));
103
103
  return { ...pool, inFlight: next.length, inFlightIds: next };
104
104
  }
105
+ /**
106
+ * Normalize the mutually-exclusive dispatch target off an untrusted body: a
107
+ * one-shot function path (`functionPath`) or a durable workflow/agent
108
+ * instance (`workflow`, a `WORKFLOW_*`/`AGENT_*` binding). Returns `undefined`
109
+ * when neither is present so the caller can reject the schedule.
110
+ */
111
+ static resolveScheduleTarget(body) {
112
+ const functionPath = typeof body?.functionPath === "string" && body.functionPath.length > 0 ? body.functionPath : void 0;
113
+ const workflow = typeof body?.workflow === "string" && body.workflow.length > 0 ? body.workflow : void 0;
114
+ if (functionPath === void 0 && workflow === void 0) {
115
+ return void 0;
116
+ }
117
+ return { functionPath, workflow };
118
+ }
105
119
  state;
106
120
  env;
107
121
  constructor(state, env) {
@@ -162,13 +176,14 @@ class SchedulerDO {
162
176
  const record = await this.state.storage.get(`${HEADER_PREFIX}${recordId}`);
163
177
  if (record) {
164
178
  due.push(record);
179
+ } else {
180
+ await this.state.storage.delete(indexKey);
165
181
  }
166
182
  }
167
183
  }
168
- const pools = /* @__PURE__ */ new Map();
169
184
  try {
170
185
  for (const record of due) {
171
- await this.drainRecordGuarded(record, pools);
186
+ await this.drainRecordGuarded(record);
172
187
  }
173
188
  } finally {
174
189
  await this.rescheduleAlarm();
@@ -210,7 +225,12 @@ class SchedulerDO {
210
225
  // so the pool's concurrency slot is released — see handleComplete().
211
226
  pool: record.pool,
212
227
  scheduledFor: record.scheduledFor,
213
- shardKey: record.shardKey
228
+ shardKey: record.shardKey,
229
+ // Present instead of `functionPath` for a workflow/agent target; the
230
+ // runtime starts a fresh instance of this binding. `undefined` when
231
+ // absent, so JSON.stringify drops it and the payload is unchanged for
232
+ // ordinary function dispatches.
233
+ workflow: record.workflow
214
234
  });
215
235
  try {
216
236
  const headers = { "content-type": "application/json" };
@@ -237,27 +257,26 @@ class SchedulerDO {
237
257
  *
238
258
  * Claims the job by deleting its time-index entry BEFORE dispatch (an alarm
239
259
  * re-fire then won't pick it up again), runs {@link drainRecord}, and on a
240
- * thrown storage op decides whether the job stays re-fireable.
260
+ * thrown storage op re-asserts the claim so the job stays re-fireable.
241
261
  *
242
- * When the record was NOT successfully dispatched, re-assert the time-index
243
- * claim so a later alarm re-attempts it (at-least-once): the claim delete may
244
- * have removed it and recordRetry()/requeuePooled() may not have re-armed it
245
- * before throwing, and re-inserting the same key is idempotent, so a
246
- * surviving claim is simply rewritten to its prior value. When the record WAS
247
- * dispatched (the throw came from post-dispatch cleanup), leave the index
248
- * deleted so the already-kicked, idempotent job is not re-fired.
262
+ * A throw reaching here always means the job was NOT dispatched:
263
+ * {@link drainRecord} swallows its own post-dispatch cleanup errors and
264
+ * returns instead of throwing once a kick succeeds, so every escaping throw
265
+ * comes from the pre-dispatch or failed-dispatch paths. We therefore always
266
+ * re-assert the time-index claim so a later alarm re-attempts it
267
+ * (at-least-once): the claim delete may have removed it and
268
+ * recordRetry()/requeuePooled() may not have re-armed it before throwing, and
269
+ * re-inserting the same key is idempotent, so a surviving claim is simply
270
+ * rewritten to its prior value.
249
271
  */
250
- async drainRecordGuarded(record, pools) {
251
- let dispatched = false;
272
+ async drainRecordGuarded(record) {
252
273
  try {
253
274
  await this.state.storage.delete(SchedulerDO.indexKey(record.scheduledFor, record.id));
254
- dispatched = await this.drainRecord(record, pools);
275
+ await this.drainRecord(record);
255
276
  } catch {
256
- if (!dispatched) {
257
- try {
258
- await this.state.storage.put(SchedulerDO.indexKey(record.scheduledFor, record.id), record.id);
259
- } catch {
260
- }
277
+ try {
278
+ await this.state.storage.put(SchedulerDO.indexKey(record.scheduledFor, record.id), record.id);
279
+ } catch {
261
280
  }
262
281
  }
263
282
  }
@@ -268,30 +287,29 @@ class SchedulerDO {
268
287
  * free slot is reserved durably before dispatch and released immediately if
269
288
  * the kick fails (success holds it until the runtime reports completion).
270
289
  * Success clears the `id:`/`retry:` rows; failure routes to
271
- * {@link recordRetry}. `pools` caches each pool's {@link PoolState} for the
272
- * lifetime of the drain so the budget decrements without re-reading storage.
290
+ * {@link recordRetry}. Pool state is read FRESH from storage per record (see
291
+ * {@link reservePoolSlot}) and never held across the dispatch() await, so a
292
+ * concurrent /complete landing mid-dispatch can't be clobbered.
293
+ * Once a kick succeeds, post-dispatch cleanup (clearing the `id:`/`retry:`
294
+ * rows) is swallowed rather than allowed to throw, so a successful dispatch
295
+ * NEVER propagates an error to {@link drainRecordGuarded}: every throw that
296
+ * escapes comes from the pre-dispatch or failed-dispatch paths, where the job
297
+ * is still re-fireable and the guard safely re-claims the time index.
273
298
  * @returns `true` only when the record was successfully dispatched (a 2xx
274
- * kick). The caller ({@link drainRecordGuarded}) uses this in its per-record
275
- * error guard: a record that returns `true` (or whose post-dispatch cleanup
276
- * later throws) must NOT have its time-index claim restored, since re-firing
277
- * an already-kicked job would break idempotency. A `false` return (pool
278
- * backpressure or a failed dispatch) means the job is still re-fireable —
279
- * either already re-armed here, or, if a throw escapes, re-claimed by the
280
- * guard's catch.
299
+ * kick); `false` on pool backpressure or a failed dispatch (the job is still
300
+ * re-fireable already re-armed here). The value is informational (the guard
301
+ * branches on throw/no-throw, not on this boolean).
281
302
  */
282
- async drainRecord(record, pools) {
283
- const reserved = await this.reservePoolSlot(record, pools);
303
+ async drainRecord(record) {
304
+ const reserved = await this.reservePoolSlot(record);
284
305
  if (!reserved) {
285
306
  return false;
286
307
  }
287
308
  const ok = await this.dispatch(record);
288
309
  if (!ok && record.pool !== void 0) {
289
- const pool = pools.get(record.pool);
290
- if (pool !== void 0) {
291
- const released = SchedulerDO.releaseSlot(pool, record.id);
292
- pools.set(record.pool, released);
293
- await this.savePool(record.pool, released);
294
- }
310
+ const pool = await this.loadPool(record.pool);
311
+ const released = SchedulerDO.releaseSlot(pool, record.id);
312
+ await this.savePool(record.pool, released);
295
313
  }
296
314
  if (ok) {
297
315
  try {
@@ -308,13 +326,20 @@ class SchedulerDO {
308
326
  * job via {@link requeuePooled}) when the pool is at `maxConcurrency`;
309
327
  * otherwise reserves a slot durably and returns `true`. Non-pooled records
310
328
  * always return `true` without touching any pool state.
329
+ *
330
+ * The pool row is read FRESH from storage on every call — never cached
331
+ * across the drain. Each reservation durably `savePool()`s before the next
332
+ * record runs, so a same-pass reservation is still visible to the next
333
+ * record's fresh read (the budget carries forward); and because dispatch()
334
+ * awaits an outbound fetch between records, a concurrent /complete that
335
+ * decrements the row mid-drain IS reflected here instead of being clobbered
336
+ * by a stale in-memory copy (which would leak a slot permanently).
311
337
  */
312
- async reservePoolSlot(record, pools) {
338
+ async reservePoolSlot(record) {
313
339
  if (record.pool === void 0) {
314
340
  return true;
315
341
  }
316
- const pool = pools.get(record.pool) ?? await this.loadPool(record.pool);
317
- pools.set(record.pool, pool);
342
+ const pool = await this.loadPool(record.pool);
318
343
  if (pool.inFlight >= pool.maxConcurrency) {
319
344
  await this.requeuePooled(record);
320
345
  return false;
@@ -402,7 +427,9 @@ class SchedulerDO {
402
427
  if (attempts > maxAttempts) {
403
428
  await this.state.storage.put(`${DEAD_PREFIX}${record.id}`, { ...record, attempts });
404
429
  await this.state.storage.delete([`${RETRY_PREFIX}${record.id}`, `${HEADER_PREFIX}${record.id}`]);
405
- console.warn(`@lunora/scheduler: job "${record.id}" (${record.functionPath}) parked in dead-letter after ${String(attempts)} attempts`);
430
+ console.warn(
431
+ `@lunora/scheduler: job "${record.id}" (${record.functionPath ?? record.workflow ?? "unknown"}) parked in dead-letter after ${String(attempts)} attempts`
432
+ );
406
433
  return;
407
434
  }
408
435
  const rawDelay = backoff === "linear" ? baseMs * attempts : baseMs * 2 ** (attempts - 1);
@@ -518,11 +545,13 @@ class SchedulerDO {
518
545
  }
519
546
  async handleSchedule(request) {
520
547
  const body = await request.json().catch(() => void 0);
521
- if (!body || typeof body.functionPath !== "string") {
522
- return SchedulerDO.error(400, "INVALID_INPUT", "functionPath is required");
548
+ const target = SchedulerDO.resolveScheduleTarget(body);
549
+ if (!body || target === void 0) {
550
+ return SchedulerDO.error(400, "INVALID_INPUT", "functionPath or workflow is required");
523
551
  }
552
+ const { functionPath, workflow } = target;
524
553
  if (typeof body.scheduledFor !== "number" || !Number.isInteger(body.scheduledFor) || body.scheduledFor <= 0 || body.scheduledFor > MAX_SCHEDULED_FOR_MS) {
525
- return SchedulerDO.error(400, "INVALID_INPUT", "scheduledFor must be a positive integer epoch-millisecond number no greater than 8640000000000000");
554
+ return SchedulerDO.error(400, "INVALID_INPUT", "scheduledFor must be a positive integer epoch-millisecond number no greater than 999999999999999");
526
555
  }
527
556
  if (typeof this.env.LUNORA_ORIGIN_URL !== "string" || this.env.LUNORA_ORIGIN_URL.length === 0) {
528
557
  return SchedulerDO.error(500, "ORIGIN_NOT_CONFIGURED", "LUNORA_ORIGIN_URL env binding must be set on the SchedulerDO");
@@ -537,13 +566,14 @@ class SchedulerDO {
537
566
  // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- parsed wire data can omit args
538
567
  args: body.args ?? {},
539
568
  enqueuedAt: Date.now(),
540
- functionPath: body.functionPath,
541
569
  id,
570
+ ...functionPath === void 0 ? {} : { functionPath },
542
571
  ...instanceName === void 0 ? {} : { instanceName },
543
572
  ...pool === void 0 ? {} : { pool },
544
573
  ...retry === void 0 ? {} : { retry },
545
574
  scheduledFor: body.scheduledFor,
546
- shardKey: body.shardKey
575
+ shardKey: body.shardKey,
576
+ ...workflow === void 0 ? {} : { workflow }
547
577
  };
548
578
  if (pool !== void 0) {
549
579
  const current = await this.loadPool(pool, body.maxConcurrency);
@@ -0,0 +1,52 @@
1
+ import { LunoraError } from '@lunora/errors';
2
+ import { c as callDO, g as getDO } from './do-client-CMJtLoHO.mjs';
3
+ import { isWorkflowReference } from './isWorkflowReference-C9mQkMXt.mjs';
4
+
5
+ const createScheduler = (options) => {
6
+ if (!options.namespace) {
7
+ throw new LunoraError("INTERNAL", "@lunora/scheduler: `namespace` (SchedulerDO binding) is required");
8
+ }
9
+ if (!options.originUrl) {
10
+ throw new LunoraError("INTERNAL", "@lunora/scheduler: `originUrl` is required so the DO can dispatch back to the Worker");
11
+ }
12
+ const runAt = async (date, target, args, options_ = {}) => {
13
+ const scheduledFor = date instanceof Date ? date.getTime() : date;
14
+ const base = {
15
+ args,
16
+ originUrl: options.originUrl,
17
+ pool: options_.pool,
18
+ retry: options_.retry,
19
+ scheduledFor,
20
+ shardKey: options_.shardKey
21
+ };
22
+ if (isWorkflowReference(target)) {
23
+ if (typeof target.binding !== "string" || target.binding.length === 0) {
24
+ throw new LunoraError(
25
+ "INTERNAL",
26
+ "@lunora/scheduler: workflow/agent schedule target is missing its `binding` — pass the generated `workflows.<name>` / `agents.<name>` reference"
27
+ );
28
+ }
29
+ return callDO(options, "/schedule", { ...base, workflow: target.binding });
30
+ }
31
+ const functionPath = typeof target === "string" ? target : target.__lunoraRef;
32
+ return callDO(options, "/schedule", { ...base, functionPath });
33
+ };
34
+ const runAfter = async (delayMs, target, args, options_ = {}) => {
35
+ if (!Number.isFinite(delayMs) || delayMs < 0) {
36
+ throw new LunoraError("INTERNAL", "@lunora/scheduler: `delayMs` must be a non-negative finite number");
37
+ }
38
+ return runAt(Date.now() + delayMs, target, args, options_);
39
+ };
40
+ const cancel = async (id) => callDO(options, "/cancel", { id });
41
+ const list = async () => {
42
+ const body = await getDO(options, "/list");
43
+ return Array.isArray(body.records) ? body.records : [];
44
+ };
45
+ const get = async (id) => {
46
+ const body = await getDO(options, `/get?id=${encodeURIComponent(id)}`);
47
+ return body.record ?? null;
48
+ };
49
+ return { cancel, get, list, runAfter, runAt };
50
+ };
51
+
52
+ export { createScheduler as default };
@@ -1,32 +1,6 @@
1
1
  import { LunoraError } from '@lunora/errors';
2
- import { a as applyJurisdiction } from './jurisdiction-CR2zC3Et.mjs';
2
+ import { g as getDO, c as callDO } from './do-client-CMJtLoHO.mjs';
3
3
 
4
- const workpoolStub = (options) => {
5
- const namespace = applyJurisdiction(options.namespace, options.jurisdiction);
6
- return namespace.get(namespace.idFromName(options.instanceName ?? "default"));
7
- };
8
- const callDO = async (options, path, body) => {
9
- const stub = workpoolStub(options);
10
- const response = await stub.fetch(`https://scheduler.internal${path}`, {
11
- body: JSON.stringify(body),
12
- headers: { "content-type": "application/json" },
13
- method: "POST"
14
- });
15
- if (!response.ok) {
16
- const text = await response.text();
17
- throw new LunoraError("INTERNAL", `@lunora/scheduler: SchedulerDO ${path} failed (${String(response.status)}): ${text}`);
18
- }
19
- return await response.json();
20
- };
21
- const getDO = async (options, path) => {
22
- const stub = workpoolStub(options);
23
- const response = await stub.fetch(`https://scheduler.internal${path}`, { method: "GET" });
24
- if (!response.ok) {
25
- const text = await response.text();
26
- throw new LunoraError("INTERNAL", `@lunora/scheduler: SchedulerDO ${path} failed (${String(response.status)}): ${text}`);
27
- }
28
- return await response.json();
29
- };
30
4
  const createWorkpool = (options) => {
31
5
  if (!options.namespace) {
32
6
  throw new LunoraError("INTERNAL", "@lunora/scheduler: `namespace` (SchedulerDO binding) is required");
@@ -0,0 +1,42 @@
1
+ import { LunoraError } from '@lunora/errors';
2
+
3
+ const applyJurisdiction = (namespace, jurisdiction) => {
4
+ if (jurisdiction === void 0) {
5
+ return namespace;
6
+ }
7
+ if (typeof namespace.jurisdiction !== "function") {
8
+ throw new TypeError(
9
+ `@lunora/scheduler: Durable Object namespace does not support jurisdiction("${jurisdiction}") — update @cloudflare/workers-types or remove the jurisdiction option`
10
+ );
11
+ }
12
+ return namespace.jurisdiction(jurisdiction);
13
+ };
14
+
15
+ const schedulerStub = (options) => {
16
+ const namespace = applyJurisdiction(options.namespace, options.jurisdiction);
17
+ return namespace.get(namespace.idFromName(options.instanceName ?? "default"));
18
+ };
19
+ const callDO = async (options, path, body) => {
20
+ const stub = schedulerStub(options);
21
+ const response = await stub.fetch(`https://scheduler.internal${path}`, {
22
+ body: JSON.stringify(body),
23
+ headers: { "content-type": "application/json" },
24
+ method: "POST"
25
+ });
26
+ if (!response.ok) {
27
+ const text = await response.text();
28
+ throw new LunoraError("INTERNAL", `@lunora/scheduler: SchedulerDO ${path} failed (${String(response.status)}): ${text}`);
29
+ }
30
+ return await response.json();
31
+ };
32
+ const getDO = async (options, path) => {
33
+ const stub = schedulerStub(options);
34
+ const response = await stub.fetch(`https://scheduler.internal${path}`, { method: "GET" });
35
+ if (!response.ok) {
36
+ const text = await response.text();
37
+ throw new LunoraError("INTERNAL", `@lunora/scheduler: SchedulerDO ${path} failed (${String(response.status)}): ${text}`);
38
+ }
39
+ return await response.json();
40
+ };
41
+
42
+ export { callDO as c, getDO as g };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lunora/scheduler",
3
- "version": "1.0.0-alpha.7",
3
+ "version": "1.0.0-alpha.9",
4
4
  "description": "Scheduling for Lunora: runAfter / runAt and Cron Triggers via SchedulerDO",
5
5
  "keywords": [
6
6
  "cloudflare",
@@ -46,7 +46,7 @@
46
46
  "access": "public"
47
47
  },
48
48
  "dependencies": {
49
- "@lunora/errors": "1.0.0-alpha.3",
49
+ "@lunora/errors": "1.0.0-alpha.4",
50
50
  "cron-parser": "5.6.1"
51
51
  },
52
52
  "engines": {
@@ -1,70 +0,0 @@
1
- import { LunoraError } from '@lunora/errors';
2
- import { a as applyJurisdiction } from './jurisdiction-CR2zC3Et.mjs';
3
-
4
- const schedulerStub = (options) => {
5
- const namespace = applyJurisdiction(options.namespace, options.jurisdiction);
6
- return namespace.get(namespace.idFromName(options.instanceName ?? "default"));
7
- };
8
- const callDO = async (options, path, body) => {
9
- const stub = schedulerStub(options);
10
- const response = await stub.fetch(`https://scheduler.internal${path}`, {
11
- body: JSON.stringify(body),
12
- headers: { "content-type": "application/json" },
13
- method: "POST"
14
- });
15
- if (!response.ok) {
16
- const text = await response.text();
17
- throw new LunoraError("INTERNAL", `@lunora/scheduler: SchedulerDO ${path} failed (${String(response.status)}): ${text}`);
18
- }
19
- return await response.json();
20
- };
21
- const getDO = async (options, path) => {
22
- const stub = schedulerStub(options);
23
- const response = await stub.fetch(`https://scheduler.internal${path}`, { method: "GET" });
24
- if (!response.ok) {
25
- const text = await response.text();
26
- throw new LunoraError("INTERNAL", `@lunora/scheduler: SchedulerDO ${path} failed (${String(response.status)}): ${text}`);
27
- }
28
- return await response.json();
29
- };
30
- const createScheduler = (options) => {
31
- if (!options.namespace) {
32
- throw new LunoraError("INTERNAL", "@lunora/scheduler: `namespace` (SchedulerDO binding) is required");
33
- }
34
- if (!options.originUrl) {
35
- throw new LunoraError("INTERNAL", "@lunora/scheduler: `originUrl` is required so the DO can dispatch back to the Worker");
36
- }
37
- const runAt = async (date, function_, args, options_ = {}) => {
38
- const scheduledFor = date instanceof Date ? date.getTime() : date;
39
- return callDO(options, "/schedule", {
40
- args,
41
- functionPath: function_.__lunoraRef,
42
- originUrl: options.originUrl,
43
- // Optional workpool / retry-policy passthrough. Absent for ordinary
44
- // `runAfter`/`runAt` calls, which keeps the wire payload (and the
45
- // DO's behaviour) identical to before this feature.
46
- pool: options_.pool,
47
- retry: options_.retry,
48
- scheduledFor,
49
- shardKey: options_.shardKey
50
- });
51
- };
52
- const runAfter = async (delayMs, function_, args, options_ = {}) => {
53
- if (!Number.isFinite(delayMs) || delayMs < 0) {
54
- throw new LunoraError("INTERNAL", "@lunora/scheduler: `delayMs` must be a non-negative finite number");
55
- }
56
- return runAt(Date.now() + delayMs, function_, args, options_);
57
- };
58
- const cancel = async (id) => callDO(options, "/cancel", { id });
59
- const list = async () => {
60
- const body = await getDO(options, "/list");
61
- return Array.isArray(body.records) ? body.records : [];
62
- };
63
- const get = async (id) => {
64
- const body = await getDO(options, `/get?id=${encodeURIComponent(id)}`);
65
- return body.record ?? null;
66
- };
67
- return { cancel, get, list, runAfter, runAt };
68
- };
69
-
70
- export { createScheduler as default };
@@ -1,13 +0,0 @@
1
- const applyJurisdiction = (namespace, jurisdiction) => {
2
- if (jurisdiction === void 0) {
3
- return namespace;
4
- }
5
- if (typeof namespace.jurisdiction !== "function") {
6
- throw new TypeError(
7
- `@lunora/scheduler: Durable Object namespace does not support jurisdiction("${jurisdiction}") — update @cloudflare/workers-types or remove the jurisdiction option`
8
- );
9
- }
10
- return namespace.jurisdiction(jurisdiction);
11
- };
12
-
13
- export { applyJurisdiction as a };