@stacksjs/scheduler 0.75.72 → 0.75.73

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.js CHANGED
@@ -1,3 +1,3 @@
1
1
  // @bun
2
- var g=import.meta.require;import{ok as D}from"@stacksjs/error-handling";import{log as j}from"@stacksjs/logging";import{path as k}from"@stacksjs/path";import{schedule as p}from"@stacksjs/scheduler";import{globSync as J}from"@stacksjs/storage";import{Every as m}from"@stacksjs/types";async function he(){let e=J([k.appPath("Jobs/*.ts")],{absolute:!0});await L();for(let t of e)try{let r=(await import(t)).default,s=N(r,t);if(!r.rate)continue;if(p.isScheduled(s)){j.debug(`[scheduler] ${s} is declared in app/Scheduler.ts; ignoring its \`rate\` so it is not scheduled twice`);continue}F(s,r.rate)}catch(n){console.error(n)}return D("Schedules ran successfully")}async function L(){let e=k.appPath("Scheduler.ts");try{let t=await import(e);if(typeof t.default==="function")t.default();else console.warn(`Scheduler file ${e} does not export a default function`)}catch(t){console.warn(`Could not load scheduler file ${e}:`,t)}}function F(e,t){switch(t){case m.Minute:p.job(e).everyMinute();break;case m.TwoMinutes:p.job(e).everyTwoMinutes();break;case m.FiveMinutes:p.job(e).everyFiveMinutes();break;case m.TenMinutes:p.job(e).everyTenMinutes();break;case m.ThirtyMinutes:p.job(e).everyThirtyMinutes();break;case m.HalfHour:p.job(e).everyThirtyMinutes();break;case m.Hour:p.job(e).everyHour();break;case m.Day:p.job(e).everyDay();break;case m.Week:p.job(e).weekly();break;case m.Month:p.job(e).monthly();break;case m.Year:p.job(e).yearly();break;default:throw Error(`Unsupported rate: ${t}`)}}function N(e,t){if(e.name)return e.name;return k.basename(t).replace(/\.ts$/,"")}import{spawn as W}from"child_process";import{mkdirSync as Y,readFileSync as Z,renameSync as Q,writeFileSync as V}from"fs";import{join as S}from"path";import{runNamedAction as ee}from"@stacksjs/action-runner";import{log as l,runCommand as te}from"@stacksjs/cli";import{parse as b}from"@stacksjs/cron";import{runJob as ne}from"@stacksjs/queue";import{createHash as E}from"crypto";import{existsSync as I,mkdirSync as z,readFileSync as C,statSync as _,unlinkSync as B,writeFileSync as P}from"fs";import{join as x}from"path";import M from"process";var v="stacks_scheduler";function H(e){let t=Date.now()-e.acquiredAt;if(t<0)return 0;return t}async function A(e,t,n,r,s){let i=s??x(M.cwd(),"storage","framework","locks"),o=x(i,`${e}.lock`);if(!I(i))z(i,{recursive:!0});let d=!1,u=null;if(r&&(n==="postgres"||n==="mysql")){if(!await U(e,n,r))return null;u=async()=>{if(d)return;d=!0,await K(e,n,r).catch(()=>{})}}let y={acquiredAt:Date.now(),monotonicStart:Math.round(performance.now()),holder:M.pid};try{P(o,JSON.stringify(y),{flag:"wx"})}catch{let c=!1;try{let h=C(o,"utf8"),f=JSON.parse(h);if(H(f)>=t)c=!0}catch{try{let h=_(o);if(Math.max(0,Date.now()-h.mtimeMs)>=t)c=!0}catch{c=!0}}if(!c){if(u)await u();return null}try{P(o,JSON.stringify(y))}catch{if(u)await u();return null}}return{release:async()=>{try{B(o)}catch{}if(u)await u()}}}function R(e){let t=E("sha256").update(`${v}:${e}`).digest();return{key1:t.readInt32BE(0),key2:t.readInt32BE(4)}}async function U(e,t,n){if(t==="postgres"){let{key1:i,key2:o}=R(e),d=await n.unsafe(`SELECT pg_try_advisory_lock(${i}, ${o}) AS acquired`);return X(d,"acquired")}let r=q(e),s=await n.unsafe(`SELECT GET_LOCK('${r}', 0) AS acquired`);return G(s,"acquired")===1}async function K(e,t,n){if(t==="postgres"){let{key1:s,key2:i}=R(e);await n.unsafe(`SELECT pg_advisory_unlock(${s}, ${i})`);return}let r=q(e);await n.unsafe(`SELECT RELEASE_LOCK('${r}')`)}function q(e){let t=`${v}_${e}`.replace(/[^A-Za-z0-9_]/g,"_");if(t.length<=64)return t;let n=E("sha256").update(e).digest("hex").slice(0,32);return`${v}_${n}`}function X(e,t){let n=O(e);if(!n)return!1;let r=n[t];return r===!0||r===1||r==="1"||r==="t"}function G(e,t){let n=O(e);if(!n)return null;let r=n[t];if(typeof r==="number")return r;if(typeof r==="string"&&/^-?\d+$/.test(r))return Number.parseInt(r,10);return null}function O(e){if(!e)return null;if(Array.isArray(e))return e[0];if(typeof e==="object"&&"rows"in e&&Array.isArray(e.rows))return e.rows[0];return null}class a{static jobs=new Map;static scheduledKeys=new Map;cronPattern="";intervalMs=null;timezone="America/Los_Angeles";task;static lockDir=S(process.cwd(),"storage","framework","locks");static stateFile=S(process.cwd(),"storage","framework","runtime","scheduler-state.json");static activeLocks=new Map;shouldPreventOverlap=!1;overlapExpiresAfterMinutes=1440;shouldRunOnOneServer=!1;shouldRunInBackground=!1;outputPath=null;outputAppend=!1;options={};constructor(e){this.task=e,queueMicrotask(()=>{try{this.start()}catch(t){l.error(`Failed to start scheduled task: ${t}`)}})}get pattern(){return this.cronPattern}everySecond(){return this.intervalMs=1000,this.cronPattern="@every_second",this}everyMinute(){return this.cronPattern="* * * * *",this}everyTwoMinutes(){return this.cronPattern="*/2 * * * *",this}everyFiveMinutes(){return this.cronPattern="*/5 * * * *",this}everyTenMinutes(){return this.cronPattern="*/10 * * * *",this}everyThirtyMinutes(){return this.cronPattern="*/30 * * * *",this}everyHour(){return this.cronPattern="0 * * * *",this}everyDay(){return this.cronPattern="0 0 * * *",this}hourly(){return this.cronPattern="0 * * * *",this}daily(){return this.cronPattern="0 0 * * *",this}weekly(){return this.cronPattern="0 0 * * 0",this}monthly(){return this.cronPattern="0 0 1 * *",this}yearly(){return this.cronPattern="0 0 1 1 *",this}annually(){return this.cronPattern="0 0 1 1 *",this}onDays(e){if(!Array.isArray(e)||e.length===0)throw Error(`onDays() requires a non-empty array; got ${JSON.stringify(e)}`);for(let t of e)if(!Number.isInteger(t)||t<0||t>6)throw Error(`onDays(): each day must be an integer 0-6 (Sun-Sat). Got ${t}`);return this.cronPattern=`0 0 * * ${e.join(",")}`,this}at(e){let t=e.split(":");if(t.length!==2||t[0]===""||t[1]==="")throw Error(`Invalid time format "${e}". Expected "HH:MM" (e.g., "14:30")`);let[n,r]=t.map(Number);if(n===void 0||r===void 0||Number.isNaN(n)||Number.isNaN(r)||n<0||n>23||r<0||r>59)throw Error(`Invalid time "${e}". Hour must be 0-23, minute must be 0-59`);let s=this.cronPattern.trim().split(/\s+/),[i,o,d]=s.length===5?s.slice(2):["*","*","*"];return this.cronPattern=`${r} ${n} ${i} ${o} ${d}`,this}setTimeZone(e){return this.timezone=e,this.options.timezone=e,this}withErrorHandler(e){return this.options.catch=e,this}withMaxRuns(e){return this.options.maxRuns=e,this}withProtection(e){return this.options.protect=e||!0,this}withName(e){return this.options.name=e,this}withContext(e){return this.options.context=e,this}withInterval(e){return this.options.interval=e,this}between(e,t){return this.options.startAt=e,this.options.stopAt=t,this}withoutOverlapping(e){if(this.shouldPreventOverlap=!0,e!==void 0)this.overlapExpiresAfterMinutes=e;return this}onOneServer(){return this.shouldRunOnOneServer=!0,this}runInBackground(){return this.shouldRunInBackground=!0,this}sendOutputTo(e){return this.outputPath=e,this.outputAppend=!1,this}appendOutputTo(e){return this.outputPath=e,this.outputAppend=!0,this}async runMissed(e){if(!this.cronPattern)return l.warn("[scheduler] runMissed() requires a cron-based schedule; interval-based tasks (everySecond) have no concept of missed slots"),0;let t=e.max??100,n=e.since instanceof Date?e.since.getTime():e.since,r=Date.now();if(n>=r)return 0;let s=[],i=n;while(s.length<t+1){let d=b(this.cronPattern,i);if(!d||d.getTime()>r)break;s.push(d),i=d.getTime()}if(s.length>t)l.warn(`[scheduler] runMissed: ${s.length} missed slots since ${new Date(n).toISOString()}, capping at ${t} - older runs dropped`),s.splice(0,s.length-t);let o=0;for(let d of s)try{let u=this.task();if(u&&typeof u.then==="function")await u;o++}catch(u){if(l.error(`[scheduler] runMissed slot ${d.toISOString()} failed: ${u instanceof Error?u.message:String(u)}`),this.options.catch)this.options.catch(u)}return o}static listJobs(){let e=[];for(let[t,n]of a.jobs)e.push({name:t,pattern:n.pattern,timezone:n.timezone,nextRun:n.nextRun?n.nextRun():null,enabled:a.isEnabled(t)});return e}static async runNow(e){let t=a.jobs.get(e);if(!t)throw Error(`Scheduled task "${e}" was not found.`);if(!a.isEnabled(e))throw Error(`Scheduled task "${e}" is paused.`);await t.run()}static setEnabled(e,t){let n=a.disabledJobs();if(t)n.delete(e);else n.add(e);let r=S(process.cwd(),"storage","framework","runtime");Y(r,{recursive:!0});let s=`${a.stateFile}.${process.pid}.tmp`;V(s,`${JSON.stringify({disabled:[...n].sort()},null,2)}
3
- `,{mode:384}),Q(s,a.stateFile)}static isEnabled(e){return!a.disabledJobs().has(e)}static isScheduled(e){return a.jobs.has(e)}static disabledJobs(){try{let e=JSON.parse(Z(a.stateFile,"utf8"));return new Set(Array.isArray(e.disabled)?e.disabled.filter((t)=>typeof t==="string"):[])}catch{return new Set}}static listLocks(){return Array.from(a.activeLocks.keys())}async acquireLock(e){if(a.activeLocks.has(e))return!1;let t=this.overlapExpiresAfterMinutes*60*1000,n=null,r=null;if(this.shouldRunOnOneServer)try{let{db:i}=await import("@stacksjs/database/runtime");r=i;let o=(await import("@stacksjs/env")).env.DB_CONNECTION||"sqlite";if(o==="postgres"||o==="mysql"||o==="sqlite")n=o}catch(i){l.warn(`[scheduler] onOneServer() couldn't reach DB; falling back to file-only lock (which only serializes within this process): ${i instanceof Error?i.message:String(i)}`)}let s=await A(e,t,n,r,a.lockDir);if(!s)return!1;return a.activeLocks.set(e,s),!0}async releaseLock(e){let t=a.activeLocks.get(e);if(!t)return;a.activeLocks.delete(e),await t.release()}wrapTask(e){let t=this.options.name||"unnamed-task",n=e;if(this.shouldPreventOverlap||this.shouldRunOnOneServer){let r=this,s=n;n=()=>(async()=>{if(!await r.acquireLock(t)){l.info(`Skipping overlapping task: ${t}`);return}try{let o=s();if(o&&typeof o==="object"&&typeof o.finally==="function")await o}finally{try{await r.releaseLock(t)}catch(o){l.error(`[scheduler] releasing the lock for ${t} failed: ${o instanceof Error?o.message:String(o)}`)}}})()}if(this.shouldRunInBackground){let r=n,s=this.outputPath,i=this.outputAppend;n=()=>{let o="inherit",d="inherit",u=null;if(s)try{let{openSync:h}=g("fs"),f=h(s,i?"a":"w");o=f,d=f,u=f}catch(h){l.warn(`[scheduler] couldn't open ${s} for background task ${t}: ${h instanceof Error?h.message:String(h)}; falling back to inherit`)}let y=r.toString();if(y.includes("[native code]")){if(l.error(`[scheduler] Background task ${t} cannot run: its function has no serializable source (it is native or bound), and a background task is evaluated from source in a new process. Wrap the call in a plain function - \`.run(() => nativeFn())\` - or drop \`.runInBackground()\`.`),u!==null)try{let{closeSync:h}=g("fs");h(u)}catch{}return}let c=W(process.execPath,["-e",`(${y})()`],{detached:!0,stdio:["ignore",o,d]});c.on("error",(h)=>{if(l.error(`Background task ${t} failed to spawn: ${h.message}`),u!==null)try{let{closeSync:f}=g("fs");f(u)}catch{}}),c.on("exit",(h,f)=>{if(h!==0&&h!==null)l.error(`[scheduler] background task ${t} (pid: ${c.pid}) exited with code ${h}`);else if(f)l.warn(`[scheduler] background task ${t} (pid: ${c.pid}) terminated by signal ${f}`);if(u!==null)try{let{closeSync:w}=g("fs");w(u)}catch{}}),c.unref(),l.info(`Task ${t} spawned in background (pid: ${c.pid})`)}}return n}getNextRunTime(){if(!this.cronPattern||this.intervalMs!==null)return null;if(!this.timezone||this.timezone==="UTC")return b(this.cronPattern);return b(this.cronPattern,void 0,{tz:this.timezone})}start(){if(!this.cronPattern&&this.intervalMs===null)return{stop:()=>{},run:async()=>{},nextRun:()=>null};let e=this.options.name?`${this.options.name.toLowerCase().replace(/[^a-z0-9]+/g,"")}::${this.cronPattern||`every:${this.intervalMs}`}`:null;if(e){let c=a.scheduledKeys.get(e);if(c)return l.debug(`[scheduler] duplicate schedule for "${this.options.name}" (${this.cronPattern||`every ${this.intervalMs}ms`}) skipped`),c}let t=this.wrapTask(this.task),n=!1,r=null,s=0,i=()=>{if(n=!0,r!==null){if(this.intervalMs!==null)clearInterval(r);else clearTimeout(r);r=null}},o=()=>this.getNextRunTime(),d=this.options.name||"unnamed-task",u=async()=>{if(!a.isEnabled(d)){l.debug(`[scheduler] task ${d} is paused`);return}if(this.options.maxRuns&&s>=this.options.maxRuns){i();return}s++;try{let c=t();if(c&&typeof c.then==="function")await c}catch(c){if(this.options.catch)this.options.catch(c);else l.error(`[scheduler] task ${d} failed (no withErrorHandler() installed): ${c instanceof Error?c.message:String(c)}`);throw c}},y={stop:i,run:u,nextRun:o,pattern:this.intervalMs!==null?`every ${this.intervalMs/1000}s`:this.cronPattern,timezone:this.timezone,name:this.options.name,enabled:a.isEnabled(d)};if(this.intervalMs!==null)r=setInterval(()=>{if(n)return;u().catch(()=>{})},this.intervalMs),r.unref?.();else if(this.cronPattern){let h=()=>{if(n)return;if(this.options.maxRuns&&s>=this.options.maxRuns)return;let f=this.getNextRunTime();if(!f)return;let w=Math.max(f.getTime()-Date.now(),0);if(w>2147483647)r=setTimeout(()=>{if(n)return;h()},2147483647);else r=setTimeout(()=>{if(n)return;u().catch(()=>{}),h()},w)};h()}if(this.options.name)a.jobs.set(this.options.name,y);if(e)a.scheduledKeys.set(e,y);return l.info(`Scheduled task with pattern: ${this.cronPattern} in timezone: ${this.timezone}`),y}static job(e){return new a(async()=>{l.info(`Running job: ${e}`);try{await ne(e)}catch(t){throw l.error(`Job ${e} failed:`,t),t}}).withName(e)}static action(e){return new a(async()=>{l.info(`Running action: ${e}`);try{await ee(e)}catch(t){throw l.error(`Action ${e} failed:`,t),t}}).withName(e)}static command(e){let t=null;return t=new a(async()=>{let r=t?.outputPath??null,s=t?.outputAppend??!1;try{if(l.info(`Executing command: ${e}`),r){let{spawn:o}=await import("child_process"),{openSync:d,closeSync:u}=await import("fs"),c=d(r,s?"a":"w");try{await new Promise((h,f)=>{let w=o(e,{shell:!0,stdio:["ignore",c,c]});w.on("error",f),w.on("exit",(T)=>{if(T===0)h();else f(Error(`Command '${e}' exited with code ${T}`))})})}finally{u(c)}return}let i=await te(e);if(i.isErr)throw l.error(i.error),i.error}catch(i){throw l.error(`Command execution failed: ${i}`),i}}),t.withName(`command-${e}`)}static notification(e,t,n,r){return new a(async()=>{try{let i=(await import("@stacksjs/notifications").catch(()=>null))?.notify;if(!i){l.error("schedule.notification: @stacksjs/notifications.notify is not available - install / wire the notifications package.");return}await i(e,t,n,r)}catch(s){throw l.error("Scheduled notification failed:",s),s}}).withName("notification")}static async gracefulShutdown(){l.info("Gracefully shutting down scheduled jobs...");for(let[e,t]of a.jobs)l.info(`Stopping job: ${e}`),t.stop();a.jobs.clear(),a.scheduledKeys.clear(),l.info("All jobs have been stopped")}}class re extends a{}function se(e){if(e instanceof Date)return e>new Date?e:null;return b(e)}function Me(e){let t=se(e);if(!t)return-1;return t.getTime()-Date.now()}var Ee=a;export{re as Queue,a as Schedule,he as runScheduler,Ee as schedule,se as sendAt,Me as timeout};
2
+ import{ok as F}from"@stacksjs/error-handling";import{log as O}from"@stacksjs/logging";import{path as E}from"@stacksjs/path";import{schedule as R}from"@stacksjs/scheduler";import{globSync as _}from"@stacksjs/storage";async function $t(){let t=_([E.appPath("Jobs/*.ts")],{absolute:!0});await B();for(let e of t)try{let r=(await import(e)).default,s=H(e);if(!r.rate)continue;let i=[s,r.name].find((o)=>typeof o==="string"&&R.isScheduled(o));if(i){O.debug(`[scheduler] ${i} is declared in app/Scheduler.ts; ignoring its \`rate\` so it is not scheduled twice`);continue}U(s,r.rate)}catch(n){O.error(`[scheduler] could not schedule ${e}: ${n instanceof Error?n.message:String(n)}`)}return F("Schedules ran successfully")}async function B(){let t=E.appPath("Scheduler.ts");try{let e=await import(t);if(typeof e.default==="function")e.default();else console.warn(`Scheduler file ${t} does not export a default function`)}catch(e){console.warn(`Could not load scheduler file ${t}:`,e)}}function U(t,e){R.job(t).cron(e)}function H(t){return E.basename(t).replace(/\.ts$/,"")}import{spawn as rt}from"child_process";import{createHash as st}from"crypto";import{closeSync as it,mkdirSync as ot,openSync as at,readFileSync as ct,renameSync as ut,writeFileSync as lt}from"fs";import{join as x}from"path";import{runNamedAction as dt}from"@stacksjs/action-runner";import{log as u,runCommand as ht}from"@stacksjs/cli";import{parse as v}from"@stacksjs/cron";import{job as ft,runJob as mt}from"@stacksjs/queue";import{createHash as M}from"crypto";import{existsSync as K,mkdirSync as Q,readFileSync as X,statSync as G,unlinkSync as W,writeFileSync as j}from"fs";import{join as D}from"path";import q from"process";var A="stacks_scheduler";function Z(t){let e=Date.now()-t.acquiredAt;if(e<0)return 0;return e}function Y(t){let e=t.replace(/[^\w.-]+/g,"_").replace(/^\.+/,"_").slice(0,80),n=M("sha256").update(t).digest("hex").slice(0,12);return`${e}-${n}.lock`}async function J(t,e,n,r,s){let i=s??D(q.cwd(),"storage","framework","locks"),o=D(i,Y(t));if(!K(i))Q(i,{recursive:!0});let a=!1,l=null;if(r&&(n==="postgres"||n==="mysql")){if(!await V(t,n,r))return null;l=async()=>{if(a)return;a=!0,await tt(t,n,r).catch(()=>{})}}let f={acquiredAt:Date.now(),monotonicStart:Math.round(performance.now()),holder:q.pid};try{j(o,JSON.stringify(f),{flag:"wx"})}catch{let d=!1;try{let h=X(o,"utf8"),m=JSON.parse(h);if(Z(m)>=e)d=!0}catch{try{let h=G(o);if(Math.max(0,Date.now()-h.mtimeMs)>=e)d=!0}catch{d=!0}}if(!d){if(l)await l();return null}try{j(o,JSON.stringify(f))}catch{if(l)await l();return null}}return{release:async()=>{try{W(o)}catch{}if(l)await l()}}}function I(t){let e=M("sha256").update(`${A}:${t}`).digest();return{key1:e.readInt32BE(0),key2:e.readInt32BE(4)}}async function V(t,e,n){if(e==="postgres"){let{key1:i,key2:o}=I(t),a=await n.unsafe(`SELECT pg_try_advisory_lock(${i}, ${o}) AS acquired`);return et(a,"acquired")}let r=L(t),s=await n.unsafe(`SELECT GET_LOCK('${r}', 0) AS acquired`);return nt(s,"acquired")===1}async function tt(t,e,n){if(e==="postgres"){let{key1:s,key2:i}=I(t);await n.unsafe(`SELECT pg_advisory_unlock(${s}, ${i})`);return}let r=L(t);await n.unsafe(`SELECT RELEASE_LOCK('${r}')`)}function L(t){let e=`${A}_${t}`.replace(/[^A-Za-z0-9_]/g,"_");if(e.length<=64)return e;let n=M("sha256").update(t).digest("hex").slice(0,32);return`${A}_${n}`}function et(t,e){let n=C(t);if(!n)return!1;let r=n[e];return r===!0||r===1||r==="1"||r==="t"}function nt(t,e){let n=C(t);if(!n)return null;let r=n[e];if(typeof r==="number")return r;if(typeof r==="string"&&/^-?\d+$/.test(r))return Number.parseInt(r,10);return null}function C(t){if(!t)return null;if(Array.isArray(t))return t[0];if(typeof t==="object"&&"rows"in t&&Array.isArray(t.rows))return t.rows[0];return null}function pt(...t){let e=JSON.stringify(t,(n,r)=>{if(typeof r==="bigint")return`${r}n`;if(typeof r==="function")return`[function ${r.name}]`;if(r&&typeof r==="object"&&!Array.isArray(r))return Object.fromEntries(Object.entries(r).sort(([s],[i])=>s.localeCompare(i)));return r});return`notification-${st("sha256").update(e).digest("hex").slice(0,12)}`}function z(t,e){let n=t instanceof Date?new Date(t.getTime()):new Date(t);if(Number.isNaN(n.getTime()))throw Error(`between(): ${e} is not a valid date: ${String(t)}`);return n}class c{static jobs=new Map;static scheduledKeys=new Map;cronPattern="";intervalMs=null;timezone="America/Los_Angeles";task;static lockDir=x(process.cwd(),"storage","framework","locks");static stateFile=x(process.cwd(),"storage","framework","runtime","scheduler-state.json");static activeLocks=new Map;static inProcessRuns=new Map;shouldPreventOverlap=!1;overlapExpiresAfterMinutes=1440;shouldRunOnOneServer=!1;shouldRunInBackground=!1;outputPath=null;outputAppend=!1;handlesOwnOutput=!1;jobName=null;dispatchQueue=null;options={};constructor(t){this.task=t,queueMicrotask(()=>{try{this.start()}catch(e){u.error(`Failed to start scheduled task: ${e}`)}})}get pattern(){return this.cronPattern}everySecond(){return this.intervalMs=1000,this.cronPattern="@every_second",this}everyMinute(){return this.cronPattern="* * * * *",this}everyTwoMinutes(){return this.cronPattern="*/2 * * * *",this}everyFiveMinutes(){return this.cronPattern="*/5 * * * *",this}everyTenMinutes(){return this.cronPattern="*/10 * * * *",this}everyThirtyMinutes(){return this.cronPattern="*/30 * * * *",this}everyHour(){return this.cronPattern="0 * * * *",this}everyDay(){return this.cronPattern="0 0 * * *",this}hourly(){return this.cronPattern="0 * * * *",this}daily(){return this.cronPattern="0 0 * * *",this}weekly(){return this.cronPattern="0 0 * * 0",this}monthly(){return this.cronPattern="0 0 1 * *",this}yearly(){return this.cronPattern="0 0 1 1 *",this}annually(){return this.cronPattern="0 0 1 1 *",this}cron(t){let e=String(t).trim().split(/\s+/).filter(Boolean);if(e.length===6){let[s,...i]=e;if(i.every((o)=>o==="*")){let o=s==="*"?1:Number(/^\*\/(\d+)$/.exec(s)?.[1]);if(Number.isInteger(o)&&o>=1&&o<60&&60%o===0)return this.intervalMs=o*1000,this.cronPattern=o===1?"@every_second":`@every_${o}_seconds`,this}if(s!=="0")throw Error(`cron(): '${t}' runs on particular seconds, which the scheduler cannot do. Use '*' or '*/N' (N dividing 60) with every other field '*', or a seconds field of 0`);e.shift()}if(e.length!==5)throw Error(`cron(): '${t}' is not a cron expression; expected 5 fields, or 6 with leading seconds`);let n=e.join(" "),r;try{r=v(n,Date.now(),{tz:"UTC"})}catch(s){throw Error(`cron(): '${t}' is not a valid cron expression: ${s instanceof Error?s.message:String(s)}`)}if(!r)throw Error(`cron(): '${t}' never matches a date`);return this.intervalMs=null,this.cronPattern=n,this}dailyAt(t){return this.cronPattern="0 0 * * *",this.at(t)}weekdays(){let t=this.cronPattern.trim().split(/\s+/),[e,n]=t.length===5?t:["0","0"];return this.cronPattern=`${e} ${n} * * 1-5`,this}onDays(t){if(!Array.isArray(t)||t.length===0)throw Error(`onDays() requires a non-empty array; got ${JSON.stringify(t)}`);for(let e of t)if(!Number.isInteger(e)||e<0||e>6)throw Error(`onDays(): each day must be an integer 0-6 (Sun-Sat). Got ${e}`);return this.cronPattern=`0 0 * * ${t.join(",")}`,this}at(t){let e=t.split(":");if(e.length!==2||e[0]===""||e[1]==="")throw Error(`Invalid time format "${t}". Expected "HH:MM" (e.g., "14:30")`);let[n,r]=e.map(Number);if(n===void 0||r===void 0||Number.isNaN(n)||Number.isNaN(r)||n<0||n>23||r<0||r>59)throw Error(`Invalid time "${t}". Hour must be 0-23, minute must be 0-59`);let s=this.cronPattern.trim().split(/\s+/),[i,o,a]=s.length===5?s.slice(2):["*","*","*"];return this.cronPattern=`${r} ${n} ${i} ${o} ${a}`,this}setTimeZone(t){return this.timezone=t,this.options.timezone=t,this}withErrorHandler(t){return this.options.catch=t,this}withMaxRuns(t){return this.options.maxRuns=t,this}withProtection(t){return this.options.protect=t||!0,this}withName(t){return this.options.name=t,this}withContext(t){return this.options.context=t,this}withInterval(t){if(typeof t!=="number"||!Number.isFinite(t)||t<=0)throw Error(`withInterval() takes a positive number of seconds; got ${String(t)}`);return this.options.interval=t,this}between(t,e){let n=z(t,"startAt"),r=z(e,"stopAt");if(r.getTime()<=n.getTime())throw Error(`between(): stopAt (${r.toISOString()}) must be after startAt (${n.toISOString()})`);return this.options.startAt=n,this.options.stopAt=r,this}onQueue(t){if(this.jobName===null)throw Error("onQueue() applies to schedule.job(...) only: an action, command or callback has no job to dispatch");if(typeof t!=="string"||t.trim()==="")throw Error(`onQueue() takes a queue name; got ${JSON.stringify(t)}`);return this.dispatchQueue=t,this}withoutOverlapping(t){if(this.shouldPreventOverlap=!0,t!==void 0)this.overlapExpiresAfterMinutes=t;return this}onOneServer(){return this.shouldRunOnOneServer=!0,this}runInBackground(){return this.shouldRunInBackground=!0,this}sendOutputTo(t){return this.outputPath=t,this.outputAppend=!1,this}appendOutputTo(t){return this.outputPath=t,this.outputAppend=!0,this}async runMissed(t){if(!this.cronPattern||this.intervalMs!==null)return u.warn("[scheduler] runMissed() requires a cron-based schedule; interval-based tasks (everySecond) have no concept of missed slots"),0;let e=t.max??100,n=t.since instanceof Date?t.since.getTime():t.since,r=Date.now();if(n>=r)return 0;let s=this.timezone&&this.timezone!=="UTC"?{tz:this.timezone}:{},i=(d)=>{let h=[],m=d;while(!0){let p=v(this.cronPattern,m,s);if(!p||p.getTime()>r)return h;h.push(p),m=p.getTime()}},o=60000*(e+1),a=Math.max(n,r-o),l=i(a);while(l.length<=e&&a>n)o*=2,a=Math.max(n,r-o),l=i(a);if(l.length>e){let d=a===n?`${l.length}`:`more than ${e}`;u.warn(`[scheduler] runMissed: ${d} missed slots since ${new Date(n).toISOString()}, running the ${e} most recent - older runs dropped`),l=l.slice(-e)}let f=0;for(let d of l)try{let h=this.task(this.options.context);if(h&&typeof h.then==="function")await h;f++}catch(h){if(u.error(`[scheduler] runMissed slot ${d.toISOString()} failed: ${h instanceof Error?h.message:String(h)}`),this.options.catch)this.options.catch(h)}return f}static listJobs(){let t=[];for(let[e,n]of c.jobs)t.push({name:e,pattern:n.pattern,timezone:n.timezone,nextRun:n.nextRun?n.nextRun():null,enabled:c.isEnabled(e)});return t}static async runNow(t,e={}){let n=c.jobs.get(t);if(!n)throw Error(`Scheduled task "${t}" was not found.`);if(!c.isEnabled(t))throw Error(`Scheduled task "${t}" is paused.`);let r=e.inProcess?c.inProcessRuns.get(t):void 0;await(r?r():n.run())}static setEnabled(t,e){let n=c.disabledJobs();if(e)n.delete(t);else n.add(t);let r=x(process.cwd(),"storage","framework","runtime");ot(r,{recursive:!0});let s=`${c.stateFile}.${process.pid}.tmp`;lt(s,`${JSON.stringify({disabled:[...n].sort()},null,2)}
3
+ `,{mode:384}),ut(s,c.stateFile)}static isEnabled(t){return!c.disabledJobs().has(t)}static isScheduled(t){return c.jobs.has(t)}static disabledJobs(){try{let t=JSON.parse(ct(c.stateFile,"utf8"));return new Set(Array.isArray(t.disabled)?t.disabled.filter((e)=>typeof e==="string"):[])}catch{return new Set}}static listLocks(){return Array.from(c.activeLocks.keys())}async acquireLock(t){if(c.activeLocks.has(t))return!1;let e=this.overlapExpiresAfterMinutes*60*1000,n=null,r=null;if(this.shouldRunOnOneServer)try{let{db:i}=await import("@stacksjs/database/runtime");r=i;let o=(await import("@stacksjs/env")).env.DB_CONNECTION||"sqlite";if(o==="postgres"||o==="mysql"||o==="sqlite")n=o}catch(i){u.warn(`[scheduler] onOneServer() couldn't reach DB; falling back to file-only lock (which only serializes within this process): ${i instanceof Error?i.message:String(i)}`)}let s=await J(t,e,n,r,c.lockDir);if(!s)return!1;return c.activeLocks.set(t,s),!0}async releaseLock(t){let e=c.activeLocks.get(t);if(!e)return;c.activeLocks.delete(t),await e.release()}wrapTask(t,e=!1){let n=this.options.name||"unnamed-task",r=t;if(this.shouldPreventOverlap||this.shouldRunOnOneServer){let s=this,i=r;r=()=>(async()=>{if(!await s.acquireLock(n)){u.info(`Skipping overlapping task: ${n}`);return}try{let a=i();if(a&&typeof a==="object"&&typeof a.finally==="function")await a}finally{try{await s.releaseLock(n)}catch(a){u.error(`[scheduler] releasing the lock for ${n} failed: ${a instanceof Error?a.message:String(a)}`)}}})()}if(this.shouldRunInBackground&&!e){let s=x(process.cwd(),"buddy"),i=this.handlesOwnOutput?null:this.outputPath,o=this.outputAppend;r=()=>{let a="inherit";if(i)try{a=at(i,o?"a":"w")}catch(d){u.warn(`[scheduler] couldn't open ${i} for background task ${n}: ${d instanceof Error?d.message:String(d)}; its output goes to this process instead`)}let l=()=>{if(typeof a!=="number")return;try{it(a)}catch{}a="inherit"},f=rt(s,["schedule:run-one",n,"--in-process"],{cwd:process.cwd(),detached:!0,stdio:["ignore",a,a]});l(),f.on("error",(d)=>{u.error(`[scheduler] background task ${n} failed to start (${s}): ${d.message}`)}),f.on("exit",(d,h)=>{if(d!==0&&d!==null)u.error(`[scheduler] background task ${n} (pid: ${f.pid}) exited with code ${d}`);else if(h)u.warn(`[scheduler] background task ${n} (pid: ${f.pid}) terminated by signal ${h}`)}),f.unref(),u.info(`Task ${n} spawned in background (pid: ${f.pid})`)}}return r}getNextRunTime(){if(!this.cronPattern||this.intervalMs!==null)return null;let{startAt:t,stopAt:e}=this.options,n=Date.now(),r=t&&t.getTime()>n?t.getTime()-1:n,s=this.nextCronTime(r);if(s&&e&&s.getTime()>e.getTime())return null;return s}nextCronTime(t){if(!this.timezone||this.timezone==="UTC")return v(this.cronPattern,t);return v(this.cronPattern,t,{tz:this.timezone})}start(){if(!this.cronPattern&&this.intervalMs===null)return{stop:()=>{},run:async()=>{},nextRun:()=>null};let t=this.options.name?`${this.options.name.toLowerCase().replace(/[^a-z0-9]+/g,"")}::${this.cronPattern||`every:${this.intervalMs}`}`:null;if(t){let k=c.scheduledKeys.get(t);if(k)return u.debug(`[scheduler] duplicate schedule for "${this.options.name}" (${this.cronPattern||`every ${this.intervalMs}ms`}) skipped`),k}if(this.shouldRunInBackground&&!this.options.name)throw Error("runInBackground() needs a named task: add .withName(...) so the background process can find it");let e=this.options.context,n=e===void 0?this.task:()=>this.task(e),r=this.wrapTask(n),s=this.shouldRunInBackground?this.wrapTask(n,!0):r,i=!1,o=!1,a=null,l=null,f=0,d=()=>{if(i=!0,l!==null){if(this.intervalMs!==null)clearInterval(l);else clearTimeout(l);l=null}},h=()=>this.getNextRunTime(),m=this.options.name||"unnamed-task",p=async(k)=>{if(!c.isEnabled(m)){u.debug(`[scheduler] task ${m} is paused`);return}let{startAt:y,stopAt:S,interval:b,protect:N}=this.options,P=Date.now();if(S&&P>S.getTime()){u.debug(`[scheduler] task ${m} is past its between() window; stopping`),d();return}if(y&&P<y.getTime()){u.debug(`[scheduler] task ${m} has not reached its between() window`);return}if(this.options.maxRuns&&f>=this.options.maxRuns){d();return}if(N&&o){if(u.debug(`[scheduler] task ${m} is still running; skipping this tick (withProtection)`),typeof N==="function")N(T);return}if(b&&a!==null&&P-a<b*1000){u.debug(`[scheduler] task ${m} ran less than ${b}s ago; skipping this tick (withInterval)`);return}f++,o=!0,a=P;try{let g=k();if(g&&typeof g.then==="function")await g}catch(g){if(this.options.catch)this.options.catch(g);else u.error(`[scheduler] task ${m} failed (no withErrorHandler() installed): ${g instanceof Error?g.message:String(g)}`);throw g}finally{o=!1}},w=()=>p(r),T={stop:d,run:w,nextRun:h,pattern:this.intervalMs!==null?`every ${this.intervalMs/1000}s`:this.cronPattern,timezone:this.timezone,name:this.options.name,enabled:c.isEnabled(m)};if(this.intervalMs!==null)l=setInterval(()=>{if(i)return;w().catch(()=>{})},this.intervalMs),l.unref?.();else if(this.cronPattern){let y=()=>{if(i)return;if(this.options.maxRuns&&f>=this.options.maxRuns)return;let S=this.getNextRunTime();if(!S){d();return}let b=Math.max(S.getTime()-Date.now(),0);if(b>2147483647)l=setTimeout(()=>{if(i)return;y()},2147483647);else l=setTimeout(()=>{if(i)return;w().catch(()=>{}),y()},b)};y()}if(this.options.name)c.jobs.set(this.options.name,T),c.inProcessRuns.set(this.options.name,()=>p(s));if(t)c.scheduledKeys.set(t,T);return u.info(`Scheduled task with pattern: ${this.cronPattern} in timezone: ${this.timezone}`),T}static job(t){let e=null;return e=new c(async(n)=>{let r=e?.dispatchQueue??null;try{if(r!==null){u.info(`Dispatching job: ${t} to queue ${r}`);let s=ft(t).onQueue(r);await(n===void 0?s:s.withContext(n)).dispatch();return}u.info(`Running job: ${t}`),await mt(t,n===void 0?{}:{context:n})}catch(s){throw u.error(`Job ${t} failed:`,s),s}}),e.jobName=t,e.withName(t)}static action(t){return new c(async()=>{u.info(`Running action: ${t}`);try{await dt(t)}catch(e){throw u.error(`Action ${t} failed:`,e),e}}).withName(t)}static command(t){let e=null;return e=new c(async()=>{let r=e?.outputPath??null,s=e?.outputAppend??!1;try{if(u.info(`Executing command: ${t}`),r){let{spawn:o}=await import("child_process"),{openSync:a,closeSync:l}=await import("fs"),d=a(r,s?"a":"w");try{await new Promise((h,m)=>{let p=o(t,{shell:!0,stdio:["ignore",d,d]});p.on("error",m),p.on("exit",(w)=>{if(w===0)h();else m(Error(`Command '${t}' exited with code ${w}`))})})}finally{l(d)}return}let i=await ht(t);if(i.isErr)throw u.error(i.error),i.error}catch(i){throw u.error(`Command execution failed: ${i}`),i}}),e.handlesOwnOutput=!0,e.withName(`command-${t}`)}static notification(t,e,n,r){return new c(async()=>{try{let{notify:s}=await import("@stacksjs/notifications"),i=await s(t,e,n,r),o=i.filter((a)=>!a.success);if(o.length>0){let a=o.map((l)=>`${l.channel}: ${l.error?.message??"not delivered"}`).join("; ");throw Error(`Scheduled notification was not delivered on ${o.length} of ${i.length} channel(s) - ${a}`)}}catch(s){throw u.error("Scheduled notification failed:",s),s}}).withName(pt(t,e,n,r))}static async gracefulShutdown(){u.info("Gracefully shutting down scheduled jobs...");for(let[t,e]of c.jobs)u.info(`Stopping job: ${t}`),e.stop();c.jobs.clear(),c.inProcessRuns.clear(),c.scheduledKeys.clear(),u.info("All jobs have been stopped")}}class gt extends c{}function wt(t){if(t instanceof Date)return t>new Date?t:null;return v(t)}function Ct(t){let e=wt(t);if(!e)return-1;return e.getTime()-Date.now()}var zt=c;export{gt as Queue,c as Schedule,$t as runScheduler,zt as schedule,wt as sendAt,Ct as timeout};
@@ -1,4 +1,5 @@
1
1
  import type { CatchCallbackFn } from '@stacksjs/cron';
2
+ import type { NotificationChannel, NotificationPayload, NotificationRecipient, NotifyOptions } from '@stacksjs/notifications';
2
3
  import type { ScheduledJob, TimedSchedule, Timezone, UntimedSchedule } from './types';
3
4
  /**
4
5
  * Get the next run time for a cron expression.
@@ -68,7 +69,7 @@ export type SchedulableActionName = keyof SchedulableActions extends never
68
69
  : Extract<keyof SchedulableActions, string>;
69
70
  export type Scheduler = typeof Schedule;
70
71
  export declare class Schedule implements UntimedSchedule {
71
- constructor(task: () => void);
72
+ constructor(task: (context?: any) => unknown);
72
73
  get pattern(): string;
73
74
  everySecond(): TimedSchedule;
74
75
  everyMinute(): TimedSchedule;
@@ -84,6 +85,9 @@ export declare class Schedule implements UntimedSchedule {
84
85
  monthly(): TimedSchedule;
85
86
  yearly(): TimedSchedule;
86
87
  annually(): TimedSchedule;
88
+ cron(expression: string): TimedSchedule;
89
+ dailyAt(time: string): TimedSchedule;
90
+ weekdays(): TimedSchedule;
87
91
  onDays(days: number[]): TimedSchedule;
88
92
  at(time: string): TimedSchedule;
89
93
  setTimeZone(timezone: Timezone): this;
@@ -94,6 +98,7 @@ export declare class Schedule implements UntimedSchedule {
94
98
  withContext(context: any): this;
95
99
  withInterval(seconds: number): this;
96
100
  between(startAt: string | Date, stopAt: string | Date): this;
101
+ onQueue(queue: string): this;
97
102
  withoutOverlapping(expiresAfterMinutes?: number): this;
98
103
  onOneServer(): this;
99
104
  runInBackground(): this;
@@ -101,7 +106,7 @@ export declare class Schedule implements UntimedSchedule {
101
106
  appendOutputTo(path: string): this;
102
107
  runMissed(opts: { since: Date | number, max?: number }): Promise<number>;
103
108
  static listJobs(): Array<{ name: string, pattern?: string, timezone?: Timezone, nextRun: Date | null, enabled: boolean }>;
104
- static runNow(name: string): Promise<void>;
109
+ static runNow(name: string, options?: { inProcess?: boolean }): Promise<void>;
105
110
  static setEnabled(name: string, enabled: boolean): void;
106
111
  static isEnabled(name: string): boolean;
107
112
  static isScheduled(name: string): boolean;
@@ -109,7 +114,7 @@ export declare class Schedule implements UntimedSchedule {
109
114
  static job(name: SchedulableJobName): UntimedSchedule;
110
115
  static action(name: SchedulableActionName): UntimedSchedule;
111
116
  static command(cmd: string): UntimedSchedule;
112
- static notification(recipient: unknown, payload: unknown, channels?: unknown, options?: unknown): UntimedSchedule;
117
+ static notification(recipient: NotificationRecipient, payload: NotificationPayload, channels?: NotificationChannel[], options?: NotifyOptions): UntimedSchedule;
113
118
  static gracefulShutdown(): Promise<void>;
114
119
  }
115
120
  export declare class Queue extends Schedule {
@@ -1,3 +1,14 @@
1
+ /**
2
+ * The file a task's lock lives in.
3
+ *
4
+ * The task name went into the path as is, and a command task is named after
5
+ * its command: `command-./backup.sh` pointed into a `command-.` directory that
6
+ * did not exist, every write failed, the lock was never acquired, and the task
7
+ * was skipped as "overlapping" on every tick - it never ran. Anything outside
8
+ * a plain file-name alphabet is replaced, and a short hash of the full name
9
+ * keeps two names that sanitize alike from sharing a lock.
10
+ */
11
+ export declare function lockFileName(taskName: string): string;
1
12
  /**
2
13
  * Try to acquire a distributed scheduler lock for `taskName`. The
3
14
  * lock is two-layered:
package/dist/types.d.ts CHANGED
@@ -21,10 +21,13 @@ export declare interface BaseSchedule {
21
21
  withoutOverlapping: (expiresAfterMinutes?: number) => this
22
22
  onOneServer: () => this
23
23
  runInBackground: () => this
24
+ onQueue: (queue: string) => this
24
25
  }
25
26
  // Interface for schedule after timing is set
26
27
  export declare interface TimedSchedule extends BaseSchedule {
27
28
  at: (time: string) => TimedSchedule
29
+ weekdays: () => TimedSchedule
30
+ runMissed: (opts: { since: Date | number, max?: number }) => Promise<number>
28
31
  }
29
32
  // Interface for schedule before timing is set
30
33
  export declare interface UntimedSchedule extends BaseSchedule {
@@ -44,6 +47,9 @@ export declare interface UntimedSchedule extends BaseSchedule {
44
47
  annually: () => TimedSchedule
45
48
  onDays: (days: number[]) => TimedSchedule
46
49
  at: (time: string) => TimedSchedule
50
+ dailyAt: (time: string) => TimedSchedule
51
+ cron: (expression: string) => TimedSchedule
52
+ weekdays: () => TimedSchedule
47
53
  }
48
54
  // IANA Timezone
49
55
  export type Timezone = | 'Africa/Abidjan'
package/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "@stacksjs/scheduler",
3
3
  "type": "module",
4
4
  "sideEffects": false,
5
- "version": "0.75.72",
5
+ "version": "0.75.73",
6
6
  "description": "The Stacks scheduler.",
7
7
  "author": "Chris Breuer",
8
8
  "contributors": [
@@ -58,18 +58,18 @@
58
58
  "prepublishOnly": "bun run build"
59
59
  },
60
60
  "dependencies": {
61
- "@stacksjs/action-runner": "0.75.72",
62
- "@stacksjs/cli": "0.75.72",
63
- "@stacksjs/cron": "0.75.72",
64
- "@stacksjs/database": "0.75.72",
65
- "@stacksjs/env": "0.75.72",
66
- "@stacksjs/error-handling": "0.75.72",
67
- "@stacksjs/logging": "0.75.72",
68
- "@stacksjs/notifications": "0.75.72",
69
- "@stacksjs/path": "0.75.72",
70
- "@stacksjs/queue": "0.75.72",
71
- "@stacksjs/storage": "0.75.72",
72
- "@stacksjs/types": "0.75.72"
61
+ "@stacksjs/action-runner": "0.75.73",
62
+ "@stacksjs/cli": "0.75.73",
63
+ "@stacksjs/cron": "0.75.73",
64
+ "@stacksjs/database": "0.75.73",
65
+ "@stacksjs/env": "0.75.73",
66
+ "@stacksjs/error-handling": "0.75.73",
67
+ "@stacksjs/logging": "0.75.73",
68
+ "@stacksjs/notifications": "0.75.73",
69
+ "@stacksjs/path": "0.75.73",
70
+ "@stacksjs/queue": "0.75.73",
71
+ "@stacksjs/storage": "0.75.73",
72
+ "@stacksjs/types": "0.75.73"
73
73
  },
74
74
  "devDependencies": {
75
75
  "better-dx": "^0.2.24"