@lunora/scheduler 1.0.0-alpha.1 → 1.0.0-alpha.11

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.mjs CHANGED
@@ -1,8 +1,8 @@
1
- export { default as createScheduler } from './packem_shared/createScheduler-KCso4_at.mjs';
2
- export { default as createWorkpool } from './packem_shared/createWorkpool-D5cWPQmH.mjs';
3
- export { createCronTrigger } from './packem_shared/createCronTrigger-Cq9IBcWQ.mjs';
4
- export { CRON_SCHEDULE_KINDS, compileCronSchedule, cronJobs } from './packem_shared/compileCronSchedule-BaLlXJiN.mjs';
5
- export { createQueueConsumer, createQueueWorkpool, httpDispatcher } from './packem_shared/createQueueConsumer-DWahNPfz.mjs';
6
- export { SchedulerDO } from './packem_shared/SchedulerDO-BNzXNnS4.mjs';
1
+ export { default as createScheduler } from './packem_shared/createScheduler-Df9JUjQb.mjs';
2
+ export { default as createWorkpool } from './packem_shared/createWorkpool-Ce0kNM2p.mjs';
3
+ export { createCronTrigger } from './packem_shared/createCronTrigger-Dh4VLxD9.mjs';
4
+ export { CRON_SCHEDULE_KINDS, compileCronSchedule, cronJobs } from './packem_shared/CRON_SCHEDULE_KINDS-C2FflEnq.mjs';
5
+ export { createQueueConsumer, createQueueWorkpool, httpDispatcher } from './packem_shared/createQueueConsumer-Cy-Mp-El.mjs';
6
+ export { SchedulerDO } from './packem_shared/SchedulerDO-DeJHI379.mjs';
7
7
  export { isWorkflowReference } from './packem_shared/isWorkflowReference-C9mQkMXt.mjs';
8
- export { assertValidCronExpression, isValidCronExpression } from './packem_shared/assertValidCronExpression-BLfrDgmK.mjs';
8
+ export { assertValidCronExpression, isValidCronExpression } from './packem_shared/assertValidCronExpression-B9m75qU0.mjs';
@@ -1,5 +1,6 @@
1
+ import { LunoraError } from '@lunora/errors';
1
2
  import { isWorkflowReference } from './isWorkflowReference-C9mQkMXt.mjs';
2
- import { assertValidCronExpression } from './assertValidCronExpression-BLfrDgmK.mjs';
3
+ import { assertValidCronExpression } from './assertValidCronExpression-B9m75qU0.mjs';
3
4
 
4
5
  const WEEKDAY_INDEX = {
5
6
  friday: 5,
@@ -12,24 +13,37 @@ const WEEKDAY_INDEX = {
12
13
  };
13
14
  const field = (value, label, min, max) => {
14
15
  if (!Number.isInteger(value) || value < min || value > max) {
15
- throw new Error(`@lunora/scheduler: cronJobs ${label} must be an integer in [${min.toFixed(0)}, ${max.toFixed(0)}], got ${String(value)}`);
16
+ throw new LunoraError(
17
+ "INTERNAL",
18
+ `@lunora/scheduler: cronJobs ${label} must be an integer in [${min.toFixed(0)}, ${max.toFixed(0)}], got ${String(value)}`
19
+ );
16
20
  }
17
21
  return value.toFixed(0);
18
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
+ };
19
33
  const compileInterval = (schedule) => {
20
34
  const units = ["seconds", "minutes", "hours"].filter((unit2) => schedule[unit2] !== void 0);
21
35
  if (units.length !== 1) {
22
- throw new Error(`@lunora/scheduler: interval schedule must specify exactly one of { seconds, minutes, hours }`);
36
+ throw new LunoraError("INTERNAL", `@lunora/scheduler: interval schedule must specify exactly one of { seconds, minutes, hours }`);
23
37
  }
24
38
  const unit = units[0];
25
39
  const value = schedule[unit];
26
40
  if (unit === "seconds") {
27
- return `*/${field(value, "interval.seconds", 1, 59)} * * * * *`;
41
+ return `*/${stepField(value, "interval.seconds", 60)} * * * * *`;
28
42
  }
29
43
  if (unit === "minutes") {
30
- return `*/${field(value, "interval.minutes", 1, 59)} * * * *`;
44
+ return `*/${stepField(value, "interval.minutes", 60)} * * * *`;
31
45
  }
32
- return `0 */${field(value, "interval.hours", 1, 23)} * * *`;
46
+ return `0 */${stepField(value, "interval.hours", 24)} * * *`;
33
47
  };
34
48
  const compileDaily = (schedule) => {
35
49
  const minute = field(schedule.minuteUTC, "daily.minuteUTC", 0, 59);
@@ -39,7 +53,7 @@ const compileDaily = (schedule) => {
39
53
  const compileWeekly = (schedule) => {
40
54
  const index = WEEKDAY_INDEX[schedule.dayOfWeek];
41
55
  if (index === void 0) {
42
- throw new Error(`@lunora/scheduler: weekly schedule has invalid dayOfWeek "${schedule.dayOfWeek}"`);
56
+ throw new LunoraError("INTERNAL", `@lunora/scheduler: weekly schedule has invalid dayOfWeek "${schedule.dayOfWeek}"`);
43
57
  }
44
58
  const minute = field(schedule.minuteUTC, "weekly.minuteUTC", 0, 59);
45
59
  const hour = field(schedule.hourUTC, "weekly.hourUTC", 0, 23);
@@ -67,7 +81,7 @@ const compileCronSchedule = (kind, schedule) => {
67
81
  return compileWeekly(schedule);
68
82
  }
69
83
  default: {
70
- throw new Error(`@lunora/scheduler: unknown cron schedule kind "${String(kind)}"`);
84
+ throw new LunoraError("INTERNAL", `@lunora/scheduler: unknown cron schedule kind "${String(kind)}"`);
71
85
  }
72
86
  }
73
87
  };
@@ -76,10 +90,10 @@ const cronJobs = () => {
76
90
  const seen = /* @__PURE__ */ new Set();
77
91
  const register = (name, cron, target, args) => {
78
92
  if (typeof name !== "string" || name.trim() === "") {
79
- throw new Error(`@lunora/scheduler: cron job name must be a non-empty string`);
93
+ throw new LunoraError("INTERNAL", `@lunora/scheduler: cron job name must be a non-empty string`);
80
94
  }
81
95
  if (seen.has(name)) {
82
- throw new Error(`@lunora/scheduler: duplicate cron job name "${name}" — names must be unique within one cronJobs()`);
96
+ throw new LunoraError("INTERNAL", `@lunora/scheduler: duplicate cron job name "${name}" — names must be unique within one cronJobs()`);
83
97
  }
84
98
  if (isWorkflowReference(target)) {
85
99
  assertValidCronExpression(cron, `cron expression for job "${name}"`);
@@ -88,7 +102,10 @@ const cronJobs = () => {
88
102
  return;
89
103
  }
90
104
  if (!target || typeof target.__lunoraRef !== "string") {
91
- throw new Error(`@lunora/scheduler: cron job "${name}" requires a function reference (e.g. internal.email.digest) or a workflow reference`);
105
+ throw new LunoraError(
106
+ "INTERNAL",
107
+ `@lunora/scheduler: cron job "${name}" requires a function reference (e.g. internal.email.digest) or a workflow reference`
108
+ );
92
109
  }
93
110
  assertValidCronExpression(cron, `cron expression for job "${name}"`);
94
111
  seen.add(name);
@@ -1,3 +1,5 @@
1
+ const jsonResponse = (body, status = 200, headers) => Response.json(body, { headers: { "content-type": "application/json", ...headers }, status });
2
+
1
3
  const HEADER_PREFIX = "id:";
2
4
  const RETRY_PREFIX = "retry:";
3
5
  const DEAD_PREFIX = "dead:";
@@ -5,7 +7,7 @@ const POOL_PREFIX = "pool:";
5
7
  const MAX_RETRY_ATTEMPTS = 5;
6
8
  const RETRY_BASE_DELAY_MS = 3e4;
7
9
  const POOL_BACKPRESSURE_DELAY_MS = 1e3;
8
- const MAX_SCHEDULED_FOR_MS = 864e13;
10
+ const MAX_SCHEDULED_FOR_MS = 999999999999999;
9
11
  const TIME_PAD = 15;
10
12
  const padTime = (n) => String(n).padStart(TIME_PAD, "0");
11
13
  const generateId = () => {
@@ -21,7 +23,7 @@ class SchedulerDO {
21
23
  return `t:${padTime(scheduledFor)}:${id}`;
22
24
  }
23
25
  static json(body, status = 200) {
24
- return Response.json(body, { headers: { "content-type": "application/json" }, status });
26
+ return jsonResponse(body, status);
25
27
  }
26
28
  static error(status, code, message) {
27
29
  return SchedulerDO.json({ error: { code, message } }, status);
@@ -100,6 +102,20 @@ class SchedulerDO {
100
102
  const next = pool.inFlightIds.slice(0, Math.max(0, pool.inFlightIds.length - 1));
101
103
  return { ...pool, inFlight: next.length, inFlightIds: next };
102
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
+ }
103
119
  state;
104
120
  env;
105
121
  constructor(state, env) {
@@ -143,13 +159,7 @@ class SchedulerDO {
143
159
  return this.handleSchedule(request);
144
160
  }
145
161
  }
146
- return Response.json(
147
- { error: { code: "NOT_FOUND" } },
148
- {
149
- headers: { "content-type": "application/json" },
150
- status: 404
151
- }
152
- );
162
+ return jsonResponse({ error: { code: "NOT_FOUND" } }, 404);
153
163
  }
154
164
  /** Called by the Workers runtime when the alarm previously set by `_rescheduleAlarm()` fires. */
155
165
  async alarm() {
@@ -166,13 +176,14 @@ class SchedulerDO {
166
176
  const record = await this.state.storage.get(`${HEADER_PREFIX}${recordId}`);
167
177
  if (record) {
168
178
  due.push(record);
179
+ } else {
180
+ await this.state.storage.delete(indexKey);
169
181
  }
170
182
  }
171
183
  }
172
- const pools = /* @__PURE__ */ new Map();
173
184
  try {
174
185
  for (const record of due) {
175
- await this.drainRecordGuarded(record, pools);
186
+ await this.drainRecordGuarded(record);
176
187
  }
177
188
  } finally {
178
189
  await this.rescheduleAlarm();
@@ -214,7 +225,12 @@ class SchedulerDO {
214
225
  // so the pool's concurrency slot is released — see handleComplete().
215
226
  pool: record.pool,
216
227
  scheduledFor: record.scheduledFor,
217
- 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
218
234
  });
219
235
  try {
220
236
  const headers = { "content-type": "application/json" };
@@ -241,27 +257,26 @@ class SchedulerDO {
241
257
  *
242
258
  * Claims the job by deleting its time-index entry BEFORE dispatch (an alarm
243
259
  * re-fire then won't pick it up again), runs {@link drainRecord}, and on a
244
- * thrown storage op decides whether the job stays re-fireable.
260
+ * thrown storage op re-asserts the claim so the job stays re-fireable.
245
261
  *
246
- * When the record was NOT successfully dispatched, re-assert the time-index
247
- * claim so a later alarm re-attempts it (at-least-once): the claim delete may
248
- * have removed it and recordRetry()/requeuePooled() may not have re-armed it
249
- * before throwing, and re-inserting the same key is idempotent, so a
250
- * surviving claim is simply rewritten to its prior value. When the record WAS
251
- * dispatched (the throw came from post-dispatch cleanup), leave the index
252
- * deleted so the already-kicked, idempotent job is not re-fired.
262
+ * A throw reaching here always means the job was NOT dispatched:
263
+ * {@link drainRecord} swallows its own post-dispatch cleanup errors and
264
+ * returns instead of throwing once a kick succeeds, so every escaping throw
265
+ * comes from the pre-dispatch or failed-dispatch paths. We therefore always
266
+ * re-assert the time-index claim so a later alarm re-attempts it
267
+ * (at-least-once): the claim delete may have removed it and
268
+ * recordRetry()/requeuePooled() may not have re-armed it before throwing, and
269
+ * re-inserting the same key is idempotent, so a surviving claim is simply
270
+ * rewritten to its prior value.
253
271
  */
254
- async drainRecordGuarded(record, pools) {
255
- let dispatched = false;
272
+ async drainRecordGuarded(record) {
256
273
  try {
257
274
  await this.state.storage.delete(SchedulerDO.indexKey(record.scheduledFor, record.id));
258
- dispatched = await this.drainRecord(record, pools);
275
+ await this.drainRecord(record);
259
276
  } catch {
260
- if (!dispatched) {
261
- try {
262
- await this.state.storage.put(SchedulerDO.indexKey(record.scheduledFor, record.id), record.id);
263
- } catch {
264
- }
277
+ try {
278
+ await this.state.storage.put(SchedulerDO.indexKey(record.scheduledFor, record.id), record.id);
279
+ } catch {
265
280
  }
266
281
  }
267
282
  }
@@ -272,30 +287,29 @@ class SchedulerDO {
272
287
  * free slot is reserved durably before dispatch and released immediately if
273
288
  * the kick fails (success holds it until the runtime reports completion).
274
289
  * Success clears the `id:`/`retry:` rows; failure routes to
275
- * {@link recordRetry}. `pools` caches each pool's {@link PoolState} for the
276
- * lifetime of the drain so the budget decrements without re-reading storage.
290
+ * {@link recordRetry}. Pool state is read FRESH from storage per record (see
291
+ * {@link reservePoolSlot}) and never held across the dispatch() await, so a
292
+ * concurrent /complete landing mid-dispatch can't be clobbered.
293
+ * Once a kick succeeds, post-dispatch cleanup (clearing the `id:`/`retry:`
294
+ * rows) is swallowed rather than allowed to throw, so a successful dispatch
295
+ * NEVER propagates an error to {@link drainRecordGuarded}: every throw that
296
+ * escapes comes from the pre-dispatch or failed-dispatch paths, where the job
297
+ * is still re-fireable and the guard safely re-claims the time index.
277
298
  * @returns `true` only when the record was successfully dispatched (a 2xx
278
- * kick). The caller ({@link drainRecordGuarded}) uses this in its per-record
279
- * error guard: a record that returns `true` (or whose post-dispatch cleanup
280
- * later throws) must NOT have its time-index claim restored, since re-firing
281
- * an already-kicked job would break idempotency. A `false` return (pool
282
- * backpressure or a failed dispatch) means the job is still re-fireable —
283
- * either already re-armed here, or, if a throw escapes, re-claimed by the
284
- * 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).
285
302
  */
286
- async drainRecord(record, pools) {
287
- const reserved = await this.reservePoolSlot(record, pools);
303
+ async drainRecord(record) {
304
+ const reserved = await this.reservePoolSlot(record);
288
305
  if (!reserved) {
289
306
  return false;
290
307
  }
291
308
  const ok = await this.dispatch(record);
292
309
  if (!ok && record.pool !== void 0) {
293
- const pool = pools.get(record.pool);
294
- if (pool !== void 0) {
295
- const released = SchedulerDO.releaseSlot(pool, record.id);
296
- pools.set(record.pool, released);
297
- await this.savePool(record.pool, released);
298
- }
310
+ const pool = await this.loadPool(record.pool);
311
+ const released = SchedulerDO.releaseSlot(pool, record.id);
312
+ await this.savePool(record.pool, released);
299
313
  }
300
314
  if (ok) {
301
315
  try {
@@ -312,13 +326,20 @@ class SchedulerDO {
312
326
  * job via {@link requeuePooled}) when the pool is at `maxConcurrency`;
313
327
  * otherwise reserves a slot durably and returns `true`. Non-pooled records
314
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).
315
337
  */
316
- async reservePoolSlot(record, pools) {
338
+ async reservePoolSlot(record) {
317
339
  if (record.pool === void 0) {
318
340
  return true;
319
341
  }
320
- const pool = pools.get(record.pool) ?? await this.loadPool(record.pool);
321
- pools.set(record.pool, pool);
342
+ const pool = await this.loadPool(record.pool);
322
343
  if (pool.inFlight >= pool.maxConcurrency) {
323
344
  await this.requeuePooled(record);
324
345
  return false;
@@ -406,6 +427,9 @@ class SchedulerDO {
406
427
  if (attempts > maxAttempts) {
407
428
  await this.state.storage.put(`${DEAD_PREFIX}${record.id}`, { ...record, attempts });
408
429
  await this.state.storage.delete([`${RETRY_PREFIX}${record.id}`, `${HEADER_PREFIX}${record.id}`]);
430
+ console.warn(
431
+ `@lunora/scheduler: job "${record.id}" (${record.functionPath ?? record.workflow ?? "unknown"}) parked in dead-letter after ${String(attempts)} attempts`
432
+ );
409
433
  return;
410
434
  }
411
435
  const rawDelay = backoff === "linear" ? baseMs * attempts : baseMs * 2 ** (attempts - 1);
@@ -521,11 +545,13 @@ class SchedulerDO {
521
545
  }
522
546
  async handleSchedule(request) {
523
547
  const body = await request.json().catch(() => void 0);
524
- if (!body || typeof body.functionPath !== "string") {
525
- return SchedulerDO.error(400, "INVALID_INPUT", "functionPath is required");
548
+ const target = SchedulerDO.resolveScheduleTarget(body);
549
+ if (!body || target === void 0) {
550
+ return SchedulerDO.error(400, "INVALID_INPUT", "functionPath or workflow is required");
526
551
  }
552
+ const { functionPath, workflow } = target;
527
553
  if (typeof body.scheduledFor !== "number" || !Number.isInteger(body.scheduledFor) || body.scheduledFor <= 0 || body.scheduledFor > MAX_SCHEDULED_FOR_MS) {
528
- return SchedulerDO.error(400, "INVALID_INPUT", "scheduledFor must be a positive integer epoch-millisecond number no greater than 8640000000000000");
554
+ return SchedulerDO.error(400, "INVALID_INPUT", "scheduledFor must be a positive integer epoch-millisecond number no greater than 999999999999999");
529
555
  }
530
556
  if (typeof this.env.LUNORA_ORIGIN_URL !== "string" || this.env.LUNORA_ORIGIN_URL.length === 0) {
531
557
  return SchedulerDO.error(500, "ORIGIN_NOT_CONFIGURED", "LUNORA_ORIGIN_URL env binding must be set on the SchedulerDO");
@@ -540,13 +566,14 @@ class SchedulerDO {
540
566
  // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- parsed wire data can omit args
541
567
  args: body.args ?? {},
542
568
  enqueuedAt: Date.now(),
543
- functionPath: body.functionPath,
544
569
  id,
570
+ ...functionPath === void 0 ? {} : { functionPath },
545
571
  ...instanceName === void 0 ? {} : { instanceName },
546
572
  ...pool === void 0 ? {} : { pool },
547
573
  ...retry === void 0 ? {} : { retry },
548
574
  scheduledFor: body.scheduledFor,
549
- shardKey: body.shardKey
575
+ shardKey: body.shardKey,
576
+ ...workflow === void 0 ? {} : { workflow }
550
577
  };
551
578
  if (pool !== void 0) {
552
579
  const current = await this.loadPool(pool, body.maxConcurrency);
@@ -1,3 +1,4 @@
1
+ import { LunoraError } from '@lunora/errors';
1
2
  import { CronExpressionParser } from 'cron-parser';
2
3
 
3
4
  const isValidCronExpression = (schedule) => {
@@ -13,7 +14,10 @@ const isValidCronExpression = (schedule) => {
13
14
  };
14
15
  const assertValidCronExpression = (schedule, context = "cron expression") => {
15
16
  if (!isValidCronExpression(schedule)) {
16
- throw new Error(`@lunora/scheduler: invalid ${context} "${schedule}" — expected a standard 5- or 6-field cron expression (e.g. "0 * * * *")`);
17
+ throw new LunoraError(
18
+ "INTERNAL",
19
+ `@lunora/scheduler: invalid ${context} "${schedule}" — expected a standard 5- or 6-field cron expression (e.g. "0 * * * *")`
20
+ );
17
21
  }
18
22
  };
19
23
 
@@ -1,8 +1,9 @@
1
- import { assertValidCronExpression } from './assertValidCronExpression-BLfrDgmK.mjs';
1
+ import { LunoraError } from '@lunora/errors';
2
+ import { assertValidCronExpression } from './assertValidCronExpression-B9m75qU0.mjs';
2
3
 
3
4
  const createCronTrigger = (options) => {
4
5
  if (!options.schedule || !options.fn) {
5
- throw new Error("@lunora/scheduler: createCronTrigger() requires `schedule` and `fn`");
6
+ throw new LunoraError("INTERNAL", "@lunora/scheduler: createCronTrigger() requires `schedule` and `fn`");
6
7
  }
7
8
  assertValidCronExpression(options.schedule);
8
9
  const snippet = JSON.stringify(
@@ -1,3 +1,5 @@
1
+ import { LunoraError } from '@lunora/errors';
2
+
1
3
  const trimTrailingSlashes = (value) => {
2
4
  let end = value.length;
3
5
  while (end > 0 && value[end - 1] === "/") {
@@ -7,7 +9,7 @@ const trimTrailingSlashes = (value) => {
7
9
  };
8
10
  const createQueueWorkpool = (options) => {
9
11
  if (!options.queue) {
10
- throw new Error("@lunora/scheduler: `queue` (a Cloudflare Queue binding) is required");
12
+ throw new LunoraError("INTERNAL", "@lunora/scheduler: `queue` (a Cloudflare Queue binding) is required");
11
13
  }
12
14
  const enqueue = async (function_, args, enqueueOptions = {}) => {
13
15
  const job = { args, functionPath: function_.__lunoraRef, shardKey: enqueueOptions.shardKey };
@@ -28,7 +30,7 @@ const createQueueConsumer = (options) => async (batch) => {
28
30
  batch.messages.map(async (message) => {
29
31
  try {
30
32
  if (!isQueueJob(message.body)) {
31
- throw new Error("@lunora/scheduler: queue message body is not a QueueJob (missing functionPath)");
33
+ throw new LunoraError("INTERNAL", "@lunora/scheduler: queue message body is not a QueueJob (missing functionPath)");
32
34
  }
33
35
  await options.dispatch(message.body);
34
36
  message.ack();
@@ -51,7 +53,7 @@ const httpDispatcher = (options) => {
51
53
  method: "POST"
52
54
  });
53
55
  if (!response.ok) {
54
- throw new Error(`@lunora/scheduler: queue dispatch failed (${response.status.toString()}): ${await response.text()}`);
56
+ throw new LunoraError("INTERNAL", `@lunora/scheduler: queue dispatch failed (${response.status.toString()}): ${await response.text()}`);
55
57
  }
56
58
  };
57
59
  };
@@ -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 };
@@ -0,0 +1,37 @@
1
+ import { LunoraError } from '@lunora/errors';
2
+ import { g as getDO, c as callDO } from './do-client-CMJtLoHO.mjs';
3
+
4
+ const createWorkpool = (options) => {
5
+ if (!options.namespace) {
6
+ throw new LunoraError("INTERNAL", "@lunora/scheduler: `namespace` (SchedulerDO binding) is required");
7
+ }
8
+ if (!options.originUrl) {
9
+ throw new LunoraError("INTERNAL", "@lunora/scheduler: `originUrl` is required so the DO can dispatch back to the Worker");
10
+ }
11
+ if (!Number.isInteger(options.maxConcurrency) || options.maxConcurrency <= 0) {
12
+ throw new LunoraError("INTERNAL", "@lunora/scheduler: `maxConcurrency` must be a positive integer");
13
+ }
14
+ const name = typeof options.name === "string" && options.name.length > 0 ? options.name : "default";
15
+ const enqueue = async (function_, args, options_ = {}) => {
16
+ const delayMs = options_.delayMs ?? 0;
17
+ if (!Number.isFinite(delayMs) || delayMs < 0) {
18
+ throw new LunoraError("INTERNAL", "@lunora/scheduler: `delayMs` must be a non-negative finite number");
19
+ }
20
+ return callDO(options, "/schedule", {
21
+ args,
22
+ functionPath: function_.__lunoraRef,
23
+ instanceName: options.instanceName ?? "default",
24
+ maxConcurrency: options.maxConcurrency,
25
+ originUrl: options.originUrl,
26
+ pool: name,
27
+ retry: options_.retry,
28
+ scheduledFor: Date.now() + delayMs,
29
+ shardKey: options_.shardKey
30
+ });
31
+ };
32
+ const cancel = async (id) => callDO(options, "/cancel", { id });
33
+ const status = async () => getDO(options, `/pool?name=${encodeURIComponent(name)}`);
34
+ return { cancel, enqueue, name, status };
35
+ };
36
+
37
+ export { createWorkpool as default };
@@ -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.1",
3
+ "version": "1.0.0-alpha.11",
4
4
  "description": "Scheduling for Lunora: runAfter / runAt and Cron Triggers via SchedulerDO",
5
5
  "keywords": [
6
6
  "cloudflare",
@@ -25,7 +25,7 @@
25
25
  "directory": "packages/scheduler"
26
26
  },
27
27
  "files": [
28
- "dist",
28
+ "./dist",
29
29
  "__assets__",
30
30
  "README.md",
31
31
  "LICENSE.md"
@@ -46,7 +46,8 @@
46
46
  "access": "public"
47
47
  },
48
48
  "dependencies": {
49
- "cron-parser": "5.5.0"
49
+ "@lunora/errors": "1.0.0-alpha.6",
50
+ "cron-parser": "5.6.2"
50
51
  },
51
52
  "engines": {
52
53
  "node": "^22.15.0 || >=24.11.0"
@@ -1,63 +0,0 @@
1
- const callDO = async (options, path, body) => {
2
- const stub = options.namespace.get(options.namespace.idFromName(options.instanceName ?? "default"));
3
- const response = await stub.fetch(`https://scheduler.internal${path}`, {
4
- body: JSON.stringify(body),
5
- headers: { "content-type": "application/json" },
6
- method: "POST"
7
- });
8
- if (!response.ok) {
9
- const text = await response.text();
10
- throw new Error(`@lunora/scheduler: SchedulerDO ${path} failed (${String(response.status)}): ${text}`);
11
- }
12
- return await response.json();
13
- };
14
- const getDO = async (options, path) => {
15
- const stub = options.namespace.get(options.namespace.idFromName(options.instanceName ?? "default"));
16
- const response = await stub.fetch(`https://scheduler.internal${path}`, { method: "GET" });
17
- if (!response.ok) {
18
- const text = await response.text();
19
- throw new Error(`@lunora/scheduler: SchedulerDO ${path} failed (${String(response.status)}): ${text}`);
20
- }
21
- return await response.json();
22
- };
23
- const createScheduler = (options) => {
24
- if (!options.namespace) {
25
- throw new Error("@lunora/scheduler: `namespace` (SchedulerDO binding) is required");
26
- }
27
- if (!options.originUrl) {
28
- throw new Error("@lunora/scheduler: `originUrl` is required so the DO can dispatch back to the Worker");
29
- }
30
- const runAt = async (date, function_, args, options_ = {}) => {
31
- const scheduledFor = date instanceof Date ? date.getTime() : date;
32
- return callDO(options, "/schedule", {
33
- args,
34
- functionPath: function_.__lunoraRef,
35
- originUrl: options.originUrl,
36
- // Optional workpool / retry-policy passthrough. Absent for ordinary
37
- // `runAfter`/`runAt` calls, which keeps the wire payload (and the
38
- // DO's behaviour) identical to before this feature.
39
- pool: options_.pool,
40
- retry: options_.retry,
41
- scheduledFor,
42
- shardKey: options_.shardKey
43
- });
44
- };
45
- const runAfter = async (delayMs, function_, args, options_ = {}) => {
46
- if (!Number.isFinite(delayMs) || delayMs < 0) {
47
- throw new Error("@lunora/scheduler: `delayMs` must be a non-negative finite number");
48
- }
49
- return runAt(Date.now() + delayMs, function_, args, options_);
50
- };
51
- const cancel = async (id) => callDO(options, "/cancel", { id });
52
- const list = async () => {
53
- const body = await getDO(options, "/list");
54
- return Array.isArray(body.records) ? body.records : [];
55
- };
56
- const get = async (id) => {
57
- const body = await getDO(options, `/get?id=${encodeURIComponent(id)}`);
58
- return body.record ?? null;
59
- };
60
- return { cancel, get, list, runAfter, runAt };
61
- };
62
-
63
- export { createScheduler as default };