@lunora/scheduler 1.0.0-alpha.16 → 1.0.0-alpha.18

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
@@ -497,9 +497,11 @@ declare const CRON_SCHEDULE_KINDS: ReadonlySet<CronScheduleKind>;
497
497
  * Compile one of the ergonomic schedule forms into a standard cron expression.
498
498
  * Exposed as a pure function so `@lunora/codegen` can reuse the exact same
499
499
  * compilation when it statically lifts a `crons.{kind}(...)` call out of the
500
- * AST — codegen imports this directly (no duplicated mirror).
500
+ * AST — codegen imports this directly (no duplicated mirror). `jobName` is
501
+ * optional (see {@link compileInterval}) so codegen's existing 2-arg call site
502
+ * keeps working unchanged.
501
503
  */
502
- declare const compileCronSchedule: (kind: CronScheduleKind, schedule: DailySchedule | HourlySchedule | IntervalSchedule | MonthlySchedule | WeeklySchedule) => string;
504
+ declare const compileCronSchedule: (kind: CronScheduleKind, schedule: DailySchedule | HourlySchedule | IntervalSchedule | MonthlySchedule | WeeklySchedule, jobName?: string) => string;
503
505
  /**
504
506
  * Builder returned by {@link cronJobs}. Each method registers one recurring
505
507
  * job; the compiled expression is validated immediately so authoring mistakes
@@ -566,6 +568,15 @@ interface SchedulerDOState {
566
568
  acceptWebSocket?: (ws: WebSocket) => void;
567
569
  /** Every accepted server WebSocket (workers `state.getWebSockets`). */
568
570
  getWebSockets?: () => WebSocket[];
571
+ /**
572
+ * Register a constant ping/pong auto-response so the runtime answers a
573
+ * known keepalive frame on a hibernated socket WITHOUT waking this DO (no
574
+ * billable request, no dispatch). Optional: absent in the unit harness and
575
+ * older runtimes, present on the real `DurableObjectState`. Mirrors
576
+ * `@lunora/do`'s `ShardDOState.setWebSocketAutoResponse` — see
577
+ * {@link SchedulerDO.armWebSocketKeepalive}.
578
+ */
579
+ setWebSocketAutoResponse?: (pair: WebSocketRequestResponsePair) => void;
569
580
  storage: {
570
581
  delete: (key: string | string[]) => Promise<number | boolean>;
571
582
  deleteAlarm: () => Promise<void> | void;
@@ -575,6 +586,7 @@ interface SchedulerDOState {
575
586
  end?: string;
576
587
  limit?: number;
577
588
  prefix?: string;
589
+ startAfter?: string;
578
590
  }) => Promise<Map<string, T>>;
579
591
  put: <T = unknown>(entries: Record<string, T> | string, value?: T) => Promise<void>;
580
592
  setAlarm: (scheduledTime: number | Date) => Promise<void> | void;
@@ -708,6 +720,20 @@ declare class SchedulerDO {
708
720
  * `false` so the record is retried rather than silently dropped.
709
721
  */
710
722
  protected dispatch(record: ScheduleRecord): Promise<boolean>;
723
+ /**
724
+ * Register the hibernation-safe ping/pong keepalive. The runtime answers a
725
+ * {@link WS_KEEPALIVE_PING} text frame with {@link WS_KEEPALIVE_PONG}
726
+ * WITHOUT waking this Durable Object, keeping an idle `/ws` subscription
727
+ * alive across hibernation with no billable wakeup and no dispatch. Without
728
+ * this, a client's heartbeat ping goes unanswered and its watchdog force-
729
+ * closes the socket every ~90s, defeating hibernation (each unanswered ping
730
+ * wakes the DO to reconnect) — mirrors `@lunora/do`'s
731
+ * `ShardDO.armWebSocketKeepalive`. The auto-response is per-instance, so
732
+ * this re-runs on every construction (including a post-hibernation wake).
733
+ * Guarded: the API and the `WebSocketRequestResponsePair` global are absent
734
+ * in the unit harness and on older runtimes, where it degrades to a no-op.
735
+ */
736
+ private armWebSocketKeepalive;
711
737
  /**
712
738
  * Claim + drain one due record with per-record fault isolation, so a storage
713
739
  * throw can never abort the whole alarm pass (which would skip the remaining
@@ -773,13 +799,28 @@ declare class SchedulerDO {
773
799
  */
774
800
  private handleWebSocketUpgrade;
775
801
  /**
776
- * Re-list the jobs and push them to every connected subscriber. Called after
777
- * any change (schedule / cancel / alarm-fire) so live studios reflect it
778
- * immediately. A no-op when the runtime doesn't support hibernated sockets.
802
+ * Re-list the jobs (bounded see {@link listRecords}) and push them to
803
+ * every connected subscriber. Called after any change (schedule / cancel /
804
+ * alarm-fire) so live studios reflect it immediately. A no-op when the
805
+ * runtime doesn't support hibernated sockets.
779
806
  */
780
807
  private broadcastChange;
781
- /** The current pending job records (shared by `/list` and the live channel). */
808
+ /**
809
+ * The current pending job records (shared by `/list` and the live channel),
810
+ * bounded to `limit` (default {@link DEFAULT_LIST_LIMIT}) so a large backlog
811
+ * can't be JSON-serialized and fanned out to every socket in one shot. Lists
812
+ * `limit + 1` and slices back down so `truncated` reflects whether there was
813
+ * a next row, without a second round-trip.
814
+ */
782
815
  private listRecords;
816
+ /**
817
+ * Page through every `id:` header exactly once with bounded per-page memory
818
+ * (a `limit`+`startAfter` cursor loop), invoking `visit` for each record.
819
+ * Unlike {@link listRecords}, which intentionally truncates for the studio's
820
+ * live view, `/status` and `/pool` need EXACT counts — this walks the full
821
+ * set, but never materializes more than one page at a time.
822
+ */
823
+ private countHeaders;
783
824
  /**
784
825
  * HMAC-SHA-256 sign the dispatch body with `env.LUNORA_SCHEDULER_SECRET`,
785
826
  * returning a base64url signature, or `undefined` when no secret is
@@ -826,7 +867,7 @@ declare class SchedulerDO {
826
867
  * a saturated-but-idle pool stays visible; a pool that only ever existed as
827
868
  * queued jobs without a persisted row is unreachable here (the schedule path
828
869
  * always writes a `pool:&lt;name>` row before the job's header), so a single
829
- * scan over `pool:`/`id:` is sufficient.
870
+ * scan over `pool:` plus a cursor loop over `id:` is sufficient.
830
871
  */
831
872
  private handleStatus;
832
873
  private handleSchedule;
@@ -900,10 +941,29 @@ interface SchedulerHostOptions {
900
941
  declare const createSchedulerHost: (options: SchedulerHostOptions) => SchedulerHost;
901
942
  /** Standard cron expression (5- or 6-field) or a supported `@macro`, per `cron-parser`. */
902
943
  declare const isValidCronExpression: (schedule: string) => boolean;
944
+ /**
945
+ * A 6-field (seconds-leading) cron expression is legal generic cron grammar
946
+ * but not a Cloudflare Cron Trigger — the platform only understands the
947
+ * 5-field, minute-granularity form and rejects the rest at `wrangler deploy`
948
+ * with a message naming neither the job nor the file. Warn here instead of
949
+ * staying silent, without throwing: unlike the ergonomic `.interval()` form,
950
+ * the raw `.cron()` escape hatch is meant to accept cron grammar this module
951
+ * doesn't otherwise second-guess.
952
+ *
953
+ * Exported (not module-private) because it is the ONE place this advisory is
954
+ * written: the runtime `cronJobs()` builder reaches it via
955
+ * {@link assertValidCronExpression}, and `@lunora/codegen`'s static
956
+ * `discover-crons.ts` calls it directly after its own `isValidCronExpression`
957
+ * check — so a hand-authored 6-field `.cron()` warns whether it's discovered
958
+ * from source at build time or registered at runtime, with one shared message.
959
+ */
960
+ declare const warnIfSecondsLeading: (schedule: string, context: string) => void;
903
961
  /**
904
962
  * Assert a raw cron expression is well-formed, throwing the same shaped error
905
963
  * both cron surfaces use. The `context` prefix lets callers name the offending
906
- * job (`cron job "send digest"`) vs. the bare trigger.
964
+ * job (`cron job "send digest"`) vs. the bare trigger. Well-formed but
965
+ * Cloudflare-incompatible (6-field) expressions pass but log a warning — see
966
+ * {@link warnIfSecondsLeading}.
907
967
  */
908
968
  declare const assertValidCronExpression: (schedule: string, context?: string) => void;
909
- export { type ArgsOf, CRON_SCHEDULE_KINDS, type CronJob, type CronJobsBuilder, type CronScheduleKind, type CronTarget, type CronTriggerOptions, type CronTriggerSnippet, type DailySchedule, type DurableObjectIdLike, type DurableObjectJurisdiction, type DurableObjectNamespaceLike, type DurableObjectStubLike, type EnqueueOptions, type FunctionReference, type HourlySchedule, type HttpDispatcherOptions, type IntervalSchedule, type LunoraSchedulerOptions, 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 SchedulerHostOptions, type SchedulerPoolStatus, type SchedulerStatus, type WeeklySchedule, type WorkflowReference, type Workpool, type WorkpoolOptions, assertValidCronExpression, compileCronSchedule, createCronTrigger, createQueueConsumer, createQueueWorkpool, createScheduler, createSchedulerHost, createWorkpool, cronJobs, httpDispatcher, isValidCronExpression, isWorkflowReference };
969
+ export { type ArgsOf, CRON_SCHEDULE_KINDS, type CronJob, type CronJobsBuilder, type CronScheduleKind, type CronTarget, type CronTriggerOptions, type CronTriggerSnippet, type DailySchedule, type DurableObjectIdLike, type DurableObjectJurisdiction, type DurableObjectNamespaceLike, type DurableObjectStubLike, type EnqueueOptions, type FunctionReference, type HourlySchedule, type HttpDispatcherOptions, type IntervalSchedule, type LunoraSchedulerOptions, 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 SchedulerHostOptions, type SchedulerPoolStatus, type SchedulerStatus, type WeeklySchedule, type WorkflowReference, type Workpool, type WorkpoolOptions, assertValidCronExpression, compileCronSchedule, createCronTrigger, createQueueConsumer, createQueueWorkpool, createScheduler, createSchedulerHost, createWorkpool, cronJobs, httpDispatcher, isValidCronExpression, isWorkflowReference, warnIfSecondsLeading };
package/dist/index.d.ts CHANGED
@@ -497,9 +497,11 @@ declare const CRON_SCHEDULE_KINDS: ReadonlySet<CronScheduleKind>;
497
497
  * Compile one of the ergonomic schedule forms into a standard cron expression.
498
498
  * Exposed as a pure function so `@lunora/codegen` can reuse the exact same
499
499
  * compilation when it statically lifts a `crons.{kind}(...)` call out of the
500
- * AST — codegen imports this directly (no duplicated mirror).
500
+ * AST — codegen imports this directly (no duplicated mirror). `jobName` is
501
+ * optional (see {@link compileInterval}) so codegen's existing 2-arg call site
502
+ * keeps working unchanged.
501
503
  */
502
- declare const compileCronSchedule: (kind: CronScheduleKind, schedule: DailySchedule | HourlySchedule | IntervalSchedule | MonthlySchedule | WeeklySchedule) => string;
504
+ declare const compileCronSchedule: (kind: CronScheduleKind, schedule: DailySchedule | HourlySchedule | IntervalSchedule | MonthlySchedule | WeeklySchedule, jobName?: string) => string;
503
505
  /**
504
506
  * Builder returned by {@link cronJobs}. Each method registers one recurring
505
507
  * job; the compiled expression is validated immediately so authoring mistakes
@@ -566,6 +568,15 @@ interface SchedulerDOState {
566
568
  acceptWebSocket?: (ws: WebSocket) => void;
567
569
  /** Every accepted server WebSocket (workers `state.getWebSockets`). */
568
570
  getWebSockets?: () => WebSocket[];
571
+ /**
572
+ * Register a constant ping/pong auto-response so the runtime answers a
573
+ * known keepalive frame on a hibernated socket WITHOUT waking this DO (no
574
+ * billable request, no dispatch). Optional: absent in the unit harness and
575
+ * older runtimes, present on the real `DurableObjectState`. Mirrors
576
+ * `@lunora/do`'s `ShardDOState.setWebSocketAutoResponse` — see
577
+ * {@link SchedulerDO.armWebSocketKeepalive}.
578
+ */
579
+ setWebSocketAutoResponse?: (pair: WebSocketRequestResponsePair) => void;
569
580
  storage: {
570
581
  delete: (key: string | string[]) => Promise<number | boolean>;
571
582
  deleteAlarm: () => Promise<void> | void;
@@ -575,6 +586,7 @@ interface SchedulerDOState {
575
586
  end?: string;
576
587
  limit?: number;
577
588
  prefix?: string;
589
+ startAfter?: string;
578
590
  }) => Promise<Map<string, T>>;
579
591
  put: <T = unknown>(entries: Record<string, T> | string, value?: T) => Promise<void>;
580
592
  setAlarm: (scheduledTime: number | Date) => Promise<void> | void;
@@ -708,6 +720,20 @@ declare class SchedulerDO {
708
720
  * `false` so the record is retried rather than silently dropped.
709
721
  */
710
722
  protected dispatch(record: ScheduleRecord): Promise<boolean>;
723
+ /**
724
+ * Register the hibernation-safe ping/pong keepalive. The runtime answers a
725
+ * {@link WS_KEEPALIVE_PING} text frame with {@link WS_KEEPALIVE_PONG}
726
+ * WITHOUT waking this Durable Object, keeping an idle `/ws` subscription
727
+ * alive across hibernation with no billable wakeup and no dispatch. Without
728
+ * this, a client's heartbeat ping goes unanswered and its watchdog force-
729
+ * closes the socket every ~90s, defeating hibernation (each unanswered ping
730
+ * wakes the DO to reconnect) — mirrors `@lunora/do`'s
731
+ * `ShardDO.armWebSocketKeepalive`. The auto-response is per-instance, so
732
+ * this re-runs on every construction (including a post-hibernation wake).
733
+ * Guarded: the API and the `WebSocketRequestResponsePair` global are absent
734
+ * in the unit harness and on older runtimes, where it degrades to a no-op.
735
+ */
736
+ private armWebSocketKeepalive;
711
737
  /**
712
738
  * Claim + drain one due record with per-record fault isolation, so a storage
713
739
  * throw can never abort the whole alarm pass (which would skip the remaining
@@ -773,13 +799,28 @@ declare class SchedulerDO {
773
799
  */
774
800
  private handleWebSocketUpgrade;
775
801
  /**
776
- * Re-list the jobs and push them to every connected subscriber. Called after
777
- * any change (schedule / cancel / alarm-fire) so live studios reflect it
778
- * immediately. A no-op when the runtime doesn't support hibernated sockets.
802
+ * Re-list the jobs (bounded see {@link listRecords}) and push them to
803
+ * every connected subscriber. Called after any change (schedule / cancel /
804
+ * alarm-fire) so live studios reflect it immediately. A no-op when the
805
+ * runtime doesn't support hibernated sockets.
779
806
  */
780
807
  private broadcastChange;
781
- /** The current pending job records (shared by `/list` and the live channel). */
808
+ /**
809
+ * The current pending job records (shared by `/list` and the live channel),
810
+ * bounded to `limit` (default {@link DEFAULT_LIST_LIMIT}) so a large backlog
811
+ * can't be JSON-serialized and fanned out to every socket in one shot. Lists
812
+ * `limit + 1` and slices back down so `truncated` reflects whether there was
813
+ * a next row, without a second round-trip.
814
+ */
782
815
  private listRecords;
816
+ /**
817
+ * Page through every `id:` header exactly once with bounded per-page memory
818
+ * (a `limit`+`startAfter` cursor loop), invoking `visit` for each record.
819
+ * Unlike {@link listRecords}, which intentionally truncates for the studio's
820
+ * live view, `/status` and `/pool` need EXACT counts — this walks the full
821
+ * set, but never materializes more than one page at a time.
822
+ */
823
+ private countHeaders;
783
824
  /**
784
825
  * HMAC-SHA-256 sign the dispatch body with `env.LUNORA_SCHEDULER_SECRET`,
785
826
  * returning a base64url signature, or `undefined` when no secret is
@@ -826,7 +867,7 @@ declare class SchedulerDO {
826
867
  * a saturated-but-idle pool stays visible; a pool that only ever existed as
827
868
  * queued jobs without a persisted row is unreachable here (the schedule path
828
869
  * always writes a `pool:&lt;name>` row before the job's header), so a single
829
- * scan over `pool:`/`id:` is sufficient.
870
+ * scan over `pool:` plus a cursor loop over `id:` is sufficient.
830
871
  */
831
872
  private handleStatus;
832
873
  private handleSchedule;
@@ -900,10 +941,29 @@ interface SchedulerHostOptions {
900
941
  declare const createSchedulerHost: (options: SchedulerHostOptions) => SchedulerHost;
901
942
  /** Standard cron expression (5- or 6-field) or a supported `@macro`, per `cron-parser`. */
902
943
  declare const isValidCronExpression: (schedule: string) => boolean;
944
+ /**
945
+ * A 6-field (seconds-leading) cron expression is legal generic cron grammar
946
+ * but not a Cloudflare Cron Trigger — the platform only understands the
947
+ * 5-field, minute-granularity form and rejects the rest at `wrangler deploy`
948
+ * with a message naming neither the job nor the file. Warn here instead of
949
+ * staying silent, without throwing: unlike the ergonomic `.interval()` form,
950
+ * the raw `.cron()` escape hatch is meant to accept cron grammar this module
951
+ * doesn't otherwise second-guess.
952
+ *
953
+ * Exported (not module-private) because it is the ONE place this advisory is
954
+ * written: the runtime `cronJobs()` builder reaches it via
955
+ * {@link assertValidCronExpression}, and `@lunora/codegen`'s static
956
+ * `discover-crons.ts` calls it directly after its own `isValidCronExpression`
957
+ * check — so a hand-authored 6-field `.cron()` warns whether it's discovered
958
+ * from source at build time or registered at runtime, with one shared message.
959
+ */
960
+ declare const warnIfSecondsLeading: (schedule: string, context: string) => void;
903
961
  /**
904
962
  * Assert a raw cron expression is well-formed, throwing the same shaped error
905
963
  * both cron surfaces use. The `context` prefix lets callers name the offending
906
- * job (`cron job "send digest"`) vs. the bare trigger.
964
+ * job (`cron job "send digest"`) vs. the bare trigger. Well-formed but
965
+ * Cloudflare-incompatible (6-field) expressions pass but log a warning — see
966
+ * {@link warnIfSecondsLeading}.
907
967
  */
908
968
  declare const assertValidCronExpression: (schedule: string, context?: string) => void;
909
- export { type ArgsOf, CRON_SCHEDULE_KINDS, type CronJob, type CronJobsBuilder, type CronScheduleKind, type CronTarget, type CronTriggerOptions, type CronTriggerSnippet, type DailySchedule, type DurableObjectIdLike, type DurableObjectJurisdiction, type DurableObjectNamespaceLike, type DurableObjectStubLike, type EnqueueOptions, type FunctionReference, type HourlySchedule, type HttpDispatcherOptions, type IntervalSchedule, type LunoraSchedulerOptions, 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 SchedulerHostOptions, type SchedulerPoolStatus, type SchedulerStatus, type WeeklySchedule, type WorkflowReference, type Workpool, type WorkpoolOptions, assertValidCronExpression, compileCronSchedule, createCronTrigger, createQueueConsumer, createQueueWorkpool, createScheduler, createSchedulerHost, createWorkpool, cronJobs, httpDispatcher, isValidCronExpression, isWorkflowReference };
969
+ export { type ArgsOf, CRON_SCHEDULE_KINDS, type CronJob, type CronJobsBuilder, type CronScheduleKind, type CronTarget, type CronTriggerOptions, type CronTriggerSnippet, type DailySchedule, type DurableObjectIdLike, type DurableObjectJurisdiction, type DurableObjectNamespaceLike, type DurableObjectStubLike, type EnqueueOptions, type FunctionReference, type HourlySchedule, type HttpDispatcherOptions, type IntervalSchedule, type LunoraSchedulerOptions, 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 SchedulerHostOptions, type SchedulerPoolStatus, type SchedulerStatus, type WeeklySchedule, type WorkflowReference, type Workpool, type WorkpoolOptions, assertValidCronExpression, compileCronSchedule, createCronTrigger, createQueueConsumer, createQueueWorkpool, createScheduler, createSchedulerHost, createWorkpool, cronJobs, httpDispatcher, isValidCronExpression, isWorkflowReference, warnIfSecondsLeading };
package/dist/index.mjs CHANGED
@@ -1 +1 @@
1
- import{default as o}from"./packem_shared/createScheduler-CsRAEtdb.mjs";import{default as p}from"./packem_shared/createWorkpool-DyVdhB6o.mjs";import{createCronTrigger as c}from"./packem_shared/createCronTrigger-CyPdVKNF.mjs";import{CRON_SCHEDULE_KINDS as f,compileCronSchedule as l,cronJobs as m}from"./packem_shared/CRON_SCHEDULE_KINDS-DgupPI9t.mjs";import{createQueueConsumer as x,createQueueWorkpool as i,httpDispatcher as n}from"./packem_shared/createQueueConsumer-B0qNXRUJ.mjs";import{SchedulerDO as C}from"./packem_shared/SchedulerDO-DGgeZ-uI.mjs";import{createSchedulerHost as S}from"./packem_shared/createSchedulerHost-C5XacwXv.mjs";import{isWorkflowReference as E}from"./packem_shared/isWorkflowReference-CT3tdefh.mjs";import{assertValidCronExpression as W,isValidCronExpression as g}from"./packem_shared/assertValidCronExpression-DHvO7Vm9.mjs";export{f as CRON_SCHEDULE_KINDS,C as SchedulerDO,W as assertValidCronExpression,l as compileCronSchedule,c as createCronTrigger,x as createQueueConsumer,i as createQueueWorkpool,o as createScheduler,S as createSchedulerHost,p as createWorkpool,m as cronJobs,n as httpDispatcher,g as isValidCronExpression,E as isWorkflowReference};
1
+ import{default as o}from"./packem_shared/createScheduler-CsRAEtdb.mjs";import{default as a}from"./packem_shared/createWorkpool-DyVdhB6o.mjs";import{createCronTrigger as c}from"./packem_shared/createCronTrigger-C4iUW_zz.mjs";import{CRON_SCHEDULE_KINDS as f,compileCronSchedule as l,cronJobs as n}from"./packem_shared/CRON_SCHEDULE_KINDS-DMu4DfhG.mjs";import{createQueueConsumer as u,createQueueWorkpool as x,httpDispatcher as d}from"./packem_shared/createQueueConsumer-BnX7wkJO.mjs";import{SchedulerDO as C}from"./packem_shared/SchedulerDO-CCP9LV5x.mjs";import{createSchedulerHost as h}from"./packem_shared/createSchedulerHost-C5XacwXv.mjs";import{isWorkflowReference as E}from"./packem_shared/isWorkflowReference-CT3tdefh.mjs";import{assertValidCronExpression as k,isValidCronExpression as W,warnIfSecondsLeading as w}from"./packem_shared/assertValidCronExpression-B2lN8n3O.mjs";export{f as CRON_SCHEDULE_KINDS,C as SchedulerDO,k as assertValidCronExpression,l as compileCronSchedule,c as createCronTrigger,u as createQueueConsumer,x as createQueueWorkpool,o as createScheduler,h as createSchedulerHost,a as createWorkpool,n as cronJobs,d as httpDispatcher,W as isValidCronExpression,E as isWorkflowReference,w as warnIfSecondsLeading};
@@ -0,0 +1 @@
1
+ import{LunoraError as a}from"@lunora/errors";import{isWorkflowReference as y}from"./isWorkflowReference-CT3tdefh.mjs";import{assertValidCronExpression as c}from"./assertValidCronExpression-B2lN8n3O.mjs";const f={friday:5,monday:1,saturday:6,sunday:0,thursday:4,tuesday:2,wednesday:3},w=24,h=" For a daily or longer schedule use crons.daily(name, { hourUTC, minuteUTC }, …), crons.weekly(name, { dayOfWeek, hourUTC, minuteUTC }, …) or crons.monthly(name, { day, hourUTC, minuteUTC }, …).",l=(e,o,r,s)=>{if(!Number.isInteger(e)||e<r||e>s)throw new a("INTERNAL",`@lunora/scheduler: cronJobs ${o} must be an integer in [${r.toFixed(0)}, ${s.toFixed(0)}], got ${String(e)}`);return e.toFixed(0)},m=(e,o,r)=>{const s=l(e,o,1,r-1);if(r%e!==0)throw new a("INTERNAL",`@lunora/scheduler: ${o} must evenly divide ${r.toFixed(0)} for a fixed "every ${e.toFixed(0)}" interval — cron "*/${e.toFixed(0)}" means "at values divisible by ${e.toFixed(0)}", which wraps unevenly; pick a divisor of ${r.toFixed(0)}`);return s},$=(e,o)=>{const r=["seconds","minutes","hours"].filter(i=>e[i]!==void 0);if(r.length!==1){const i=Object.entries(e).filter(([,t])=>t!==void 0).map(([t])=>t);throw new a("INTERNAL",`@lunora/scheduler: interval schedule must specify exactly one of { seconds, minutes, hours }, got { ${i.join(", ")} }.${h}`)}const s=r[0],n=e[s];if(s==="hours"&&n>=w)throw new a("INTERNAL",`@lunora/scheduler: interval.hours is capped at 23, got ${String(n)} — an interval repeats WITHIN a day rather than spanning one.${h}`);if(s==="seconds")throw new a("INTERNAL",`@lunora/scheduler: cron job${o?` "${o}"`:""} uses interval.seconds (${String(n)}) — Cloudflare Cron Triggers have a one-minute floor and cannot run sub-minute schedules; \`wrangler deploy\` would reject the resulting 6-field cron expression. Use ctx.scheduler.runAfter/runAt (optionally via a workpool for bounded concurrency) for sub-minute recurrence, or crons.interval(name, { minutes: 1 }, …) for the fastest cron-native cadence.`);return s==="minutes"?`*/${m(n,"interval.minutes",60)} * * * *`:`0 */${m(n,"interval.hours",24)} * * *`},T=e=>`${l(e.minuteUTC,"hourly.minuteUTC",0,59)} * * * *`,p=e=>{const o=l(e.minuteUTC,"daily.minuteUTC",0,59),r=l(e.hourUTC,"daily.hourUTC",0,23);return`${o} ${r} * * *`},C=e=>{const o=f[e.dayOfWeek];if(o===void 0)throw new a("INTERNAL",`@lunora/scheduler: weekly schedule has invalid dayOfWeek "${e.dayOfWeek}"`);const r=l(e.minuteUTC,"weekly.minuteUTC",0,59),s=l(e.hourUTC,"weekly.hourUTC",0,23);return`${r} ${s} * * ${o.toFixed(0)}`},v=e=>{const o=l(e.day,"monthly.day",1,31),r=l(e.minuteUTC,"monthly.minuteUTC",0,59),s=l(e.hourUTC,"monthly.hourUTC",0,23);return`${r} ${s} ${o} * *`},b=new Set(["daily","hourly","interval","monthly","weekly"]),d=(e,o,r)=>{switch(e){case"daily":return p(o);case"hourly":return T(o);case"interval":return $(o,r);case"monthly":return v(o);case"weekly":return C(o);default:throw new a("INTERNAL",`@lunora/scheduler: unknown cron schedule kind "${String(e)}"`)}},k=()=>{const e=[],o=new Set,r=(n,i,t,u)=>{if(typeof n!="string"||n.trim()==="")throw new a("INTERNAL","@lunora/scheduler: cron job name must be a non-empty string");if(o.has(n))throw new a("INTERNAL",`@lunora/scheduler: duplicate cron job name "${n}" — names must be unique within one cronJobs()`);if(y(t)){c(i,`cron expression for job "${n}"`),o.add(n),e.push({args:u??{},cron:i,name:n,workflow:typeof t.name=="string"?t.name:""});return}if(!t||typeof t.__lunoraRef!="string")throw new a("INTERNAL",`@lunora/scheduler: cron job "${n}" requires a function reference (e.g. internal.email.digest) or a workflow reference`);c(i,`cron expression for job "${n}"`),o.add(n),e.push({args:u??{},cron:i,functionPath:t.__lunoraRef,name:n})},s={cron(n,i,t,u){return r(n,i,t,u),s},daily(n,i,t,u){return r(n,d("daily",i),t,u),s},hourly(n,i,t,u){return r(n,d("hourly",i),t,u),s},interval(n,i,t,u){return r(n,d("interval",i,n),t,u),s},jobs:()=>[...e],monthly(n,i,t,u){return r(n,d("monthly",i),t,u),s},weekly(n,i,t,u){return r(n,d("weekly",i),t,u),s}};return s};export{b as CRON_SCHEDULE_KINDS,d as compileCronSchedule,k as cronJobs};
@@ -0,0 +1 @@
1
+ const f=(u,t=200,e)=>Response.json(u,{headers:{"content-type":"application/json",...e},status:t}),w="lunora-ping",b="lunora-pong";const p="retry:",g="dead:",m="pool:";const y=u=>String(u).padStart(15,"0"),v=()=>{const u=crypto.getRandomValues(new Uint8Array(12));let t="";for(const e of u)t+=String.fromCodePoint(e);return btoa(t).replaceAll("+","-").replaceAll("/","_").replaceAll("=","")};class r{static indexKey(t,e){return`t:${y(t)}:${e}`}static json(t,e=200){return f(t,e)}static error(t,e,s){return r.json({error:{code:e,message:s}},t)}static resolveRetry(t){const e=t.retry,s=typeof e?.maxAttempts=="number"&&Number.isInteger(e.maxAttempts)&&e.maxAttempts>0?e.maxAttempts:5,a=typeof e?.baseMs=="number"&&Number.isFinite(e.baseMs)&&e.baseMs>=0?e.baseMs:3e4,n=e?.backoff==="linear"?"linear":"exponential",i=typeof e?.maxMs=="number"&&Number.isFinite(e.maxMs)&&e.maxMs>=0?e.maxMs:void 0;return{backoff:n,baseMs:a,maxAttempts:s,maxMs:i}}static normalizeConcurrency(t,e){return typeof t=="number"&&Number.isInteger(t)&&t>0?t:e}static normalizeRetry(t){if(typeof t!="object"||t===null)return;const e=t,s={};return typeof e.maxAttempts=="number"&&Number.isInteger(e.maxAttempts)&&e.maxAttempts>0&&(s.maxAttempts=e.maxAttempts),typeof e.baseMs=="number"&&Number.isFinite(e.baseMs)&&e.baseMs>=0&&(s.baseMs=e.baseMs),(e.backoff==="exponential"||e.backoff==="linear")&&(s.backoff=e.backoff),typeof e.maxMs=="number"&&Number.isFinite(e.maxMs)&&e.maxMs>=0&&(s.maxMs=e.maxMs),Object.keys(s).length===0?void 0:s}static releaseSlot(t,e){if(t.inFlightIds===void 0)return{...t,inFlight:Math.max(0,t.inFlight-1)};const s=t.inFlightIds.filter(a=>a!==e);return{...t,inFlight:s.length,inFlightIds:s}}static releaseFirstSlot(t){if(t.inFlightIds===void 0)return{...t,inFlight:Math.max(0,t.inFlight-1)};const e=t.inFlightIds.slice(0,Math.max(0,t.inFlightIds.length-1));return{...t,inFlight:e.length,inFlightIds:e}}static resolveScheduleTarget(t){const e=typeof t?.functionPath=="string"&&t.functionPath.length>0?t.functionPath:void 0,s=typeof t?.workflow=="string"&&t.workflow.length>0?t.workflow:void 0;if(!(e===void 0&&s===void 0))return{functionPath:e,workflow:s}}state;env;constructor(t,e){this.state=t,this.env=e,this.armWebSocketKeepalive()}async fetch(t){const e=new URL(t.url);if(e.pathname==="/ws"&&t.headers.get("Upgrade")==="websocket")return this.handleWebSocketUpgrade();switch(`${t.method} ${e.pathname}`){case"GET /dead":return this.handleDeadList();case"GET /get":return this.handleGet(e);case"GET /list":return this.handleList();case"GET /pool":return this.handlePoolStatus(e);case"GET /status":return this.handleStatus();case"POST /cancel":return this.handleCancel(t);case"POST /complete":return this.handleComplete(t);case"POST /dead/cancel":return this.handleDeadCancel(t);case"POST /dead/retry":return this.handleDeadRetry(t);case"POST /schedule":return this.handleSchedule(t)}return f({error:{code:"NOT_FOUND"}},404)}async alarm(){const t=Date.now(),e=[],s=await this.state.storage.list({end:`t:${y(t)}:~`,limit:100,prefix:"t:"});for(const[a,n]of s.entries()){const i=Number.parseInt(a.slice(2,a.indexOf(":",2)),10);if(Number.isFinite(i)&&i<=t){const o=await this.state.storage.get(`id:${n}`);o?e.push(o):await this.state.storage.delete(a)}}try{for(const a of e)await this.drainRecordGuarded(a)}finally{await this.rescheduleAlarm()}e.length>0&&await this.broadcastChange()}async dispatch(t){const e=typeof this.env.LUNORA_ORIGIN_URL=="string"&&this.env.LUNORA_ORIGIN_URL.length>0?this.env.LUNORA_ORIGIN_URL:void 0;if(!e)return!1;const s=JSON.stringify({args:t.args,functionPath:t.functionPath,id:t.id,instanceName:t.instanceName,pool:t.pool,scheduledFor:t.scheduledFor,shardKey:t.shardKey,workflow:t.workflow});try{const a={"content-type":"application/json"},n=await this.signDispatch(s);return n!==void 0?a["x-lunora-scheduler-signature"]=n:typeof this.env.LUNORA_ADMIN_TOKEN=="string"&&this.env.LUNORA_ADMIN_TOKEN.length>0&&(a.authorization=`Bearer ${this.env.LUNORA_ADMIN_TOKEN}`),(await fetch(`${e}/_lunora/scheduler/dispatch`,{body:s,headers:a,method:"POST"})).ok}catch{return!1}}armWebSocketKeepalive(){const t=this.state.setWebSocketAutoResponse;typeof t!="function"||typeof WebSocketRequestResponsePair>"u"||t.call(this.state,new WebSocketRequestResponsePair(w,b))}async drainRecordGuarded(t){try{await this.state.storage.delete(r.indexKey(t.scheduledFor,t.id)),await this.drainRecord(t)}catch{try{await this.state.storage.put(r.indexKey(t.scheduledFor,t.id),t.id)}catch{}}}async drainRecord(t){if(!await this.reservePoolSlot(t))return!1;const e=await this.dispatch(t);if(!e&&t.pool!==void 0){const s=await this.loadPool(t.pool),a=r.releaseSlot(s,t.id);await this.savePool(t.pool,a)}if(e){try{await this.state.storage.delete([`id:${t.id}`,`${p}${t.id}`])}catch{}return!0}return await this.recordRetry(t),!1}async reservePoolSlot(t){if(t.pool===void 0)return!0;const e=await this.loadPool(t.pool);if(e.inFlight>=e.maxConcurrency)return await this.requeuePooled(t),!1;const s=e.inFlightIds??[];return s.includes(t.id)||s.push(t.id),e.inFlightIds=s,e.inFlight=s.length,await this.savePool(t.pool,e),!0}async handleWebSocketUpgrade(){if(this.state.acceptWebSocket===void 0)return r.error(501,"WS_UNSUPPORTED","WebSocket subscriptions are not supported in this runtime");const t=new WebSocketPair,e=t[0],s=t[1];this.state.acceptWebSocket(s);const a=await this.listRecords();return s.send(JSON.stringify({records:a.records,truncated:a.truncated,type:"jobs"})),new Response(null,{status:101,webSocket:e})}async broadcastChange(){const t=this.state.getWebSockets?.();if(t===void 0||t.length===0)return;const{records:e,truncated:s}=await this.listRecords(),a=JSON.stringify({records:e,truncated:s,type:"jobs"});for(const n of t)try{n.send(a)}catch{}}async listRecords(t=100){const e=[...(await this.state.storage.list({limit:t+1,prefix:"id:"})).values()],s=e.length>t;return{records:s?e.slice(0,t):e,truncated:s}}async countHeaders(t,e=100){let s;for(;;){const a=await this.state.storage.list(s===void 0?{limit:e,prefix:"id:"}:{limit:e,prefix:"id:",startAfter:s});if(a.size===0)break;for(const n of a.values())t(n);if(s=[...a.keys()].at(-1),a.size<e)break}}async signDispatch(t){const e=typeof this.env.LUNORA_SCHEDULER_SECRET=="string"?this.env.LUNORA_SCHEDULER_SECRET:void 0;if(!e||e.length===0)return;const s=new TextEncoder,a=await crypto.subtle.importKey("raw",s.encode(e),{hash:"SHA-256",name:"HMAC"},!1,["sign"]),n=await crypto.subtle.sign("HMAC",a,s.encode(t)),i=new Uint8Array(n);let o="";for(const d of i)o+=String.fromCodePoint(d);return btoa(o).replaceAll("+","-").replaceAll("/","_").replaceAll("=","")}async recordRetry(t){const e=(t.attempts??0)+1,{backoff:s,baseMs:a,maxAttempts:n,maxMs:i}=r.resolveRetry(t);if(e>n){await this.state.storage.put(`${g}${t.id}`,{...t,attempts:e}),await this.state.storage.delete([`${p}${t.id}`,`id:${t.id}`]),console.warn(`@lunora/scheduler: job "${t.id}" (${t.functionPath??t.workflow??"unknown"}) parked in dead-letter after ${String(e)} attempts`);return}const o=s==="linear"?a*e:a*2**(e-1),d=i===void 0?o:Math.min(o,i),c=Date.now()+d,l={...t,attempts:e,scheduledFor:c};await this.state.storage.put(`${p}${t.id}`,l),await this.state.storage.put(`id:${t.id}`,l),await this.state.storage.put(r.indexKey(c,t.id),t.id)}async loadPool(t,e){const s=await this.state.storage.get(`${m}${t}`);return s!==void 0?Array.isArray(s.inFlightIds)?{inFlight:s.inFlightIds.length,inFlightIds:[...s.inFlightIds],maxConcurrency:s.maxConcurrency}:{inFlight:Math.max(0,s.inFlight),maxConcurrency:s.maxConcurrency}:{inFlight:0,inFlightIds:[],maxConcurrency:r.normalizeConcurrency(e,1)}}async savePool(t,e){await this.state.storage.put(`${m}${t}`,e)}async requeuePooled(t){const e=Date.now()+1e3,s={...t,scheduledFor:e};await this.state.storage.put(`id:${t.id}`,s),await this.state.storage.put(r.indexKey(e,t.id),t.id)}async handleComplete(t){const e=await t.json().catch(()=>{}),s=typeof e?.pool=="string"&&e.pool.length>0?e.pool:void 0,a=typeof e?.id=="string"&&e.id.length>0?e.id:void 0;if(s===void 0)return r.error(400,"INVALID_INPUT","pool is required");const n=await this.loadPool(s),i=a===void 0?r.releaseFirstSlot(n):r.releaseSlot(n,a);return await this.savePool(s,i),await this.armAlarmIfEarlier(Date.now()),r.json({inFlight:i.inFlight})}async handlePoolStatus(t){const e=t.searchParams.get("name");if(e===null||e.length===0)return r.error(400,"INVALID_INPUT","name is required");const s=await this.loadPool(e);let a=0;return await this.countHeaders(n=>{n.pool===e&&(a+=1)}),r.json({inFlight:s.inFlight,maxConcurrency:s.maxConcurrency,queued:a})}async handleStatus(){const t=await this.state.storage.list({prefix:m}),e=new Map;await this.countHeaders(o=>{o.pool!==void 0&&e.set(o.pool,(e.get(o.pool)??0)+1)});const s=[];let a=0,n=0;for(const[o,d]of t.entries()){const c=o.slice(m.length),l=Math.max(0,d.inFlight),h=e.get(c)??0;s.push({inFlight:l,maxConcurrency:d.maxConcurrency,name:c,queued:h}),a+=h,n+=l}const i={backlog:a,inFlight:n,pools:s};return r.json(i)}async handleSchedule(t){const e=await t.json().catch(()=>{}),s=r.resolveScheduleTarget(e);if(!e||s===void 0)return r.error(400,"INVALID_INPUT","functionPath or workflow is required");const{functionPath:a,workflow:n}=s;if(typeof e.scheduledFor!="number"||!Number.isInteger(e.scheduledFor)||e.scheduledFor<=0||e.scheduledFor>999999999999999)return r.error(400,"INVALID_INPUT","scheduledFor must be a positive integer epoch-millisecond number no greater than 999999999999999");if(typeof this.env.LUNORA_ORIGIN_URL!="string"||this.env.LUNORA_ORIGIN_URL.length===0)return r.error(500,"ORIGIN_NOT_CONFIGURED","LUNORA_ORIGIN_URL env binding must be set on the SchedulerDO");const i=typeof e.pool=="string"&&e.pool.length>0?e.pool:void 0,o=typeof e.instanceName=="string"&&e.instanceName.length>0?e.instanceName:void 0,d=r.normalizeRetry(e.retry),c=v(),l={args:e.args??{},enqueuedAt:Date.now(),id:c,...a===void 0?{}:{functionPath:a},...o===void 0?{}:{instanceName:o},...i===void 0?{}:{pool:i},...d===void 0?{}:{retry:d},scheduledFor:e.scheduledFor,shardKey:e.shardKey,...n===void 0?{}:{workflow:n}};if(i!==void 0){const h=await this.loadPool(i,e.maxConcurrency);await this.savePool(i,{inFlight:h.inFlight,...h.inFlightIds===void 0?{}:{inFlightIds:h.inFlightIds},maxConcurrency:r.normalizeConcurrency(e.maxConcurrency,h.maxConcurrency)})}return await this.state.storage.put(`id:${c}`,l),await this.state.storage.put(r.indexKey(l.scheduledFor,c),c),await this.armAlarmIfEarlier(l.scheduledFor),await this.broadcastChange(),r.json({id:c,scheduledFor:l.scheduledFor})}async handleCancel(t){const e=await t.json().catch(()=>{});if(!e?.id)return r.error(400,"INVALID_INPUT","id is required");const s=await this.state.storage.get(`id:${e.id}`);return s?(await this.removeRecord(s),await this.rescheduleAlarm(),await this.broadcastChange(),r.json({cancelled:!0})):r.json({cancelled:!1})}async handleList(){const{records:t,truncated:e}=await this.listRecords();return r.json({records:t,truncated:e})}async handleDeadList(){const t=await this.state.storage.list({prefix:g});return r.json({records:[...t.values()]})}async handleDeadRetry(t){const e=await t.json().catch(()=>{});if(typeof e?.id!="string"||e.id.length===0)return r.error(400,"INVALID_INPUT","id is required");const s=await this.state.storage.get(`${g}${e.id}`);if(s===void 0)return r.json({retried:!1});const a=Date.now(),n={...s,attempts:0,scheduledFor:a};return await this.state.storage.put(`id:${s.id}`,n),await this.state.storage.put(r.indexKey(a,s.id),s.id),await this.state.storage.delete(`${g}${s.id}`),await this.armAlarmIfEarlier(a),await this.broadcastChange(),r.json({id:s.id,retried:!0,scheduledFor:a})}async handleDeadCancel(t){const e=await t.json().catch(()=>{});if(typeof e?.id!="string"||e.id.length===0)return r.error(400,"INVALID_INPUT","id is required");const s=await this.state.storage.delete(`${g}${e.id}`);return r.json({removed:!!s})}async handleGet(t){const e=t.searchParams.get("id");if(e===null||e.length===0)return r.error(400,"INVALID_INPUT","id is required");const s=await this.state.storage.get(`id:${e}`);return r.json(s===void 0?{}:{record:s})}async removeRecord(t){await this.state.storage.delete([`id:${t.id}`,r.indexKey(t.scheduledFor,t.id),`${p}${t.id}`])}async armAlarmIfEarlier(t){const e=await this.state.storage.getAlarm();(e===null||t<e)&&await this.state.storage.setAlarm(t)}async rescheduleAlarm(){const t=(await this.state.storage.list({limit:1,prefix:"t:"})).entries().next();if(t.done){await this.state.storage.deleteAlarm();return}const[e]=t.value,s=Number.parseInt(e.slice(2,e.indexOf(":",2)),10);Number.isFinite(s)&&await this.state.storage.setAlarm(s)}}export{r as SchedulerDO};
@@ -0,0 +1 @@
1
+ import{LunoraError as s}from"@lunora/errors";import{CronExpressionParser as o}from"cron-parser";const n=r=>{if(typeof r!="string"||r.trim()==="")return!1;try{return o.parse(r.trim()),!0}catch{return!1}},t=/\s+/u,i=(r,e)=>{r.trim().split(t).length===6&&console.warn(`@lunora/scheduler: ${e} "${r}" is a 6-field (seconds-leading) cron expression — Cloudflare Cron Triggers only support the standard 5-field form and will reject this at \`wrangler deploy\`. Drop the seconds field, or use ctx.scheduler.runAfter/runAt for sub-minute work.`)},d=(r,e="cron expression")=>{if(!n(r))throw new s("INTERNAL",`@lunora/scheduler: invalid ${e} "${r}" — expected a standard 5- or 6-field cron expression (e.g. "0 * * * *")`);i(r,e)};export{d as assertValidCronExpression,n as isValidCronExpression,i as warnIfSecondsLeading};
@@ -1 +1 @@
1
- import{LunoraError as s}from"@lunora/errors";import{assertValidCronExpression as n}from"./assertValidCronExpression-DHvO7Vm9.mjs";const a=r=>{if(!r.schedule||!r.fn)throw new s("INTERNAL","@lunora/scheduler: createCronTrigger() requires `schedule` and `fn`");n(r.schedule);const e=JSON.stringify({triggers:{crons:[r.schedule]}},void 0,2);return{crons:[r.schedule],dispatcher:{args:r.args??{},functionPath:r.fn.__lunoraRef},wranglerJsonc:e}};export{a as createCronTrigger};
1
+ import{LunoraError as s}from"@lunora/errors";import{assertValidCronExpression as n}from"./assertValidCronExpression-B2lN8n3O.mjs";const t=r=>{if(!r.schedule||!r.fn)throw new s("INTERNAL","@lunora/scheduler: createCronTrigger() requires `schedule` and `fn`");n(r.schedule);const e=JSON.stringify({triggers:{crons:[r.schedule]}},void 0,2);return{crons:[r.schedule],dispatcher:{args:r.args??{},functionPath:r.fn.__lunoraRef},wranglerJsonc:e}};export{t as createCronTrigger};
@@ -0,0 +1 @@
1
+ import{LunoraError as o}from"@lunora/errors";const s=100,i=e=>{let a=e.length;for(;a>0&&e[a-1]==="/";)a-=1;return e.slice(0,a)},l=e=>{if(!e.queue)throw new o("INTERNAL","@lunora/scheduler: `queue` (a Cloudflare Queue binding) is required");return{enqueue:async(a,t,n={})=>{const r={args:t,functionPath:a.__lunoraRef,shardKey:n.shardKey},u=n.delaySeconds===void 0?void 0:{delaySeconds:n.delaySeconds};await e.queue.send(r,u)},enqueueBatch:async(a,t)=>{if(a.length>s)throw new o("INTERNAL",`@lunora/scheduler: enqueueBatch exceeds ${String(s)} (got ${String(a.length)}) — split across calls`);const n=a.map(r=>({body:{args:r.args,functionPath:r.ref.__lunoraRef,shardKey:r.shardKey}}));await e.queue.sendBatch(n,t)}}},c=e=>typeof e=="object"&&e!==null&&typeof e.functionPath=="string",d=e=>async a=>{await Promise.all(a.messages.map(async t=>{try{if(!c(t.body))throw new o("INTERNAL","@lunora/scheduler: queue message body is not a QueueJob (missing functionPath)");await e.dispatch(t.body),t.ack()}catch{t.retry()}}))},f=e=>{const a=e.fetchImpl??globalThis.fetch;if(typeof a!="function")throw new TypeError("@lunora/scheduler: no fetch implementation available — pass fetchImpl or run on a platform with global fetch");const t=`${i(e.originUrl)}/_lunora/scheduler/dispatch`;return async n=>{const r=await a(t,{body:JSON.stringify({args:n.args??{},functionPath:n.functionPath,shardKey:n.shardKey}),headers:{authorization:`Bearer ${e.adminToken}`,"content-type":"application/json"},method:"POST"});if(!r.ok)throw new o("INTERNAL",`@lunora/scheduler: queue dispatch failed (${r.status.toString()}): ${await r.text()}`)}};export{d as createQueueConsumer,l as createQueueWorkpool,f as httpDispatcher};
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lunora/scheduler",
3
- "version": "1.0.0-alpha.16",
3
+ "version": "1.0.0-alpha.18",
4
4
  "description": "Scheduling for Lunora: runAfter / runAt and Cron Triggers via SchedulerDO",
5
5
  "keywords": [
6
6
  "cloudflare",
@@ -46,8 +46,8 @@
46
46
  "access": "public"
47
47
  },
48
48
  "dependencies": {
49
- "@lunora/errors": "1.0.0-alpha.10",
50
- "@lunora/platform": "1.0.0-alpha.1",
49
+ "@lunora/errors": "1.0.0-alpha.12",
50
+ "@lunora/platform": "1.0.0-alpha.3",
51
51
  "cron-parser": "5.6.2"
52
52
  },
53
53
  "engines": {
@@ -1 +0,0 @@
1
- import{LunoraError as s}from"@lunora/errors";import{isWorkflowReference as m}from"./isWorkflowReference-CT3tdefh.mjs";import{assertValidCronExpression as h}from"./assertValidCronExpression-DHvO7Vm9.mjs";const f={friday:5,monday:1,saturday:6,sunday:0,thursday:4,tuesday:2,wednesday:3},w=24,y=" For a daily or longer schedule use crons.daily(name, { hourUTC, minuteUTC }, …), crons.weekly(name, { dayOfWeek, hourUTC, minuteUTC }, …) or crons.monthly(name, { day, hourUTC, minuteUTC }, …).",l=(e,o,r,t)=>{if(!Number.isInteger(e)||e<r||e>t)throw new s("INTERNAL",`@lunora/scheduler: cronJobs ${o} must be an integer in [${r.toFixed(0)}, ${t.toFixed(0)}], got ${String(e)}`);return e.toFixed(0)},c=(e,o,r)=>{const t=l(e,o,1,r-1);if(r%e!==0)throw new s("INTERNAL",`@lunora/scheduler: ${o} must evenly divide ${r.toFixed(0)} for a fixed "every ${e.toFixed(0)}" interval — cron "*/${e.toFixed(0)}" means "at values divisible by ${e.toFixed(0)}", which wraps unevenly; pick a divisor of ${r.toFixed(0)}`);return t},T=e=>{const o=["seconds","minutes","hours"].filter(n=>e[n]!==void 0);if(o.length!==1){const n=Object.entries(e).filter(([,a])=>a!==void 0).map(([a])=>a);throw new s("INTERNAL",`@lunora/scheduler: interval schedule must specify exactly one of { seconds, minutes, hours }, got { ${n.join(", ")} }.${y}`)}const r=o[0],t=e[r];if(r==="hours"&&t>=w)throw new s("INTERNAL",`@lunora/scheduler: interval.hours is capped at 23, got ${String(t)} — an interval repeats WITHIN a day rather than spanning one.${y}`);return r==="seconds"?`*/${c(t,"interval.seconds",60)} * * * * *`:r==="minutes"?`*/${c(t,"interval.minutes",60)} * * * *`:`0 */${c(t,"interval.hours",24)} * * *`},$=e=>`${l(e.minuteUTC,"hourly.minuteUTC",0,59)} * * * *`,C=e=>{const o=l(e.minuteUTC,"daily.minuteUTC",0,59),r=l(e.hourUTC,"daily.hourUTC",0,23);return`${o} ${r} * * *`},p=e=>{const o=f[e.dayOfWeek];if(o===void 0)throw new s("INTERNAL",`@lunora/scheduler: weekly schedule has invalid dayOfWeek "${e.dayOfWeek}"`);const r=l(e.minuteUTC,"weekly.minuteUTC",0,59),t=l(e.hourUTC,"weekly.hourUTC",0,23);return`${r} ${t} * * ${o.toFixed(0)}`},v=e=>{const o=l(e.day,"monthly.day",1,31),r=l(e.minuteUTC,"monthly.minuteUTC",0,59),t=l(e.hourUTC,"monthly.hourUTC",0,23);return`${r} ${t} ${o} * *`},k=new Set(["daily","hourly","interval","monthly","weekly"]),d=(e,o)=>{switch(e){case"daily":return C(o);case"hourly":return $(o);case"interval":return T(o);case"monthly":return v(o);case"weekly":return p(o);default:throw new s("INTERNAL",`@lunora/scheduler: unknown cron schedule kind "${String(e)}"`)}},b=()=>{const e=[],o=new Set,r=(n,a,i,u)=>{if(typeof n!="string"||n.trim()==="")throw new s("INTERNAL","@lunora/scheduler: cron job name must be a non-empty string");if(o.has(n))throw new s("INTERNAL",`@lunora/scheduler: duplicate cron job name "${n}" — names must be unique within one cronJobs()`);if(m(i)){h(a,`cron expression for job "${n}"`),o.add(n),e.push({args:u??{},cron:a,name:n,workflow:typeof i.name=="string"?i.name:""});return}if(!i||typeof i.__lunoraRef!="string")throw new s("INTERNAL",`@lunora/scheduler: cron job "${n}" requires a function reference (e.g. internal.email.digest) or a workflow reference`);h(a,`cron expression for job "${n}"`),o.add(n),e.push({args:u??{},cron:a,functionPath:i.__lunoraRef,name:n})},t={cron(n,a,i,u){return r(n,a,i,u),t},daily(n,a,i,u){return r(n,d("daily",a),i,u),t},hourly(n,a,i,u){return r(n,d("hourly",a),i,u),t},interval(n,a,i,u){return r(n,d("interval",a),i,u),t},jobs:()=>[...e],monthly(n,a,i,u){return r(n,d("monthly",a),i,u),t},weekly(n,a,i,u){return r(n,d("weekly",a),i,u),t}};return t};export{k as CRON_SCHEDULE_KINDS,d as compileCronSchedule,b as cronJobs};
@@ -1 +0,0 @@
1
- const y=(u,t=200,e)=>Response.json(u,{headers:{"content-type":"application/json",...e},status:t});const m="retry:",g="dead:",p="pool:";const w=u=>String(u).padStart(15,"0"),I=()=>{const u=crypto.getRandomValues(new Uint8Array(12));let t="";for(const e of u)t+=String.fromCodePoint(e);return btoa(t).replaceAll("+","-").replaceAll("/","_").replaceAll("=","")};class i{static indexKey(t,e){return`t:${w(t)}:${e}`}static json(t,e=200){return y(t,e)}static error(t,e,a){return i.json({error:{code:e,message:a}},t)}static resolveRetry(t){const e=t.retry,a=typeof e?.maxAttempts=="number"&&Number.isInteger(e.maxAttempts)&&e.maxAttempts>0?e.maxAttempts:5,r=typeof e?.baseMs=="number"&&Number.isFinite(e.baseMs)&&e.baseMs>=0?e.baseMs:3e4,s=e?.backoff==="linear"?"linear":"exponential",n=typeof e?.maxMs=="number"&&Number.isFinite(e.maxMs)&&e.maxMs>=0?e.maxMs:void 0;return{backoff:s,baseMs:r,maxAttempts:a,maxMs:n}}static normalizeConcurrency(t,e){return typeof t=="number"&&Number.isInteger(t)&&t>0?t:e}static normalizeRetry(t){if(typeof t!="object"||t===null)return;const e=t,a={};return typeof e.maxAttempts=="number"&&Number.isInteger(e.maxAttempts)&&e.maxAttempts>0&&(a.maxAttempts=e.maxAttempts),typeof e.baseMs=="number"&&Number.isFinite(e.baseMs)&&e.baseMs>=0&&(a.baseMs=e.baseMs),(e.backoff==="exponential"||e.backoff==="linear")&&(a.backoff=e.backoff),typeof e.maxMs=="number"&&Number.isFinite(e.maxMs)&&e.maxMs>=0&&(a.maxMs=e.maxMs),Object.keys(a).length===0?void 0:a}static releaseSlot(t,e){if(t.inFlightIds===void 0)return{...t,inFlight:Math.max(0,t.inFlight-1)};const a=t.inFlightIds.filter(r=>r!==e);return{...t,inFlight:a.length,inFlightIds:a}}static releaseFirstSlot(t){if(t.inFlightIds===void 0)return{...t,inFlight:Math.max(0,t.inFlight-1)};const e=t.inFlightIds.slice(0,Math.max(0,t.inFlightIds.length-1));return{...t,inFlight:e.length,inFlightIds:e}}static resolveScheduleTarget(t){const e=typeof t?.functionPath=="string"&&t.functionPath.length>0?t.functionPath:void 0,a=typeof t?.workflow=="string"&&t.workflow.length>0?t.workflow:void 0;if(!(e===void 0&&a===void 0))return{functionPath:e,workflow:a}}state;env;constructor(t,e){this.state=t,this.env=e}async fetch(t){const e=new URL(t.url);if(e.pathname==="/ws"&&t.headers.get("Upgrade")==="websocket")return this.handleWebSocketUpgrade();switch(`${t.method} ${e.pathname}`){case"GET /dead":return this.handleDeadList();case"GET /get":return this.handleGet(e);case"GET /list":return this.handleList();case"GET /pool":return this.handlePoolStatus(e);case"GET /status":return this.handleStatus();case"POST /cancel":return this.handleCancel(t);case"POST /complete":return this.handleComplete(t);case"POST /dead/cancel":return this.handleDeadCancel(t);case"POST /dead/retry":return this.handleDeadRetry(t);case"POST /schedule":return this.handleSchedule(t)}return y({error:{code:"NOT_FOUND"}},404)}async alarm(){const t=Date.now(),e=[],a=await this.state.storage.list({end:`t:${w(t)}:~`,limit:100,prefix:"t:"});for(const[r,s]of a.entries()){const n=Number.parseInt(r.slice(2,r.indexOf(":",2)),10);if(Number.isFinite(n)&&n<=t){const o=await this.state.storage.get(`id:${s}`);o?e.push(o):await this.state.storage.delete(r)}}try{for(const r of e)await this.drainRecordGuarded(r)}finally{await this.rescheduleAlarm()}e.length>0&&await this.broadcastChange()}async dispatch(t){const e=typeof this.env.LUNORA_ORIGIN_URL=="string"&&this.env.LUNORA_ORIGIN_URL.length>0?this.env.LUNORA_ORIGIN_URL:void 0;if(!e)return!1;const a=JSON.stringify({args:t.args,functionPath:t.functionPath,id:t.id,instanceName:t.instanceName,pool:t.pool,scheduledFor:t.scheduledFor,shardKey:t.shardKey,workflow:t.workflow});try{const r={"content-type":"application/json"},s=await this.signDispatch(a);return s!==void 0?r["x-lunora-scheduler-signature"]=s:typeof this.env.LUNORA_ADMIN_TOKEN=="string"&&this.env.LUNORA_ADMIN_TOKEN.length>0&&(r.authorization=`Bearer ${this.env.LUNORA_ADMIN_TOKEN}`),(await fetch(`${e}/_lunora/scheduler/dispatch`,{body:a,headers:r,method:"POST"})).ok}catch{return!1}}async drainRecordGuarded(t){try{await this.state.storage.delete(i.indexKey(t.scheduledFor,t.id)),await this.drainRecord(t)}catch{try{await this.state.storage.put(i.indexKey(t.scheduledFor,t.id),t.id)}catch{}}}async drainRecord(t){if(!await this.reservePoolSlot(t))return!1;const e=await this.dispatch(t);if(!e&&t.pool!==void 0){const a=await this.loadPool(t.pool),r=i.releaseSlot(a,t.id);await this.savePool(t.pool,r)}if(e){try{await this.state.storage.delete([`id:${t.id}`,`${m}${t.id}`])}catch{}return!0}return await this.recordRetry(t),!1}async reservePoolSlot(t){if(t.pool===void 0)return!0;const e=await this.loadPool(t.pool);if(e.inFlight>=e.maxConcurrency)return await this.requeuePooled(t),!1;const a=e.inFlightIds??[];return a.includes(t.id)||a.push(t.id),e.inFlightIds=a,e.inFlight=a.length,await this.savePool(t.pool,e),!0}async handleWebSocketUpgrade(){if(this.state.acceptWebSocket===void 0)return i.error(501,"WS_UNSUPPORTED","WebSocket subscriptions are not supported in this runtime");const t=new WebSocketPair,e=t[0],a=t[1];return this.state.acceptWebSocket(a),a.send(JSON.stringify({records:await this.listRecords(),type:"jobs"})),new Response(null,{status:101,webSocket:e})}async broadcastChange(){const t=this.state.getWebSockets?.();if(t===void 0||t.length===0)return;const e=JSON.stringify({records:await this.listRecords(),type:"jobs"});for(const a of t)try{a.send(e)}catch{}}async listRecords(){return[...(await this.state.storage.list({prefix:"id:"})).values()]}async signDispatch(t){const e=typeof this.env.LUNORA_SCHEDULER_SECRET=="string"?this.env.LUNORA_SCHEDULER_SECRET:void 0;if(!e||e.length===0)return;const a=new TextEncoder,r=await crypto.subtle.importKey("raw",a.encode(e),{hash:"SHA-256",name:"HMAC"},!1,["sign"]),s=await crypto.subtle.sign("HMAC",r,a.encode(t)),n=new Uint8Array(s);let o="";for(const l of n)o+=String.fromCodePoint(l);return btoa(o).replaceAll("+","-").replaceAll("/","_").replaceAll("=","")}async recordRetry(t){const e=(t.attempts??0)+1,{backoff:a,baseMs:r,maxAttempts:s,maxMs:n}=i.resolveRetry(t);if(e>s){await this.state.storage.put(`${g}${t.id}`,{...t,attempts:e}),await this.state.storage.delete([`${m}${t.id}`,`id:${t.id}`]),console.warn(`@lunora/scheduler: job "${t.id}" (${t.functionPath??t.workflow??"unknown"}) parked in dead-letter after ${String(e)} attempts`);return}const o=a==="linear"?r*e:r*2**(e-1),l=n===void 0?o:Math.min(o,n),c=Date.now()+l,d={...t,attempts:e,scheduledFor:c};await this.state.storage.put(`${m}${t.id}`,d),await this.state.storage.put(`id:${t.id}`,d),await this.state.storage.put(i.indexKey(c,t.id),t.id)}async loadPool(t,e){const a=await this.state.storage.get(`${p}${t}`);return a!==void 0?Array.isArray(a.inFlightIds)?{inFlight:a.inFlightIds.length,inFlightIds:[...a.inFlightIds],maxConcurrency:a.maxConcurrency}:{inFlight:Math.max(0,a.inFlight),maxConcurrency:a.maxConcurrency}:{inFlight:0,inFlightIds:[],maxConcurrency:i.normalizeConcurrency(e,1)}}async savePool(t,e){await this.state.storage.put(`${p}${t}`,e)}async requeuePooled(t){const e=Date.now()+1e3,a={...t,scheduledFor:e};await this.state.storage.put(`id:${t.id}`,a),await this.state.storage.put(i.indexKey(e,t.id),t.id)}async handleComplete(t){const e=await t.json().catch(()=>{}),a=typeof e?.pool=="string"&&e.pool.length>0?e.pool:void 0,r=typeof e?.id=="string"&&e.id.length>0?e.id:void 0;if(a===void 0)return i.error(400,"INVALID_INPUT","pool is required");const s=await this.loadPool(a),n=r===void 0?i.releaseFirstSlot(s):i.releaseSlot(s,r);return await this.savePool(a,n),await this.armAlarmIfEarlier(Date.now()),i.json({inFlight:n.inFlight})}async handlePoolStatus(t){const e=t.searchParams.get("name");if(e===null||e.length===0)return i.error(400,"INVALID_INPUT","name is required");const a=await this.loadPool(e),r=await this.state.storage.list({prefix:"id:"});let s=0;for(const n of r.values())n.pool===e&&(s+=1);return i.json({inFlight:a.inFlight,maxConcurrency:a.maxConcurrency,queued:s})}async handleStatus(){const t=await this.state.storage.list({prefix:p}),e=await this.state.storage.list({prefix:"id:"}),a=new Map;for(const l of e.values())l.pool!==void 0&&a.set(l.pool,(a.get(l.pool)??0)+1);const r=[];let s=0,n=0;for(const[l,c]of t.entries()){const d=l.slice(p.length),h=Math.max(0,c.inFlight),f=a.get(d)??0;r.push({inFlight:h,maxConcurrency:c.maxConcurrency,name:d,queued:f}),s+=f,n+=h}const o={backlog:s,inFlight:n,pools:r};return i.json(o)}async handleSchedule(t){const e=await t.json().catch(()=>{}),a=i.resolveScheduleTarget(e);if(!e||a===void 0)return i.error(400,"INVALID_INPUT","functionPath or workflow is required");const{functionPath:r,workflow:s}=a;if(typeof e.scheduledFor!="number"||!Number.isInteger(e.scheduledFor)||e.scheduledFor<=0||e.scheduledFor>999999999999999)return i.error(400,"INVALID_INPUT","scheduledFor must be a positive integer epoch-millisecond number no greater than 999999999999999");if(typeof this.env.LUNORA_ORIGIN_URL!="string"||this.env.LUNORA_ORIGIN_URL.length===0)return i.error(500,"ORIGIN_NOT_CONFIGURED","LUNORA_ORIGIN_URL env binding must be set on the SchedulerDO");const n=typeof e.pool=="string"&&e.pool.length>0?e.pool:void 0,o=typeof e.instanceName=="string"&&e.instanceName.length>0?e.instanceName:void 0,l=i.normalizeRetry(e.retry),c=I(),d={args:e.args??{},enqueuedAt:Date.now(),id:c,...r===void 0?{}:{functionPath:r},...o===void 0?{}:{instanceName:o},...n===void 0?{}:{pool:n},...l===void 0?{}:{retry:l},scheduledFor:e.scheduledFor,shardKey:e.shardKey,...s===void 0?{}:{workflow:s}};if(n!==void 0){const h=await this.loadPool(n,e.maxConcurrency);await this.savePool(n,{inFlight:h.inFlight,...h.inFlightIds===void 0?{}:{inFlightIds:h.inFlightIds},maxConcurrency:i.normalizeConcurrency(e.maxConcurrency,h.maxConcurrency)})}return await this.state.storage.put(`id:${c}`,d),await this.state.storage.put(i.indexKey(d.scheduledFor,c),c),await this.armAlarmIfEarlier(d.scheduledFor),await this.broadcastChange(),i.json({id:c,scheduledFor:d.scheduledFor})}async handleCancel(t){const e=await t.json().catch(()=>{});if(!e?.id)return i.error(400,"INVALID_INPUT","id is required");const a=await this.state.storage.get(`id:${e.id}`);return a?(await this.removeRecord(a),await this.rescheduleAlarm(),await this.broadcastChange(),i.json({cancelled:!0})):i.json({cancelled:!1})}async handleList(){return i.json({records:await this.listRecords()})}async handleDeadList(){const t=await this.state.storage.list({prefix:g});return i.json({records:[...t.values()]})}async handleDeadRetry(t){const e=await t.json().catch(()=>{});if(typeof e?.id!="string"||e.id.length===0)return i.error(400,"INVALID_INPUT","id is required");const a=await this.state.storage.get(`${g}${e.id}`);if(a===void 0)return i.json({retried:!1});const r=Date.now(),s={...a,attempts:0,scheduledFor:r};return await this.state.storage.put(`id:${a.id}`,s),await this.state.storage.put(i.indexKey(r,a.id),a.id),await this.state.storage.delete(`${g}${a.id}`),await this.armAlarmIfEarlier(r),await this.broadcastChange(),i.json({id:a.id,retried:!0,scheduledFor:r})}async handleDeadCancel(t){const e=await t.json().catch(()=>{});if(typeof e?.id!="string"||e.id.length===0)return i.error(400,"INVALID_INPUT","id is required");const a=await this.state.storage.delete(`${g}${e.id}`);return i.json({removed:!!a})}async handleGet(t){const e=t.searchParams.get("id");if(e===null||e.length===0)return i.error(400,"INVALID_INPUT","id is required");const a=await this.state.storage.get(`id:${e}`);return i.json(a===void 0?{}:{record:a})}async removeRecord(t){await this.state.storage.delete([`id:${t.id}`,i.indexKey(t.scheduledFor,t.id),`${m}${t.id}`])}async armAlarmIfEarlier(t){const e=await this.state.storage.getAlarm();(e===null||t<e)&&await this.state.storage.setAlarm(t)}async rescheduleAlarm(){const t=(await this.state.storage.list({limit:1,prefix:"t:"})).entries().next();if(t.done){await this.state.storage.deleteAlarm();return}const[e]=t.value,a=Number.parseInt(e.slice(2,e.indexOf(":",2)),10);Number.isFinite(a)&&await this.state.storage.setAlarm(a)}}export{i as SchedulerDO};
@@ -1 +0,0 @@
1
- import{LunoraError as s}from"@lunora/errors";import{CronExpressionParser as o}from"cron-parser";const n=r=>{if(typeof r!="string"||r.trim()==="")return!1;try{return o.parse(r.trim()),!0}catch{return!1}},a=(r,e="cron expression")=>{if(!n(r))throw new s("INTERNAL",`@lunora/scheduler: invalid ${e} "${r}" — expected a standard 5- or 6-field cron expression (e.g. "0 * * * *")`)};export{a as assertValidCronExpression,n as isValidCronExpression};
@@ -1 +0,0 @@
1
- import{LunoraError as r}from"@lunora/errors";const u=e=>{let a=e.length;for(;a>0&&e[a-1]==="/";)a-=1;return e.slice(0,a)},h=e=>{if(!e.queue)throw new r("INTERNAL","@lunora/scheduler: `queue` (a Cloudflare Queue binding) is required");return{enqueue:async(a,t,n={})=>{const o={args:t,functionPath:a.__lunoraRef,shardKey:n.shardKey},s=n.delaySeconds===void 0?void 0:{delaySeconds:n.delaySeconds};await e.queue.send(o,s)},enqueueBatch:async(a,t)=>{const n=a.map(o=>({body:{args:o.args,functionPath:o.ref.__lunoraRef,shardKey:o.shardKey}}));await e.queue.sendBatch(n,t)}}},i=e=>typeof e=="object"&&e!==null&&typeof e.functionPath=="string",l=e=>async a=>{await Promise.all(a.messages.map(async t=>{try{if(!i(t.body))throw new r("INTERNAL","@lunora/scheduler: queue message body is not a QueueJob (missing functionPath)");await e.dispatch(t.body),t.ack()}catch{t.retry()}}))},d=e=>{const a=e.fetchImpl??globalThis.fetch;if(typeof a!="function")throw new TypeError("@lunora/scheduler: no fetch implementation available — pass fetchImpl or run on a platform with global fetch");const t=`${u(e.originUrl)}/_lunora/scheduler/dispatch`;return async n=>{const o=await a(t,{body:JSON.stringify({args:n.args??{},functionPath:n.functionPath,shardKey:n.shardKey}),headers:{authorization:`Bearer ${e.adminToken}`,"content-type":"application/json"},method:"POST"});if(!o.ok)throw new r("INTERNAL",`@lunora/scheduler: queue dispatch failed (${o.status.toString()}): ${await o.text()}`)}};export{l as createQueueConsumer,h as createQueueWorkpool,d as httpDispatcher};