@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 +70 -20
- package/dist/index.d.ts +70 -20
- package/dist/index.mjs +4 -4
- package/dist/packem_shared/{CRON_SCHEDULE_KINDS-BMVeCHOu.mjs → CRON_SCHEDULE_KINDS-C2FflEnq.mjs} +13 -3
- package/dist/packem_shared/{SchedulerDO-DVeJrNbs.mjs → SchedulerDO-DeJHI379.mjs} +76 -46
- package/dist/packem_shared/createScheduler-Df9JUjQb.mjs +52 -0
- package/dist/packem_shared/{createWorkpool-C28trhTA.mjs → createWorkpool-Ce0kNM2p.mjs} +1 -27
- package/dist/packem_shared/do-client-CMJtLoHO.mjs +42 -0
- package/package.json +2 -2
- package/dist/packem_shared/createScheduler-CcD09oIm.mjs +0 -70
- package/dist/packem_shared/jurisdiction-CR2zC3Et.mjs +0 -13
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
|
-
|
|
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
|
-
|
|
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.<name>` /
|
|
151
|
+
* `agents.<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
|
-
|
|
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
|
|
689
|
+
* thrown storage op re-asserts the claim so the job stays re-fireable.
|
|
652
690
|
*
|
|
653
|
-
*
|
|
654
|
-
*
|
|
655
|
-
*
|
|
656
|
-
*
|
|
657
|
-
*
|
|
658
|
-
*
|
|
659
|
-
*
|
|
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}.
|
|
670
|
-
*
|
|
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)
|
|
673
|
-
*
|
|
674
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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.<name>` /
|
|
151
|
+
* `agents.<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
|
-
|
|
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
|
|
689
|
+
* thrown storage op re-asserts the claim so the job stays re-fireable.
|
|
652
690
|
*
|
|
653
|
-
*
|
|
654
|
-
*
|
|
655
|
-
*
|
|
656
|
-
*
|
|
657
|
-
*
|
|
658
|
-
*
|
|
659
|
-
*
|
|
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}.
|
|
670
|
-
*
|
|
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)
|
|
673
|
-
*
|
|
674
|
-
*
|
|
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-
|
|
2
|
-
export { default as createWorkpool } from './packem_shared/createWorkpool-
|
|
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-
|
|
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-
|
|
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';
|
package/dist/packem_shared/{CRON_SCHEDULE_KINDS-BMVeCHOu.mjs → CRON_SCHEDULE_KINDS-C2FflEnq.mjs}
RENAMED
|
@@ -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 `*/${
|
|
41
|
+
return `*/${stepField(value, "interval.seconds", 60)} * * * * *`;
|
|
32
42
|
}
|
|
33
43
|
if (unit === "minutes") {
|
|
34
|
-
return `*/${
|
|
44
|
+
return `*/${stepField(value, "interval.minutes", 60)} * * * *`;
|
|
35
45
|
}
|
|
36
|
-
return `0 */${
|
|
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 =
|
|
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
|
|
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
|
|
260
|
+
* thrown storage op re-asserts the claim so the job stays re-fireable.
|
|
241
261
|
*
|
|
242
|
-
*
|
|
243
|
-
*
|
|
244
|
-
*
|
|
245
|
-
*
|
|
246
|
-
*
|
|
247
|
-
*
|
|
248
|
-
*
|
|
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
|
|
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
|
-
|
|
275
|
+
await this.drainRecord(record);
|
|
255
276
|
} catch {
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
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}.
|
|
272
|
-
*
|
|
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)
|
|
275
|
-
*
|
|
276
|
-
*
|
|
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
|
|
283
|
-
const reserved = await this.reservePoolSlot(record
|
|
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 =
|
|
290
|
-
|
|
291
|
-
|
|
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
|
|
338
|
+
async reservePoolSlot(record) {
|
|
313
339
|
if (record.pool === void 0) {
|
|
314
340
|
return true;
|
|
315
341
|
}
|
|
316
|
-
const 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(
|
|
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
|
-
|
|
522
|
-
|
|
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
|
|
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 {
|
|
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.
|
|
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.
|
|
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 };
|