@stacksjs/scheduler 0.75.72 → 0.75.74
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 +2 -2
- package/dist/schedule.d.ts +8 -3
- package/dist/scheduler-lock.d.ts +11 -0
- package/dist/types.d.ts +6 -0
- package/package.json +13 -13
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}),
|
|
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};
|
package/dist/schedule.d.ts
CHANGED
|
@@ -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: () =>
|
|
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:
|
|
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 {
|
package/dist/scheduler-lock.d.ts
CHANGED
|
@@ -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.
|
|
5
|
+
"version": "0.75.74",
|
|
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.
|
|
62
|
-
"@stacksjs/cli": "0.75.
|
|
63
|
-
"@stacksjs/cron": "0.75.
|
|
64
|
-
"@stacksjs/database": "0.75.
|
|
65
|
-
"@stacksjs/env": "0.75.
|
|
66
|
-
"@stacksjs/error-handling": "0.75.
|
|
67
|
-
"@stacksjs/logging": "0.75.
|
|
68
|
-
"@stacksjs/notifications": "0.75.
|
|
69
|
-
"@stacksjs/path": "0.75.
|
|
70
|
-
"@stacksjs/queue": "0.75.
|
|
71
|
-
"@stacksjs/storage": "0.75.
|
|
72
|
-
"@stacksjs/types": "0.75.
|
|
61
|
+
"@stacksjs/action-runner": "0.75.74",
|
|
62
|
+
"@stacksjs/cli": "0.75.74",
|
|
63
|
+
"@stacksjs/cron": "0.75.74",
|
|
64
|
+
"@stacksjs/database": "0.75.74",
|
|
65
|
+
"@stacksjs/env": "0.75.74",
|
|
66
|
+
"@stacksjs/error-handling": "0.75.74",
|
|
67
|
+
"@stacksjs/logging": "0.75.74",
|
|
68
|
+
"@stacksjs/notifications": "0.75.74",
|
|
69
|
+
"@stacksjs/path": "0.75.74",
|
|
70
|
+
"@stacksjs/queue": "0.75.74",
|
|
71
|
+
"@stacksjs/storage": "0.75.74",
|
|
72
|
+
"@stacksjs/types": "0.75.74"
|
|
73
73
|
},
|
|
74
74
|
"devDependencies": {
|
|
75
75
|
"better-dx": "^0.2.24"
|