@lunora/scheduler 1.0.0-alpha.12 → 1.0.0-alpha.13

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 @@
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
- export { isWorkflowReference } from './packem_shared/isWorkflowReference-C9mQkMXt.mjs';
8
- export { assertValidCronExpression, isValidCronExpression } from './packem_shared/assertValidCronExpression-B9m75qU0.mjs';
1
+ import{default as o}from"./packem_shared/createScheduler-B2YOy_ph.mjs";import{default as p}from"./packem_shared/createWorkpool-DyVdhB6o.mjs";import{createCronTrigger as s}from"./packem_shared/createCronTrigger-CyPdVKNF.mjs";import{CRON_SCHEDULE_KINDS as f,compileCronSchedule as l,cronJobs as m}from"./packem_shared/CRON_SCHEDULE_KINDS-CZFw97XW.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{isWorkflowReference as S}from"./packem_shared/isWorkflowReference-CT3tdefh.mjs";import{assertValidCronExpression as E,isValidCronExpression as k}from"./packem_shared/assertValidCronExpression-DHvO7Vm9.mjs";export{f as CRON_SCHEDULE_KINDS,C as SchedulerDO,E as assertValidCronExpression,l as compileCronSchedule,s as createCronTrigger,x as createQueueConsumer,i as createQueueWorkpool,o as createScheduler,p as createWorkpool,m as cronJobs,n as httpDispatcher,k as isValidCronExpression,S as isWorkflowReference};
@@ -0,0 +1 @@
1
+ import{LunoraError as u}from"@lunora/errors";import{isWorkflowReference as m}from"./isWorkflowReference-CT3tdefh.mjs";import{assertValidCronExpression as h}from"./assertValidCronExpression-DHvO7Vm9.mjs";const y={friday:5,monday:1,saturday:6,sunday:0,thursday:4,tuesday:2,wednesday:3},l=(e,r,n,t)=>{if(!Number.isInteger(e)||e<n||e>t)throw new u("INTERNAL",`@lunora/scheduler: cronJobs ${r} must be an integer in [${n.toFixed(0)}, ${t.toFixed(0)}], got ${String(e)}`);return e.toFixed(0)},c=(e,r,n)=>{const t=l(e,r,1,n-1);if(n%e!==0)throw new u("INTERNAL",`@lunora/scheduler: ${r} must evenly divide ${n.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 ${n.toFixed(0)}`);return t},f=e=>{const r=["seconds","minutes","hours"].filter(o=>e[o]!==void 0);if(r.length!==1)throw new u("INTERNAL","@lunora/scheduler: interval schedule must specify exactly one of { seconds, minutes, hours }");const n=r[0],t=e[n];return n==="seconds"?`*/${c(t,"interval.seconds",60)} * * * * *`:n==="minutes"?`*/${c(t,"interval.minutes",60)} * * * *`:`0 */${c(t,"interval.hours",24)} * * *`},w=e=>{const r=l(e.minuteUTC,"daily.minuteUTC",0,59),n=l(e.hourUTC,"daily.hourUTC",0,23);return`${r} ${n} * * *`},$=e=>{const r=y[e.dayOfWeek];if(r===void 0)throw new u("INTERNAL",`@lunora/scheduler: weekly schedule has invalid dayOfWeek "${e.dayOfWeek}"`);const n=l(e.minuteUTC,"weekly.minuteUTC",0,59),t=l(e.hourUTC,"weekly.hourUTC",0,23);return`${n} ${t} * * ${r.toFixed(0)}`},T=e=>{const r=l(e.day,"monthly.day",1,31),n=l(e.minuteUTC,"monthly.minuteUTC",0,59),t=l(e.hourUTC,"monthly.hourUTC",0,23);return`${n} ${t} ${r} * *`},C=new Set(["daily","interval","monthly","weekly"]),d=(e,r)=>{switch(e){case"daily":return w(r);case"interval":return f(r);case"monthly":return T(r);case"weekly":return $(r);default:throw new u("INTERNAL",`@lunora/scheduler: unknown cron schedule kind "${String(e)}"`)}},b=()=>{const e=[],r=new Set,n=(o,s,i,a)=>{if(typeof o!="string"||o.trim()==="")throw new u("INTERNAL","@lunora/scheduler: cron job name must be a non-empty string");if(r.has(o))throw new u("INTERNAL",`@lunora/scheduler: duplicate cron job name "${o}" — names must be unique within one cronJobs()`);if(m(i)){h(s,`cron expression for job "${o}"`),r.add(o),e.push({args:a??{},cron:s,name:o,workflow:typeof i.name=="string"?i.name:""});return}if(!i||typeof i.__lunoraRef!="string")throw new u("INTERNAL",`@lunora/scheduler: cron job "${o}" requires a function reference (e.g. internal.email.digest) or a workflow reference`);h(s,`cron expression for job "${o}"`),r.add(o),e.push({args:a??{},cron:s,functionPath:i.__lunoraRef,name:o})},t={cron(o,s,i,a){return n(o,s,i,a),t},daily(o,s,i,a){return n(o,d("daily",s),i,a),t},interval(o,s,i,a){return n(o,d("interval",s),i,a),t},jobs:()=>[...e],monthly(o,s,i,a){return n(o,d("monthly",s),i,a),t},weekly(o,s,i,a){return n(o,d("weekly",s),i,a),t}};return t};export{C as CRON_SCHEDULE_KINDS,d as compileCronSchedule,b as cronJobs};
@@ -0,0 +1 @@
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};
@@ -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}},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};
@@ -0,0 +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};
@@ -0,0 +1 @@
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};
@@ -0,0 +1 @@
1
+ import{LunoraError as i}from"@lunora/errors";import{l,u as s}from"./do-client-BFps7NIj.mjs";import{isWorkflowReference as g}from"./isWorkflowReference-CT3tdefh.mjs";const m=n=>{if(!n.namespace)throw new i("INTERNAL","@lunora/scheduler: `namespace` (SchedulerDO binding) is required");if(!n.originUrl)throw new i("INTERNAL","@lunora/scheduler: `originUrl` is required so the DO can dispatch back to the Worker");const a=async(e,r,o,t={})=>{const u=e instanceof Date?e.getTime():e,c={args:o,originUrl:n.originUrl,pool:t.pool,retry:t.retry,scheduledFor:u,shardKey:t.shardKey};if(g(r)){if(typeof r.binding!="string"||r.binding.length===0)throw new i("INTERNAL","@lunora/scheduler: workflow/agent schedule target is missing its `binding` — pass the generated `workflows.<name>` / `agents.<name>` reference");return s(n,"/schedule",{...c,workflow:r.binding})}const d=typeof r=="string"?r:r.__lunoraRef;return s(n,"/schedule",{...c,functionPath:d})};return{cancel:async e=>s(n,"/cancel",{id:e}),get:async e=>(await l(n,`/get?id=${encodeURIComponent(e)}`)).record??null,list:async()=>{const e=await l(n,"/list");return Array.isArray(e.records)?e.records:[]},runAfter:async(e,r,o,t={})=>{if(!Number.isFinite(e)||e<0)throw new i("INTERNAL","@lunora/scheduler: `delayMs` must be a non-negative finite number");return a(Date.now()+e,r,o,t)},runAt:a}};export{m as default};
@@ -0,0 +1 @@
1
+ import{LunoraError as n}from"@lunora/errors";import{l as c,u as i}from"./do-client-BFps7NIj.mjs";const m=e=>{if(!e.namespace)throw new n("INTERNAL","@lunora/scheduler: `namespace` (SchedulerDO binding) is required");if(!e.originUrl)throw new n("INTERNAL","@lunora/scheduler: `originUrl` is required so the DO can dispatch back to the Worker");if(!Number.isInteger(e.maxConcurrency)||e.maxConcurrency<=0)throw new n("INTERNAL","@lunora/scheduler: `maxConcurrency` must be a positive integer");const r=typeof e.name=="string"&&e.name.length>0?e.name:"default";return{cancel:async a=>i(e,"/cancel",{id:a}),enqueue:async(a,u,o={})=>{const t=o.delayMs??0;if(!Number.isFinite(t)||t<0)throw new n("INTERNAL","@lunora/scheduler: `delayMs` must be a non-negative finite number");return i(e,"/schedule",{args:u,functionPath:a.__lunoraRef,instanceName:e.instanceName??"default",maxConcurrency:e.maxConcurrency,originUrl:e.originUrl,pool:r,retry:o.retry,scheduledFor:Date.now()+t,shardKey:o.shardKey})},name:r,status:async()=>c(e,`/pool?name=${encodeURIComponent(r)}`)}};export{m as default};
@@ -0,0 +1 @@
1
+ import{LunoraError as o}from"@lunora/errors";const s=(e,t)=>{if(t===void 0)return e;if(typeof e.jurisdiction!="function")throw new TypeError(`@lunora/scheduler: Durable Object namespace does not support jurisdiction("${t}") — update @cloudflare/workers-types or remove the jurisdiction option`);return e.jurisdiction(t)},i=e=>{const t=s(e.namespace,e.jurisdiction);return t.get(t.idFromName(e.instanceName??"default"))},u=async(e,t,r)=>{const n=await i(e).fetch(`https://scheduler.internal${t}`,{body:JSON.stringify(r),headers:{"content-type":"application/json"},method:"POST"});if(!n.ok){const a=await n.text();throw new o("INTERNAL",`@lunora/scheduler: SchedulerDO ${t} failed (${String(n.status)}): ${a}`)}return await n.json()},d=async(e,t)=>{const r=await i(e).fetch(`https://scheduler.internal${t}`,{method:"GET"});if(!r.ok){const n=await r.text();throw new o("INTERNAL",`@lunora/scheduler: SchedulerDO ${t} failed (${String(r.status)}): ${n}`)}return await r.json()};export{d as l,u};
@@ -0,0 +1 @@
1
+ const e=o=>typeof o=="object"&&o!==null&&o.isLunoraWorkflow===!0;export{e as isWorkflowReference};
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lunora/scheduler",
3
- "version": "1.0.0-alpha.12",
3
+ "version": "1.0.0-alpha.13",
4
4
  "description": "Scheduling for Lunora: runAfter / runAt and Cron Triggers via SchedulerDO",
5
5
  "keywords": [
6
6
  "cloudflare",
@@ -46,7 +46,7 @@
46
46
  "access": "public"
47
47
  },
48
48
  "dependencies": {
49
- "@lunora/errors": "1.0.0-alpha.8",
49
+ "@lunora/errors": "1.0.0-alpha.9",
50
50
  "cron-parser": "5.6.2"
51
51
  },
52
52
  "engines": {
@@ -1,140 +0,0 @@
1
- import { LunoraError } from '@lunora/errors';
2
- import { isWorkflowReference } from './isWorkflowReference-C9mQkMXt.mjs';
3
- import { assertValidCronExpression } from './assertValidCronExpression-B9m75qU0.mjs';
4
-
5
- const WEEKDAY_INDEX = {
6
- friday: 5,
7
- monday: 1,
8
- saturday: 6,
9
- sunday: 0,
10
- thursday: 4,
11
- tuesday: 2,
12
- wednesday: 3
13
- };
14
- const field = (value, label, min, max) => {
15
- if (!Number.isInteger(value) || value < min || value > max) {
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
- );
20
- }
21
- return value.toFixed(0);
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
- };
33
- const compileInterval = (schedule) => {
34
- const units = ["seconds", "minutes", "hours"].filter((unit2) => schedule[unit2] !== void 0);
35
- if (units.length !== 1) {
36
- throw new LunoraError("INTERNAL", `@lunora/scheduler: interval schedule must specify exactly one of { seconds, minutes, hours }`);
37
- }
38
- const unit = units[0];
39
- const value = schedule[unit];
40
- if (unit === "seconds") {
41
- return `*/${stepField(value, "interval.seconds", 60)} * * * * *`;
42
- }
43
- if (unit === "minutes") {
44
- return `*/${stepField(value, "interval.minutes", 60)} * * * *`;
45
- }
46
- return `0 */${stepField(value, "interval.hours", 24)} * * *`;
47
- };
48
- const compileDaily = (schedule) => {
49
- const minute = field(schedule.minuteUTC, "daily.minuteUTC", 0, 59);
50
- const hour = field(schedule.hourUTC, "daily.hourUTC", 0, 23);
51
- return `${minute} ${hour} * * *`;
52
- };
53
- const compileWeekly = (schedule) => {
54
- const index = WEEKDAY_INDEX[schedule.dayOfWeek];
55
- if (index === void 0) {
56
- throw new LunoraError("INTERNAL", `@lunora/scheduler: weekly schedule has invalid dayOfWeek "${schedule.dayOfWeek}"`);
57
- }
58
- const minute = field(schedule.minuteUTC, "weekly.minuteUTC", 0, 59);
59
- const hour = field(schedule.hourUTC, "weekly.hourUTC", 0, 23);
60
- return `${minute} ${hour} * * ${index.toFixed(0)}`;
61
- };
62
- const compileMonthly = (schedule) => {
63
- const day = field(schedule.day, "monthly.day", 1, 31);
64
- const minute = field(schedule.minuteUTC, "monthly.minuteUTC", 0, 59);
65
- const hour = field(schedule.hourUTC, "monthly.hourUTC", 0, 23);
66
- return `${minute} ${hour} ${day} * *`;
67
- };
68
- const CRON_SCHEDULE_KINDS = /* @__PURE__ */ new Set(["daily", "interval", "monthly", "weekly"]);
69
- const compileCronSchedule = (kind, schedule) => {
70
- switch (kind) {
71
- case "daily": {
72
- return compileDaily(schedule);
73
- }
74
- case "interval": {
75
- return compileInterval(schedule);
76
- }
77
- case "monthly": {
78
- return compileMonthly(schedule);
79
- }
80
- case "weekly": {
81
- return compileWeekly(schedule);
82
- }
83
- default: {
84
- throw new LunoraError("INTERNAL", `@lunora/scheduler: unknown cron schedule kind "${String(kind)}"`);
85
- }
86
- }
87
- };
88
- const cronJobs = () => {
89
- const jobs = [];
90
- const seen = /* @__PURE__ */ new Set();
91
- const register = (name, cron, target, args) => {
92
- if (typeof name !== "string" || name.trim() === "") {
93
- throw new LunoraError("INTERNAL", `@lunora/scheduler: cron job name must be a non-empty string`);
94
- }
95
- if (seen.has(name)) {
96
- throw new LunoraError("INTERNAL", `@lunora/scheduler: duplicate cron job name "${name}" — names must be unique within one cronJobs()`);
97
- }
98
- if (isWorkflowReference(target)) {
99
- assertValidCronExpression(cron, `cron expression for job "${name}"`);
100
- seen.add(name);
101
- jobs.push({ args: args ?? {}, cron, name, workflow: typeof target.name === "string" ? target.name : "" });
102
- return;
103
- }
104
- if (!target || typeof target.__lunoraRef !== "string") {
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
- );
109
- }
110
- assertValidCronExpression(cron, `cron expression for job "${name}"`);
111
- seen.add(name);
112
- jobs.push({ args: args ?? {}, cron, functionPath: target.__lunoraRef, name });
113
- };
114
- const builder = {
115
- cron(name, cronExpr, function_, args) {
116
- register(name, cronExpr, function_, args);
117
- return builder;
118
- },
119
- daily(name, schedule, function_, args) {
120
- register(name, compileCronSchedule("daily", schedule), function_, args);
121
- return builder;
122
- },
123
- interval(name, schedule, function_, args) {
124
- register(name, compileCronSchedule("interval", schedule), function_, args);
125
- return builder;
126
- },
127
- jobs: () => [...jobs],
128
- monthly(name, schedule, function_, args) {
129
- register(name, compileCronSchedule("monthly", schedule), function_, args);
130
- return builder;
131
- },
132
- weekly(name, schedule, function_, args) {
133
- register(name, compileCronSchedule("weekly", schedule), function_, args);
134
- return builder;
135
- }
136
- };
137
- return builder;
138
- };
139
-
140
- export { CRON_SCHEDULE_KINDS, compileCronSchedule, cronJobs };
@@ -1,705 +0,0 @@
1
- const jsonResponse = (body, status = 200, headers) => Response.json(body, { headers: { "content-type": "application/json", ...headers }, status });
2
-
3
- const HEADER_PREFIX = "id:";
4
- const RETRY_PREFIX = "retry:";
5
- const DEAD_PREFIX = "dead:";
6
- const POOL_PREFIX = "pool:";
7
- const MAX_RETRY_ATTEMPTS = 5;
8
- const RETRY_BASE_DELAY_MS = 3e4;
9
- const POOL_BACKPRESSURE_DELAY_MS = 1e3;
10
- const MAX_SCHEDULED_FOR_MS = 999999999999999;
11
- const TIME_PAD = 15;
12
- const padTime = (n) => String(n).padStart(TIME_PAD, "0");
13
- const generateId = () => {
14
- const bytes = crypto.getRandomValues(new Uint8Array(12));
15
- let binary = "";
16
- for (const byte of bytes) {
17
- binary += String.fromCodePoint(byte);
18
- }
19
- return btoa(binary).replaceAll("+", "-").replaceAll("/", "_").replaceAll("=", "");
20
- };
21
- class SchedulerDO {
22
- static indexKey(scheduledFor, id) {
23
- return `t:${padTime(scheduledFor)}:${id}`;
24
- }
25
- static json(body, status = 200) {
26
- return jsonResponse(body, status);
27
- }
28
- static error(status, code, message) {
29
- return SchedulerDO.json({ error: { code, message } }, status);
30
- }
31
- /**
32
- * Resolve the effective retry parameters for a record: its per-job
33
- * {@link RetryPolicy} merged over the DO's built-in defaults. Callers that
34
- * never set `record.retry` get today's behaviour verbatim
35
- * (`maxAttempts: 5`, exponential, `baseMs: 30_000`, no ceiling).
36
- */
37
- static resolveRetry(record) {
38
- const policy = record.retry;
39
- const maxAttempts = typeof policy?.maxAttempts === "number" && Number.isInteger(policy.maxAttempts) && policy.maxAttempts > 0 ? policy.maxAttempts : MAX_RETRY_ATTEMPTS;
40
- const baseMs = typeof policy?.baseMs === "number" && Number.isFinite(policy.baseMs) && policy.baseMs >= 0 ? policy.baseMs : RETRY_BASE_DELAY_MS;
41
- const backoff = policy?.backoff === "linear" ? "linear" : "exponential";
42
- const maxMs = typeof policy?.maxMs === "number" && Number.isFinite(policy.maxMs) && policy.maxMs >= 0 ? policy.maxMs : void 0;
43
- return { backoff, baseMs, maxAttempts, maxMs };
44
- }
45
- /** Clamp an untrusted `maxConcurrency` to a positive integer, else fall back. */
46
- static normalizeConcurrency(value, fallback) {
47
- return typeof value === "number" && Number.isInteger(value) && value > 0 ? value : fallback;
48
- }
49
- /**
50
- * Sanitize an untrusted retry policy from the wire into a `RetryPolicy` (or
51
- * `undefined` when nothing valid was provided). Keeps obviously-bad values
52
- * out of storage so {@link SchedulerDO.resolveRetry} never has to re-guard.
53
- * @returns The normalized policy, or `undefined` if no valid policy was found.
54
- */
55
- static normalizeRetry(value) {
56
- if (typeof value !== "object" || value === null) {
57
- return void 0;
58
- }
59
- const raw = value;
60
- const policy = {};
61
- if (typeof raw.maxAttempts === "number" && Number.isInteger(raw.maxAttempts) && raw.maxAttempts > 0) {
62
- policy.maxAttempts = raw.maxAttempts;
63
- }
64
- if (typeof raw.baseMs === "number" && Number.isFinite(raw.baseMs) && raw.baseMs >= 0) {
65
- policy.baseMs = raw.baseMs;
66
- }
67
- if (raw.backoff === "exponential" || raw.backoff === "linear") {
68
- policy.backoff = raw.backoff;
69
- }
70
- if (typeof raw.maxMs === "number" && Number.isFinite(raw.maxMs) && raw.maxMs >= 0) {
71
- policy.maxMs = raw.maxMs;
72
- }
73
- return Object.keys(policy).length === 0 ? void 0 : policy;
74
- }
75
- /**
76
- * Idempotently release the slot held by `jobId`, returning the updated
77
- * {@link PoolState} (pure — the caller persists it). A duplicate release for
78
- * an id that no longer holds a slot is a no-op, so an at-least-once
79
- * `/complete` (or a complete racing a failed-kick release) can never push
80
- * `inFlight` below the true number of running jobs and oversubscribe the
81
- * pool. Pools persisted before `inFlightIds` existed fall back to a clamped
82
- * counter decrement.
83
- */
84
- static releaseSlot(pool, jobId) {
85
- if (pool.inFlightIds === void 0) {
86
- return { ...pool, inFlight: Math.max(0, pool.inFlight - 1) };
87
- }
88
- const next = pool.inFlightIds.filter((id) => id !== jobId);
89
- return { ...pool, inFlight: next.length, inFlightIds: next };
90
- }
91
- /**
92
- * Best-effort release with no job id (legacy `/complete` payloads). Drops one
93
- * tracked id if the set exists, else clamps the counter. Less precise than
94
- * {@link SchedulerDO.releaseSlot} — a duplicate id-less complete CAN
95
- * over-release — but every current client sends the id, so this is the
96
- * compatibility shim, not the hot path.
97
- */
98
- static releaseFirstSlot(pool) {
99
- if (pool.inFlightIds === void 0) {
100
- return { ...pool, inFlight: Math.max(0, pool.inFlight - 1) };
101
- }
102
- const next = pool.inFlightIds.slice(0, Math.max(0, pool.inFlightIds.length - 1));
103
- return { ...pool, inFlight: next.length, inFlightIds: next };
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
- }
119
- state;
120
- env;
121
- constructor(state, env) {
122
- this.state = state;
123
- this.env = env;
124
- }
125
- async fetch(request) {
126
- const url = new URL(request.url);
127
- if (url.pathname === "/ws" && request.headers.get("Upgrade") === "websocket") {
128
- return this.handleWebSocketUpgrade();
129
- }
130
- switch (`${request.method} ${url.pathname}`) {
131
- case "GET /dead": {
132
- return this.handleDeadList();
133
- }
134
- case "GET /get": {
135
- return this.handleGet(url);
136
- }
137
- case "GET /list": {
138
- return this.handleList();
139
- }
140
- case "GET /pool": {
141
- return this.handlePoolStatus(url);
142
- }
143
- case "GET /status": {
144
- return this.handleStatus();
145
- }
146
- case "POST /cancel": {
147
- return this.handleCancel(request);
148
- }
149
- case "POST /complete": {
150
- return this.handleComplete(request);
151
- }
152
- case "POST /dead/cancel": {
153
- return this.handleDeadCancel(request);
154
- }
155
- case "POST /dead/retry": {
156
- return this.handleDeadRetry(request);
157
- }
158
- case "POST /schedule": {
159
- return this.handleSchedule(request);
160
- }
161
- }
162
- return jsonResponse({ error: { code: "NOT_FOUND" } }, 404);
163
- }
164
- /** Called by the Workers runtime when the alarm previously set by `_rescheduleAlarm()` fires. */
165
- async alarm() {
166
- const now = Date.now();
167
- const due = [];
168
- const indexEntries = await this.state.storage.list({
169
- end: `t:${padTime(now)}:~`,
170
- limit: 100,
171
- prefix: "t:"
172
- });
173
- for (const [indexKey, recordId] of indexEntries.entries()) {
174
- const dueAt = Number.parseInt(indexKey.slice(2, indexKey.indexOf(":", 2)), 10);
175
- if (Number.isFinite(dueAt) && dueAt <= now) {
176
- const record = await this.state.storage.get(`${HEADER_PREFIX}${recordId}`);
177
- if (record) {
178
- due.push(record);
179
- } else {
180
- await this.state.storage.delete(indexKey);
181
- }
182
- }
183
- }
184
- try {
185
- for (const record of due) {
186
- await this.drainRecordGuarded(record);
187
- }
188
- } finally {
189
- await this.rescheduleAlarm();
190
- }
191
- if (due.length > 0) {
192
- await this.broadcastChange();
193
- }
194
- }
195
- /**
196
- * Internal dispatch hook; overridden in unit tests to capture the outgoing
197
- * request. Returns `true` ONLY on an explicit 2xx response (`response.ok`).
198
- * Anything else — a network failure, a 5xx, OR a non-2xx such as 404
199
- * (receiver route not mounted) / 401 / 403 / 4xx — returns `false` and
200
- * enters the retry pipeline via {@link recordRetry}. Treating 4xx as
201
- * success used to permanently delete the job; since the receiver may simply
202
- * be missing (404) or transiently failing, we retry rather than silently
203
- * drop. After {@link MAX_RETRY_ATTEMPTS} the record is parked under a
204
- * `dead:` key for inspection — never silently deleted.
205
- *
206
- * The dispatch target is taken from `env.LUNORA_ORIGIN_URL` (NOT from the
207
- * stored record) to prevent SSRF via a forged `originUrl` on the schedule
208
- * request. If that env var is missing at fire time (a deploy/binding
209
- * regression — schedule time already enforced its presence) we return
210
- * `false` so the record is retried rather than silently dropped.
211
- */
212
- async dispatch(record) {
213
- const originUrl = typeof this.env.LUNORA_ORIGIN_URL === "string" && this.env.LUNORA_ORIGIN_URL.length > 0 ? this.env.LUNORA_ORIGIN_URL : void 0;
214
- if (!originUrl) {
215
- return false;
216
- }
217
- const body = JSON.stringify({
218
- args: record.args,
219
- functionPath: record.functionPath,
220
- id: record.id,
221
- // Echoed so the receiver can call back the SAME DO instance.
222
- instanceName: record.instanceName,
223
- // When the job belongs to a workpool, the receiver must report
224
- // completion back to the SchedulerDO (`POST /complete { pool, id }`)
225
- // so the pool's concurrency slot is released — see handleComplete().
226
- pool: record.pool,
227
- scheduledFor: record.scheduledFor,
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
234
- });
235
- try {
236
- const headers = { "content-type": "application/json" };
237
- const signature = await this.signDispatch(body);
238
- if (signature !== void 0) {
239
- headers["x-lunora-scheduler-signature"] = signature;
240
- } else if (typeof this.env.LUNORA_ADMIN_TOKEN === "string" && this.env.LUNORA_ADMIN_TOKEN.length > 0) {
241
- headers.authorization = `Bearer ${this.env.LUNORA_ADMIN_TOKEN}`;
242
- }
243
- const response = await fetch(`${originUrl}/_lunora/scheduler/dispatch`, {
244
- body,
245
- headers,
246
- method: "POST"
247
- });
248
- return response.ok;
249
- } catch {
250
- return false;
251
- }
252
- }
253
- /**
254
- * Claim + drain one due record with per-record fault isolation, so a storage
255
- * throw can never abort the whole alarm pass (which would skip the remaining
256
- * due records and the `rescheduleAlarm()` that re-arms the clock).
257
- *
258
- * Claims the job by deleting its time-index entry BEFORE dispatch (an alarm
259
- * re-fire then won't pick it up again), runs {@link drainRecord}, and on a
260
- * thrown storage op re-asserts the claim so the job stays re-fireable.
261
- *
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.
271
- */
272
- async drainRecordGuarded(record) {
273
- try {
274
- await this.state.storage.delete(SchedulerDO.indexKey(record.scheduledFor, record.id));
275
- await this.drainRecord(record);
276
- } catch {
277
- try {
278
- await this.state.storage.put(SchedulerDO.indexKey(record.scheduledFor, record.id), record.id);
279
- } catch {
280
- }
281
- }
282
- }
283
- /**
284
- * Process one due (already index-claimed) record within an alarm drain:
285
- * apply the workpool concurrency gate, dispatch, and settle the result.
286
- * A saturated pool re-arms the job (backpressure, no attempt charged); a
287
- * free slot is reserved durably before dispatch and released immediately if
288
- * the kick fails (success holds it until the runtime reports completion).
289
- * Success clears the `id:`/`retry:` rows; failure routes to
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.
298
- * @returns `true` only when the record was successfully dispatched (a 2xx
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).
302
- */
303
- async drainRecord(record) {
304
- const reserved = await this.reservePoolSlot(record);
305
- if (!reserved) {
306
- return false;
307
- }
308
- const ok = await this.dispatch(record);
309
- if (!ok && record.pool !== void 0) {
310
- const pool = await this.loadPool(record.pool);
311
- const released = SchedulerDO.releaseSlot(pool, record.id);
312
- await this.savePool(record.pool, released);
313
- }
314
- if (ok) {
315
- try {
316
- await this.state.storage.delete([`${HEADER_PREFIX}${record.id}`, `${RETRY_PREFIX}${record.id}`]);
317
- } catch {
318
- }
319
- return true;
320
- }
321
- await this.recordRetry(record);
322
- return false;
323
- }
324
- /**
325
- * Concurrency gate for a pooled record. Returns `false` (and re-arms the
326
- * job via {@link requeuePooled}) when the pool is at `maxConcurrency`;
327
- * otherwise reserves a slot durably and returns `true`. Non-pooled records
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).
337
- */
338
- async reservePoolSlot(record) {
339
- if (record.pool === void 0) {
340
- return true;
341
- }
342
- const pool = await this.loadPool(record.pool);
343
- if (pool.inFlight >= pool.maxConcurrency) {
344
- await this.requeuePooled(record);
345
- return false;
346
- }
347
- const ids = pool.inFlightIds ?? [];
348
- if (!ids.includes(record.id)) {
349
- ids.push(record.id);
350
- }
351
- pool.inFlightIds = ids;
352
- pool.inFlight = ids.length;
353
- await this.savePool(record.pool, pool);
354
- return true;
355
- }
356
- /**
357
- * Accept a hibernatable live subscription to the job list. The scheduler has
358
- * exactly one subscription shape (the whole list), so there's no per-socket
359
- * registry or dependency tracking — every accepted socket gets the full list
360
- * on connect and on every change. The worker is responsible for gating the
361
- * upgrade behind the admin token before it reaches here.
362
- */
363
- async handleWebSocketUpgrade() {
364
- if (this.state.acceptWebSocket === void 0) {
365
- return SchedulerDO.error(501, "WS_UNSUPPORTED", "WebSocket subscriptions are not supported in this runtime");
366
- }
367
- const pair = new WebSocketPair();
368
- const client = pair[0];
369
- const server = pair[1];
370
- this.state.acceptWebSocket(server);
371
- server.send(JSON.stringify({ records: await this.listRecords(), type: "jobs" }));
372
- return new Response(null, { status: 101, webSocket: client });
373
- }
374
- /**
375
- * Re-list the jobs and push them to every connected subscriber. Called after
376
- * any change (schedule / cancel / alarm-fire) so live studios reflect it
377
- * immediately. A no-op when the runtime doesn't support hibernated sockets.
378
- */
379
- async broadcastChange() {
380
- const sockets = this.state.getWebSockets?.();
381
- if (sockets === void 0 || sockets.length === 0) {
382
- return;
383
- }
384
- const message = JSON.stringify({ records: await this.listRecords(), type: "jobs" });
385
- for (const socket of sockets) {
386
- try {
387
- socket.send(message);
388
- } catch {
389
- }
390
- }
391
- }
392
- /** The current pending job records (shared by `/list` and the live channel). */
393
- async listRecords() {
394
- const entries = await this.state.storage.list({ prefix: HEADER_PREFIX });
395
- return [...entries.values()];
396
- }
397
- /**
398
- * HMAC-SHA-256 sign the dispatch body with `env.LUNORA_SCHEDULER_SECRET`,
399
- * returning a base64url signature, or `undefined` when no secret is
400
- * configured. Mirrors `@lunora/storage`'s signed-URL HMAC pattern (WebCrypto
401
- * `crypto.subtle`, available in workerd).
402
- */
403
- async signDispatch(body) {
404
- const secret = typeof this.env.LUNORA_SCHEDULER_SECRET === "string" ? this.env.LUNORA_SCHEDULER_SECRET : void 0;
405
- if (!secret || secret.length === 0) {
406
- return void 0;
407
- }
408
- const encoder = new TextEncoder();
409
- const key = await crypto.subtle.importKey("raw", encoder.encode(secret), { hash: "SHA-256", name: "HMAC" }, false, ["sign"]);
410
- const signature = await crypto.subtle.sign("HMAC", key, encoder.encode(body));
411
- const bytes = new Uint8Array(signature);
412
- let binary = "";
413
- for (const byte of bytes) {
414
- binary += String.fromCodePoint(byte);
415
- }
416
- return btoa(binary).replaceAll("+", "-").replaceAll("/", "_").replaceAll("=", "");
417
- }
418
- /**
419
- * Move a failed record into the retry pipeline with configurable backoff.
420
- * The retry budget/backoff comes from the record's {@link RetryPolicy}
421
- * (falling back to the DO defaults); on exhaustion the record is parked
422
- * under a `dead:` key for manual inspection.
423
- */
424
- async recordRetry(record) {
425
- const attempts = (record.attempts ?? 0) + 1;
426
- const { backoff, baseMs, maxAttempts, maxMs } = SchedulerDO.resolveRetry(record);
427
- if (attempts > maxAttempts) {
428
- await this.state.storage.put(`${DEAD_PREFIX}${record.id}`, { ...record, attempts });
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
- );
433
- return;
434
- }
435
- const rawDelay = backoff === "linear" ? baseMs * attempts : baseMs * 2 ** (attempts - 1);
436
- const delayMs = maxMs === void 0 ? rawDelay : Math.min(rawDelay, maxMs);
437
- const nextScheduledFor = Date.now() + delayMs;
438
- const retryRecord = {
439
- ...record,
440
- attempts,
441
- scheduledFor: nextScheduledFor
442
- };
443
- await this.state.storage.put(`${RETRY_PREFIX}${record.id}`, retryRecord);
444
- await this.state.storage.put(`${HEADER_PREFIX}${record.id}`, retryRecord);
445
- await this.state.storage.put(SchedulerDO.indexKey(nextScheduledFor, record.id), record.id);
446
- }
447
- /** Read the durable `pool:&lt;name>` row, defaulting to a fresh `inFlight: 0` pool. */
448
- async loadPool(name, maxConcurrencyHint) {
449
- const stored = await this.state.storage.get(`${POOL_PREFIX}${name}`);
450
- if (stored !== void 0) {
451
- if (Array.isArray(stored.inFlightIds)) {
452
- return { inFlight: stored.inFlightIds.length, inFlightIds: [...stored.inFlightIds], maxConcurrency: stored.maxConcurrency };
453
- }
454
- return { inFlight: Math.max(0, stored.inFlight), maxConcurrency: stored.maxConcurrency };
455
- }
456
- return { inFlight: 0, inFlightIds: [], maxConcurrency: SchedulerDO.normalizeConcurrency(maxConcurrencyHint, 1) };
457
- }
458
- async savePool(name, pool) {
459
- await this.state.storage.put(`${POOL_PREFIX}${name}`, pool);
460
- }
461
- /**
462
- * Re-arm a pooled job that couldn't run because its pool was at capacity.
463
- * No attempt is charged (this is backpressure, not a failure): the job is
464
- * pushed `POOL_BACKPRESSURE_DELAY_MS` into the future so a later alarm
465
- * drains it once a slot frees, keeping its `id:` header and retry policy.
466
- */
467
- async requeuePooled(record) {
468
- const nextScheduledFor = Date.now() + POOL_BACKPRESSURE_DELAY_MS;
469
- const requeued = { ...record, scheduledFor: nextScheduledFor };
470
- await this.state.storage.put(`${HEADER_PREFIX}${record.id}`, requeued);
471
- await this.state.storage.put(SchedulerDO.indexKey(nextScheduledFor, record.id), record.id);
472
- }
473
- /**
474
- * Release a pool slot when the runtime reports an action finished. This is
475
- * the durable-semaphore decrement: dispatch() only KICKS the action and
476
- * holds the slot; the runtime calls back here (`POST /complete { id }`) once
477
- * the action settles, freeing the slot for the next queued job. Idempotent
478
- * and safe if the job/pool is already gone.
479
- */
480
- async handleComplete(request) {
481
- const body = await request.json().catch(() => void 0);
482
- const poolName = typeof body?.pool === "string" && body.pool.length > 0 ? body.pool : void 0;
483
- const jobId = typeof body?.id === "string" && body.id.length > 0 ? body.id : void 0;
484
- if (poolName === void 0) {
485
- return SchedulerDO.error(400, "INVALID_INPUT", "pool is required");
486
- }
487
- const pool = await this.loadPool(poolName);
488
- const next = jobId === void 0 ? SchedulerDO.releaseFirstSlot(pool) : SchedulerDO.releaseSlot(pool, jobId);
489
- await this.savePool(poolName, next);
490
- await this.armAlarmIfEarlier(Date.now());
491
- return SchedulerDO.json({ inFlight: next.inFlight });
492
- }
493
- /** `GET /pool?name=` — inspect a pool's slot usage + queued count. */
494
- async handlePoolStatus(url) {
495
- const name = url.searchParams.get("name");
496
- if (name === null || name.length === 0) {
497
- return SchedulerDO.error(400, "INVALID_INPUT", "name is required");
498
- }
499
- const pool = await this.loadPool(name);
500
- const headers = await this.state.storage.list({ prefix: HEADER_PREFIX });
501
- let queued = 0;
502
- for (const record of headers.values()) {
503
- if (record.pool === name) {
504
- queued += 1;
505
- }
506
- }
507
- return SchedulerDO.json({ inFlight: pool.inFlight, maxConcurrency: pool.maxConcurrency, queued });
508
- }
509
- /**
510
- * `GET /status` — the app-level backlog signal that powers the studio's
511
- * SLO view. Enumerates every durable `pool:&lt;name>` row for its `inFlight`/
512
- * `maxConcurrency` semaphore, counts the pending (not-yet-dispatched) jobs
513
- * routed to each pool with the same single-pass scan {@link handlePoolStatus}
514
- * uses, and rolls those up into app-wide `backlog` (sum of `queued`) and
515
- * `inFlight` (sum of held slots) totals.
516
- *
517
- * Pools that have rows but no queued jobs still appear (with `queued: 0`) so
518
- * a saturated-but-idle pool stays visible; a pool that only ever existed as
519
- * queued jobs without a persisted row is unreachable here (the schedule path
520
- * always writes a `pool:&lt;name>` row before the job's header), so a single
521
- * scan over `pool:`/`id:` is sufficient.
522
- */
523
- async handleStatus() {
524
- const poolRows = await this.state.storage.list({ prefix: POOL_PREFIX });
525
- const headers = await this.state.storage.list({ prefix: HEADER_PREFIX });
526
- const queuedByPool = /* @__PURE__ */ new Map();
527
- for (const record of headers.values()) {
528
- if (record.pool !== void 0) {
529
- queuedByPool.set(record.pool, (queuedByPool.get(record.pool) ?? 0) + 1);
530
- }
531
- }
532
- const pools = [];
533
- let backlog = 0;
534
- let inFlight = 0;
535
- for (const [key, pool] of poolRows.entries()) {
536
- const name = key.slice(POOL_PREFIX.length);
537
- const slots = Math.max(0, pool.inFlight);
538
- const queued = queuedByPool.get(name) ?? 0;
539
- pools.push({ inFlight: slots, maxConcurrency: pool.maxConcurrency, name, queued });
540
- backlog += queued;
541
- inFlight += slots;
542
- }
543
- const status = { backlog, inFlight, pools };
544
- return SchedulerDO.json(status);
545
- }
546
- async handleSchedule(request) {
547
- const body = await request.json().catch(() => void 0);
548
- const target = SchedulerDO.resolveScheduleTarget(body);
549
- if (!body || target === void 0) {
550
- return SchedulerDO.error(400, "INVALID_INPUT", "functionPath or workflow is required");
551
- }
552
- const { functionPath, workflow } = target;
553
- if (typeof body.scheduledFor !== "number" || !Number.isInteger(body.scheduledFor) || body.scheduledFor <= 0 || body.scheduledFor > MAX_SCHEDULED_FOR_MS) {
554
- return SchedulerDO.error(400, "INVALID_INPUT", "scheduledFor must be a positive integer epoch-millisecond number no greater than 999999999999999");
555
- }
556
- if (typeof this.env.LUNORA_ORIGIN_URL !== "string" || this.env.LUNORA_ORIGIN_URL.length === 0) {
557
- return SchedulerDO.error(500, "ORIGIN_NOT_CONFIGURED", "LUNORA_ORIGIN_URL env binding must be set on the SchedulerDO");
558
- }
559
- const pool = typeof body.pool === "string" && body.pool.length > 0 ? body.pool : void 0;
560
- const instanceName = typeof body.instanceName === "string" && body.instanceName.length > 0 ? body.instanceName : void 0;
561
- const retry = SchedulerDO.normalizeRetry(body.retry);
562
- const id = generateId();
563
- const record = {
564
- // body is parsed from an untrusted request; args may be absent at runtime
565
- // despite the type, so the ?? fallback is a real guard.
566
- // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- parsed wire data can omit args
567
- args: body.args ?? {},
568
- enqueuedAt: Date.now(),
569
- id,
570
- ...functionPath === void 0 ? {} : { functionPath },
571
- ...instanceName === void 0 ? {} : { instanceName },
572
- ...pool === void 0 ? {} : { pool },
573
- ...retry === void 0 ? {} : { retry },
574
- scheduledFor: body.scheduledFor,
575
- shardKey: body.shardKey,
576
- ...workflow === void 0 ? {} : { workflow }
577
- };
578
- if (pool !== void 0) {
579
- const current = await this.loadPool(pool, body.maxConcurrency);
580
- await this.savePool(pool, {
581
- inFlight: current.inFlight,
582
- // Preserve the in-flight id set so refreshing the cap on a new
583
- // enqueue can't wipe the held-slot bookkeeping (which would let
584
- // a later /complete over-release).
585
- ...current.inFlightIds === void 0 ? {} : { inFlightIds: current.inFlightIds },
586
- maxConcurrency: SchedulerDO.normalizeConcurrency(body.maxConcurrency, current.maxConcurrency)
587
- });
588
- }
589
- await this.state.storage.put(`${HEADER_PREFIX}${id}`, record);
590
- await this.state.storage.put(SchedulerDO.indexKey(record.scheduledFor, id), id);
591
- await this.armAlarmIfEarlier(record.scheduledFor);
592
- await this.broadcastChange();
593
- return SchedulerDO.json({ id, scheduledFor: record.scheduledFor });
594
- }
595
- async handleCancel(request) {
596
- const body = await request.json().catch(() => void 0);
597
- if (!body?.id) {
598
- return SchedulerDO.error(400, "INVALID_INPUT", "id is required");
599
- }
600
- const record = await this.state.storage.get(`${HEADER_PREFIX}${body.id}`);
601
- if (!record) {
602
- return SchedulerDO.json({ cancelled: false });
603
- }
604
- await this.removeRecord(record);
605
- await this.rescheduleAlarm();
606
- await this.broadcastChange();
607
- return SchedulerDO.json({ cancelled: true });
608
- }
609
- async handleList() {
610
- return SchedulerDO.json({ records: await this.listRecords() });
611
- }
612
- /**
613
- * `GET /dead` — list the dead-letter records: jobs that exhausted their
614
- * retry budget ({@link recordRetry}) and were parked under `dead:&lt;id>`
615
- * instead of being silently dropped. These never appear in `/list` (their
616
- * `id:` header is deleted on park), so this is the ONLY way the studio can
617
- * surface — and recover — a permanently-failed job.
618
- */
619
- async handleDeadList() {
620
- const entries = await this.state.storage.list({ prefix: DEAD_PREFIX });
621
- return SchedulerDO.json({ records: [...entries.values()] });
622
- }
623
- /**
624
- * `POST /dead/retry { id }` — resurrect a dead-letter record: reset its
625
- * exhausted attempt count to 0 (a fresh retry budget), re-arm it for
626
- * immediate dispatch via the standard time index, and drop the `dead:` row.
627
- * The new `id:` header makes it visible to `/list` and the live `/ws`
628
- * subscription again. A miss is a no-op (`{ retried: false }`).
629
- */
630
- async handleDeadRetry(request) {
631
- const body = await request.json().catch(() => void 0);
632
- if (typeof body?.id !== "string" || body.id.length === 0) {
633
- return SchedulerDO.error(400, "INVALID_INPUT", "id is required");
634
- }
635
- const dead = await this.state.storage.get(`${DEAD_PREFIX}${body.id}`);
636
- if (dead === void 0) {
637
- return SchedulerDO.json({ retried: false });
638
- }
639
- const scheduledFor = Date.now();
640
- const revived = { ...dead, attempts: 0, scheduledFor };
641
- await this.state.storage.put(`${HEADER_PREFIX}${dead.id}`, revived);
642
- await this.state.storage.put(SchedulerDO.indexKey(scheduledFor, dead.id), dead.id);
643
- await this.state.storage.delete(`${DEAD_PREFIX}${dead.id}`);
644
- await this.armAlarmIfEarlier(scheduledFor);
645
- await this.broadcastChange();
646
- return SchedulerDO.json({ id: dead.id, retried: true, scheduledFor });
647
- }
648
- /**
649
- * `POST /dead/cancel { id }` — permanently drop a dead-letter record the
650
- * operator has decided not to recover. Returns `{ removed }` (false when
651
- * nothing matched). Idempotent: a repeated purge is a harmless no-op.
652
- */
653
- async handleDeadCancel(request) {
654
- const body = await request.json().catch(() => void 0);
655
- if (typeof body?.id !== "string" || body.id.length === 0) {
656
- return SchedulerDO.error(400, "INVALID_INPUT", "id is required");
657
- }
658
- const removed = await this.state.storage.delete(`${DEAD_PREFIX}${body.id}`);
659
- return SchedulerDO.json({ removed: Boolean(removed) });
660
- }
661
- /**
662
- * Resolve a single pending job by id via a direct `id:&lt;id>` storage read —
663
- * O(1), versus scanning the whole `/list` view. Responds `{ record }` on a
664
- * hit and `{}` on a miss (an absent `record` field — JSON has no `undefined`
665
- * — which the client reads back as `null`).
666
- */
667
- async handleGet(url) {
668
- const id = url.searchParams.get("id");
669
- if (id === null || id.length === 0) {
670
- return SchedulerDO.error(400, "INVALID_INPUT", "id is required");
671
- }
672
- const record = await this.state.storage.get(`${HEADER_PREFIX}${id}`);
673
- return SchedulerDO.json(record === void 0 ? {} : { record });
674
- }
675
- async removeRecord(record) {
676
- await this.state.storage.delete([`${HEADER_PREFIX}${record.id}`, SchedulerDO.indexKey(record.scheduledFor, record.id), `${RETRY_PREFIX}${record.id}`]);
677
- }
678
- /**
679
- * Arm the alarm for `scheduledFor` only if it is sooner than the currently
680
- * set alarm (or none is set). Used on the schedule path: inserting a job
681
- * can only ever pull the earliest-pending time *earlier*, never later, so a
682
- * full `t:` rescan is unnecessary unless the new job is the new earliest.
683
- */
684
- async armAlarmIfEarlier(scheduledFor) {
685
- const current = await this.state.storage.getAlarm();
686
- if (current === null || scheduledFor < current) {
687
- await this.state.storage.setAlarm(scheduledFor);
688
- }
689
- }
690
- async rescheduleAlarm() {
691
- const entries = await this.state.storage.list({ limit: 1, prefix: "t:" });
692
- const first = entries.entries().next();
693
- if (first.done) {
694
- await this.state.storage.deleteAlarm();
695
- return;
696
- }
697
- const [indexKey] = first.value;
698
- const dueAt = Number.parseInt(indexKey.slice(2, indexKey.indexOf(":", 2)), 10);
699
- if (Number.isFinite(dueAt)) {
700
- await this.state.storage.setAlarm(dueAt);
701
- }
702
- }
703
- }
704
-
705
- export { SchedulerDO };
@@ -1,24 +0,0 @@
1
- import { LunoraError } from '@lunora/errors';
2
- import { CronExpressionParser } from 'cron-parser';
3
-
4
- const isValidCronExpression = (schedule) => {
5
- if (typeof schedule !== "string" || schedule.trim() === "") {
6
- return false;
7
- }
8
- try {
9
- CronExpressionParser.parse(schedule.trim());
10
- return true;
11
- } catch {
12
- return false;
13
- }
14
- };
15
- const assertValidCronExpression = (schedule, context = "cron expression") => {
16
- if (!isValidCronExpression(schedule)) {
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
- );
21
- }
22
- };
23
-
24
- export { assertValidCronExpression, isValidCronExpression };
@@ -1,28 +0,0 @@
1
- import { LunoraError } from '@lunora/errors';
2
- import { assertValidCronExpression } from './assertValidCronExpression-B9m75qU0.mjs';
3
-
4
- const createCronTrigger = (options) => {
5
- if (!options.schedule || !options.fn) {
6
- throw new LunoraError("INTERNAL", "@lunora/scheduler: createCronTrigger() requires `schedule` and `fn`");
7
- }
8
- assertValidCronExpression(options.schedule);
9
- const snippet = JSON.stringify(
10
- {
11
- triggers: {
12
- crons: [options.schedule]
13
- }
14
- },
15
- void 0,
16
- 2
17
- );
18
- return {
19
- crons: [options.schedule],
20
- dispatcher: {
21
- args: options.args ?? {},
22
- functionPath: options.fn.__lunoraRef
23
- },
24
- wranglerJsonc: snippet
25
- };
26
- };
27
-
28
- export { createCronTrigger };
@@ -1,61 +0,0 @@
1
- import { LunoraError } from '@lunora/errors';
2
-
3
- const trimTrailingSlashes = (value) => {
4
- let end = value.length;
5
- while (end > 0 && value[end - 1] === "/") {
6
- end -= 1;
7
- }
8
- return value.slice(0, end);
9
- };
10
- const createQueueWorkpool = (options) => {
11
- if (!options.queue) {
12
- throw new LunoraError("INTERNAL", "@lunora/scheduler: `queue` (a Cloudflare Queue binding) is required");
13
- }
14
- const enqueue = async (function_, args, enqueueOptions = {}) => {
15
- const job = { args, functionPath: function_.__lunoraRef, shardKey: enqueueOptions.shardKey };
16
- const sendOptions = enqueueOptions.delaySeconds === void 0 ? void 0 : { delaySeconds: enqueueOptions.delaySeconds };
17
- await options.queue.send(job, sendOptions);
18
- };
19
- const enqueueBatch = async (jobs, sendOptions) => {
20
- const messages = jobs.map((job) => {
21
- return { body: { args: job.args, functionPath: job.ref.__lunoraRef, shardKey: job.shardKey } };
22
- });
23
- await options.queue.sendBatch(messages, sendOptions);
24
- };
25
- return { enqueue, enqueueBatch };
26
- };
27
- const isQueueJob = (value) => typeof value === "object" && value !== null && typeof value.functionPath === "string";
28
- const createQueueConsumer = (options) => async (batch) => {
29
- await Promise.all(
30
- batch.messages.map(async (message) => {
31
- try {
32
- if (!isQueueJob(message.body)) {
33
- throw new LunoraError("INTERNAL", "@lunora/scheduler: queue message body is not a QueueJob (missing functionPath)");
34
- }
35
- await options.dispatch(message.body);
36
- message.ack();
37
- } catch {
38
- message.retry();
39
- }
40
- })
41
- );
42
- };
43
- const httpDispatcher = (options) => {
44
- const fetchImpl = options.fetchImpl ?? globalThis.fetch;
45
- if (typeof fetchImpl !== "function") {
46
- throw new TypeError("@lunora/scheduler: no fetch implementation available — pass fetchImpl or run on a platform with global fetch");
47
- }
48
- const url = `${trimTrailingSlashes(options.originUrl)}/_lunora/scheduler/dispatch`;
49
- return async (job) => {
50
- const response = await fetchImpl(url, {
51
- body: JSON.stringify({ args: job.args ?? {}, functionPath: job.functionPath, shardKey: job.shardKey }),
52
- headers: { authorization: `Bearer ${options.adminToken}`, "content-type": "application/json" },
53
- method: "POST"
54
- });
55
- if (!response.ok) {
56
- throw new LunoraError("INTERNAL", `@lunora/scheduler: queue dispatch failed (${response.status.toString()}): ${await response.text()}`);
57
- }
58
- };
59
- };
60
-
61
- export { createQueueConsumer, createQueueWorkpool, httpDispatcher };
@@ -1,52 +0,0 @@
1
- import { LunoraError } from '@lunora/errors';
2
- import { c as callDO, g as getDO } from './do-client-CMJtLoHO.mjs';
3
- import { isWorkflowReference } from './isWorkflowReference-C9mQkMXt.mjs';
4
-
5
- const createScheduler = (options) => {
6
- if (!options.namespace) {
7
- throw new LunoraError("INTERNAL", "@lunora/scheduler: `namespace` (SchedulerDO binding) is required");
8
- }
9
- if (!options.originUrl) {
10
- throw new LunoraError("INTERNAL", "@lunora/scheduler: `originUrl` is required so the DO can dispatch back to the Worker");
11
- }
12
- const runAt = async (date, target, args, options_ = {}) => {
13
- const scheduledFor = date instanceof Date ? date.getTime() : date;
14
- const base = {
15
- args,
16
- originUrl: options.originUrl,
17
- pool: options_.pool,
18
- retry: options_.retry,
19
- scheduledFor,
20
- shardKey: options_.shardKey
21
- };
22
- if (isWorkflowReference(target)) {
23
- if (typeof target.binding !== "string" || target.binding.length === 0) {
24
- throw new LunoraError(
25
- "INTERNAL",
26
- "@lunora/scheduler: workflow/agent schedule target is missing its `binding` — pass the generated `workflows.<name>` / `agents.<name>` reference"
27
- );
28
- }
29
- return callDO(options, "/schedule", { ...base, workflow: target.binding });
30
- }
31
- const functionPath = typeof target === "string" ? target : target.__lunoraRef;
32
- return callDO(options, "/schedule", { ...base, functionPath });
33
- };
34
- const runAfter = async (delayMs, target, args, options_ = {}) => {
35
- if (!Number.isFinite(delayMs) || delayMs < 0) {
36
- throw new LunoraError("INTERNAL", "@lunora/scheduler: `delayMs` must be a non-negative finite number");
37
- }
38
- return runAt(Date.now() + delayMs, target, args, options_);
39
- };
40
- const cancel = async (id) => callDO(options, "/cancel", { id });
41
- const list = async () => {
42
- const body = await getDO(options, "/list");
43
- return Array.isArray(body.records) ? body.records : [];
44
- };
45
- const get = async (id) => {
46
- const body = await getDO(options, `/get?id=${encodeURIComponent(id)}`);
47
- return body.record ?? null;
48
- };
49
- return { cancel, get, list, runAfter, runAt };
50
- };
51
-
52
- export { createScheduler as default };
@@ -1,37 +0,0 @@
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 };
@@ -1,42 +0,0 @@
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 };
@@ -1,3 +0,0 @@
1
- const isWorkflowReference = (target) => typeof target === "object" && target !== null && target.isLunoraWorkflow === true;
2
-
3
- export { isWorkflowReference };