sails-hook-quest 0.0.0 → 0.0.2

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.
@@ -0,0 +1,7 @@
1
+ {
2
+ "permissions": {
3
+ "allow": ["WebFetch(domain:github.com)"],
4
+ "deny": [],
5
+ "ask": []
6
+ }
7
+ }
@@ -0,0 +1 @@
1
+ module.exports = { extends: ['@commitlint/config-conventional'] }
@@ -0,0 +1 @@
1
+ github: DominusKelvin
@@ -0,0 +1,22 @@
1
+ name: Prettier
2
+
3
+ on: [push, pull_request]
4
+
5
+ jobs:
6
+ prettier:
7
+ runs-on: ubuntu-latest
8
+
9
+ steps:
10
+ - name: Checkout code
11
+ uses: actions/checkout@v3
12
+
13
+ - name: Setup Node.js
14
+ uses: actions/setup-node@v3
15
+ with:
16
+ node-version: 18
17
+
18
+ - name: Run npm ci
19
+ run: npm ci
20
+
21
+ - name: Run Prettier
22
+ run: npx prettier --config ./.prettierrc.js --write .
@@ -0,0 +1 @@
1
+ npx lint-staged
package/.prettierrc.js ADDED
@@ -0,0 +1,5 @@
1
+ module.exports = {
2
+ semi: false,
3
+ singleQuote: true,
4
+ trailingComma: 'none'
5
+ }
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2024 The Sailscasts Company
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,350 @@
1
+ # sails-hook-quest
2
+
3
+ Elegant job scheduling for Sails.js applications. Run scheduled tasks with full access to your Sails app's models, helpers, and configuration.
4
+
5
+ ## Features
6
+
7
+ - 🕐 **Multiple scheduling formats** - Cron expressions, human-readable intervals, or specific dates
8
+ - 🚀 **Full Sails context** - Access models, helpers, and config in your jobs
9
+ - 🎯 **Simple API** - Just add a `quest` property to your existing Sails scripts
10
+ - 🔄 **Overlap prevention** - Prevent jobs from running concurrently
11
+ - 📊 **Event system** - Listen to job lifecycle events for monitoring and alerting
12
+ - 🌍 **Environment support** - Run jobs in a minimal 'console' environment for better performance
13
+
14
+ ## Installation
15
+
16
+ ```bash
17
+ npm install sails-hook-quest
18
+ ```
19
+
20
+ ## Quick Start
21
+
22
+ ### 1. Create a scheduled job
23
+
24
+ Create a script in your `scripts/` directory:
25
+
26
+ ```javascript
27
+ // scripts/cleanup-sessions.js
28
+ module.exports = {
29
+ friendlyName: 'Cleanup old sessions',
30
+ description: 'Remove expired sessions from the database',
31
+
32
+ // Add Quest scheduling configuration
33
+ quest: {
34
+ interval: '1 hour', // Human-readable interval
35
+ // or
36
+ cron: '0 * * * *', // Standard cron expression
37
+
38
+ withoutOverlapping: true // Prevent concurrent runs
39
+ },
40
+
41
+ inputs: {
42
+ daysOld: {
43
+ type: 'number',
44
+ defaultsTo: 30
45
+ }
46
+ },
47
+
48
+ fn: async function (inputs) {
49
+ // Full access to Sails models
50
+ const deleted = await Session.destroy({
51
+ lastActive: {
52
+ '<': new Date(Date.now() - inputs.daysOld * 24 * 60 * 60 * 1000)
53
+ }
54
+ }).fetch()
55
+
56
+ // Use helpers
57
+ await sails.helpers.sendEmail.with({
58
+ to: 'admin@example.com',
59
+ subject: 'Cleanup complete',
60
+ text: `Deleted ${deleted.length} sessions`
61
+ })
62
+
63
+ return { deletedCount: deleted.length }
64
+ }
65
+ }
66
+ ```
67
+
68
+ ### 2. Configure Quest (optional)
69
+
70
+ ```javascript
71
+ // config/quest.js
72
+ module.exports.quest = {
73
+ // Auto-start jobs on lift
74
+ autoStart: true,
75
+
76
+ // Timezone for cron expressions
77
+ timezone: 'UTC',
78
+
79
+ // Run jobs in console environment (minimal Sails lift)
80
+ environment: 'console',
81
+
82
+ // Define additional jobs in config
83
+ jobs: [
84
+ {
85
+ name: 'health-check',
86
+ interval: '5 minutes',
87
+ inputs: {
88
+ url: 'https://api.example.com/health'
89
+ }
90
+ }
91
+ ]
92
+ }
93
+ ```
94
+
95
+ ### 3. Listen to job events (optional)
96
+
97
+ ```javascript
98
+ // config/bootstrap.js
99
+ module.exports.bootstrap = async function () {
100
+ // Job started
101
+ sails.on('quest:job:start', (data) => {
102
+ console.log(`Job ${data.name} started`)
103
+ })
104
+
105
+ // Job completed
106
+ sails.on('quest:job:complete', (data) => {
107
+ console.log(`Job ${data.name} completed in ${data.duration}ms`)
108
+ // Send metrics to monitoring service
109
+ })
110
+
111
+ // Job failed
112
+ sails.on('quest:job:error', (data) => {
113
+ console.error(`Job ${data.name} failed:`, data.error)
114
+ // Send alert to Slack/Discord/Telegram
115
+ })
116
+ }
117
+ ```
118
+
119
+ ## Scheduling Options
120
+
121
+ ### Human-Readable Intervals
122
+
123
+ ```javascript
124
+ quest: {
125
+ interval: '30 seconds'
126
+ interval: '5 minutes'
127
+ interval: '2 hours'
128
+ interval: '7 days'
129
+ }
130
+ ```
131
+
132
+ ### Cron Expressions
133
+
134
+ ```javascript
135
+ quest: {
136
+ cron: '0 2 * * *' // Daily at 2 AM
137
+ cron: '*/5 * * * *' // Every 5 minutes
138
+ cron: '0 9 * * MON' // Every Monday at 9 AM
139
+ }
140
+ ```
141
+
142
+ ### One-time Execution
143
+
144
+ ```javascript
145
+ quest: {
146
+ timeout: '10 minutes' // Run once after 10 minutes
147
+ date: new Date('2024-12-25') // Run on specific date
148
+ }
149
+ ```
150
+
151
+ ## API
152
+
153
+ ### `sails.quest.run(jobName, inputs?)`
154
+
155
+ Manually run a job immediately
156
+
157
+ ```javascript
158
+ await sails.quest.run('cleanup-sessions', { daysOld: 7 })
159
+ ```
160
+
161
+ ### `sails.quest.start(jobName?)`
162
+
163
+ Start scheduling a job (or all jobs if no name provided)
164
+
165
+ ```javascript
166
+ sails.quest.start('weekly-report')
167
+ ```
168
+
169
+ ### `sails.quest.stop(jobName?)`
170
+
171
+ Stop scheduling a job
172
+
173
+ ```javascript
174
+ sails.quest.stop('weekly-report')
175
+ ```
176
+
177
+ ### `sails.quest.list()`
178
+
179
+ Get list of all registered jobs
180
+
181
+ ```javascript
182
+ const jobs = sails.quest.list()
183
+ // [{ name: 'cleanup', interval: '1 hour', ... }]
184
+ ```
185
+
186
+ ### `sails.quest.pause(jobName)`
187
+
188
+ Pause a job (prevents execution but keeps schedule)
189
+
190
+ ```javascript
191
+ sails.quest.pause('heavy-task')
192
+ ```
193
+
194
+ ### `sails.quest.resume(jobName)`
195
+
196
+ Resume a paused job
197
+
198
+ ```javascript
199
+ sails.quest.resume('heavy-task')
200
+ ```
201
+
202
+ ## Events
203
+
204
+ Quest emits the following events that you can listen to:
205
+
206
+ - `quest:job:start` - Job execution started
207
+ - `quest:job:complete` - Job completed successfully
208
+ - `quest:job:error` - Job failed with error
209
+
210
+ Each event includes:
211
+
212
+ ```javascript
213
+ {
214
+ name: 'job-name',
215
+ inputs: { /* job inputs */ },
216
+ timestamp: Date,
217
+ duration: 1234, // milliseconds (complete/error only)
218
+ error: { } // error details (error event only)
219
+ }
220
+ ```
221
+
222
+ ## Console Environment
223
+
224
+ Quest can run jobs in a minimal 'console' environment that skips unnecessary hooks for better performance:
225
+
226
+ ```javascript
227
+ // config/env/console.js
228
+ module.exports = {
229
+ hooks: {
230
+ views: false,
231
+ sockets: false,
232
+ pubsub: false
233
+ // Only load what your jobs need
234
+ }
235
+ }
236
+ ```
237
+
238
+ ```javascript
239
+ // config/quest.js
240
+ module.exports.quest = {
241
+ environment: 'console' // Use console environment for jobs
242
+ }
243
+ ```
244
+
245
+ ## Examples
246
+
247
+ ### Send Weekly Newsletter
248
+
249
+ ```javascript
250
+ // scripts/send-newsletter.js
251
+ module.exports = {
252
+ friendlyName: 'Send weekly newsletter',
253
+
254
+ quest: {
255
+ cron: '0 9 * * MON', // Every Monday at 9 AM
256
+ withoutOverlapping: true
257
+ },
258
+
259
+ fn: async function () {
260
+ const subscribers = await User.find({
261
+ subscribed: true,
262
+ emailVerified: true
263
+ })
264
+
265
+ for (const user of subscribers) {
266
+ await sails.helpers.sendEmail.newsletter(user)
267
+ }
268
+
269
+ return { sent: subscribers.length }
270
+ }
271
+ }
272
+ ```
273
+
274
+ ### Process Upload Queue
275
+
276
+ ```javascript
277
+ // scripts/process-uploads.js
278
+ module.exports = {
279
+ friendlyName: 'Process pending uploads',
280
+
281
+ quest: {
282
+ interval: '2 minutes',
283
+ withoutOverlapping: true
284
+ },
285
+
286
+ fn: async function () {
287
+ const pending = await Upload.find({
288
+ status: 'pending'
289
+ }).limit(10)
290
+
291
+ for (const upload of pending) {
292
+ await sails.helpers.processUpload(upload)
293
+ await Upload.updateOne({ id: upload.id }).set({ status: 'processed' })
294
+ }
295
+
296
+ return { processed: pending.length }
297
+ }
298
+ }
299
+ ```
300
+
301
+ ### Database Backup
302
+
303
+ ```javascript
304
+ // scripts/backup-database.js
305
+ module.exports = {
306
+ friendlyName: 'Backup database',
307
+
308
+ quest: {
309
+ cron: '0 3 * * *', // Daily at 3 AM
310
+ withoutOverlapping: true
311
+ },
312
+
313
+ fn: async function () {
314
+ const backup = await sails.helpers.createDatabaseBackup()
315
+
316
+ await sails.helpers.uploadToS3(backup.path)
317
+
318
+ await sails.helpers.sendEmail.with({
319
+ to: 'admin@example.com',
320
+ subject: 'Database backup complete',
321
+ text: `Backup saved: ${backup.filename}`
322
+ })
323
+
324
+ return { filename: backup.filename }
325
+ }
326
+ }
327
+ ```
328
+
329
+ ## How It Works
330
+
331
+ Quest leverages the existing `sails run` command to execute jobs. Each job runs as a separate Sails process with full access to your application's context. This approach provides:
332
+
333
+ 1. **Full Sails context** - Models, helpers, and config work exactly as expected
334
+ 2. **Process isolation** - Jobs can't crash your main application
335
+ 3. **Simple implementation** - No complex worker thread communication
336
+ 4. **Familiar patterns** - Jobs are just Sails scripts with scheduling metadata
337
+
338
+ ## License
339
+
340
+ MIT
341
+
342
+ ## Support
343
+
344
+ - 📖 [Documentation](https://docs.sailscasts.com/sails-quest)
345
+ - 🐛 [Report Issues](https://github.com/sailscastshq/sails-hook-quest/issues)
346
+ - 💬 [Discord Community](https://discord.gg/gbJZuNm)
347
+
348
+ ---
349
+
350
+ Built with ❤️ by [The Sailscasts Company](https://sailscasts.com)
@@ -0,0 +1,184 @@
1
+ /**
2
+ * core/executor.js
3
+ *
4
+ * Functions for executing jobs via child processes
5
+ */
6
+
7
+ const { spawn } = require('child_process')
8
+ const fs = require('fs')
9
+ const path = require('path')
10
+
11
+ /**
12
+ * Execute a job via `sails run`
13
+ * @param {String} name - Job name
14
+ * @param {Object} job - Job configuration
15
+ * @param {Object} customInputs - Custom input values
16
+ * @param {Object} context - Execution context with running map, config, etc
17
+ * @returns {Promise} Resolves when job completes
18
+ */
19
+ async function executeJob(name, job, customInputs = {}, context = {}) {
20
+ const { running = new Map(), config = {} } = context
21
+
22
+ // Check if job is already running (and overlapping is disabled)
23
+ if (job.withoutOverlapping && running.has(name)) {
24
+ if (global.sails) {
25
+ sails.log.warn(`Job "${name}" is already running, skipping...`)
26
+ }
27
+ return { skipped: true, reason: 'already_running' }
28
+ }
29
+
30
+ // Don't run if paused
31
+ if (job.paused) {
32
+ if (global.sails) {
33
+ sails.log.verbose(`Job "${name}" is paused, skipping...`)
34
+ }
35
+ return { skipped: true, reason: 'paused' }
36
+ }
37
+
38
+ if (global.sails) {
39
+ sails.log.info(`Running job: ${name}`)
40
+ }
41
+
42
+ running.set(name, Date.now())
43
+
44
+ // Merge inputs with priority: jobInputs < scriptInputs < customInputs
45
+ const inputs = { ...job.inputs, ...job.scriptInputs, ...customInputs }
46
+
47
+ // Emit job start event
48
+ if (global.sails) {
49
+ sails.emit('quest:job:start', {
50
+ name,
51
+ inputs,
52
+ timestamp: new Date()
53
+ })
54
+ }
55
+
56
+ return new Promise((resolve, reject) => {
57
+ // Build command arguments
58
+ const args = buildCommandArgs(name, inputs)
59
+
60
+ // Setup environment
61
+ const env = { ...process.env }
62
+ if (config.environment) {
63
+ env.NODE_ENV = config.environment
64
+ }
65
+
66
+ const sailsPath = config.sailsPath || './node_modules/.bin/sails'
67
+ const cwd = config.appPath || process.cwd()
68
+ const scriptsDir = config.scriptsDir || 'scripts'
69
+
70
+ // Validate script exists before attempting to run
71
+ const scriptPath = path.resolve(cwd, scriptsDir, `${name}.js`)
72
+ if (!fs.existsSync(scriptPath)) {
73
+ running.delete(name)
74
+ const error = new Error(
75
+ `Job "${name}" not found. Please check that the script exists at ${scriptsDir}/${name}.js`
76
+ )
77
+ if (global.sails) {
78
+ sails.log.error(error.message)
79
+ sails.emit('quest:job:error', {
80
+ name,
81
+ inputs,
82
+ error: { message: error.message },
83
+ duration: 0,
84
+ timestamp: new Date()
85
+ })
86
+ }
87
+ return reject(error)
88
+ }
89
+
90
+ const child = spawn(sailsPath, args, {
91
+ cwd,
92
+ env,
93
+ stdio: 'inherit'
94
+ })
95
+
96
+ child.on('exit', (code) => {
97
+ const startTime = running.get(name)
98
+ const duration = Date.now() - startTime
99
+ running.delete(name)
100
+
101
+ if (code === 0) {
102
+ if (global.sails) {
103
+ sails.log.info(`Job "${name}" completed successfully`)
104
+
105
+ // Emit success event
106
+ sails.emit('quest:job:complete', {
107
+ name,
108
+ inputs,
109
+ duration,
110
+ timestamp: new Date()
111
+ })
112
+ }
113
+
114
+ resolve({ success: true, duration })
115
+ } else {
116
+ const error = new Error(`Job "${name}" exited with code ${code}`)
117
+
118
+ if (global.sails) {
119
+ sails.log.error(error)
120
+
121
+ // Emit error event
122
+ sails.emit('quest:job:error', {
123
+ name,
124
+ inputs,
125
+ error: {
126
+ message: error.message,
127
+ code
128
+ },
129
+ duration,
130
+ timestamp: new Date()
131
+ })
132
+ }
133
+
134
+ reject(error)
135
+ }
136
+ })
137
+
138
+ child.on('error', (err) => {
139
+ const startTime = running.get(name) || Date.now()
140
+ const duration = Date.now() - startTime
141
+ running.delete(name)
142
+
143
+ if (global.sails) {
144
+ sails.log.error(`Job "${name}" failed to start:`, err)
145
+
146
+ // Emit error event
147
+ sails.emit('quest:job:error', {
148
+ name,
149
+ inputs,
150
+ error: {
151
+ message: err.message,
152
+ stack: err.stack
153
+ },
154
+ duration,
155
+ timestamp: new Date()
156
+ })
157
+ }
158
+
159
+ reject(err)
160
+ })
161
+ })
162
+ }
163
+
164
+ /**
165
+ * Build command arguments for sails run
166
+ * @param {String} scriptName - Name of the script
167
+ * @param {Object} inputs - Input values
168
+ * @returns {Array} Command arguments
169
+ */
170
+ function buildCommandArgs(scriptName, inputs = {}) {
171
+ const args = ['run', scriptName]
172
+
173
+ // Add inputs as command line args
174
+ for (const [key, value] of Object.entries(inputs)) {
175
+ args.push(`--${key}=${JSON.stringify(value)}`)
176
+ }
177
+
178
+ return args
179
+ }
180
+
181
+ module.exports = {
182
+ executeJob,
183
+ buildCommandArgs
184
+ }
@@ -0,0 +1,192 @@
1
+ /**
2
+ * core/job-control.js
3
+ *
4
+ * Functions for controlling job lifecycle (start, stop, pause, resume)
5
+ */
6
+
7
+ /**
8
+ * Schedule a job to run at its next scheduled time
9
+ * @param {String} name - Job name
10
+ * @param {Object} context - Context with jobs, timers maps and helper functions
11
+ */
12
+ function scheduleJob(name, context = {}) {
13
+ const {
14
+ jobs = new Map(),
15
+ timers = new Map(),
16
+ getNextRunTime,
17
+ executeJob
18
+ } = context
19
+
20
+ const job = jobs.get(name)
21
+ if (!job) {
22
+ if (global.sails) {
23
+ sails.log.warn(`Job "${name}" not found in jobs Map`)
24
+ }
25
+ return
26
+ }
27
+
28
+ // Clear any existing timer
29
+ stopJob(name, { timers })
30
+
31
+ // Get the next run time
32
+ const nextRun = getNextRunTime(job)
33
+ if (!nextRun) {
34
+ if (global.sails) {
35
+ sails.log.warn(
36
+ `Job "${name}" has no valid schedule (interval: ${job.interval}, cron: ${job.cron}, timeout: ${job.timeout})`
37
+ )
38
+ }
39
+ return
40
+ }
41
+
42
+ const delay = nextRun.getTime() - Date.now()
43
+
44
+ // If delay is negative (past time), run immediately
45
+ if (delay <= 0) {
46
+ executeJob(name).catch((err) => {
47
+ if (global.sails) {
48
+ sails.log.error(`Error running job "${name}":`, err)
49
+ }
50
+ })
51
+
52
+ // If it's a recurring job, schedule the next run
53
+ if (job.interval || job.cron) {
54
+ scheduleJob(name, context)
55
+ }
56
+ return
57
+ }
58
+
59
+ // Set timer for the next execution
60
+ const timer = setTimeout(() => {
61
+ executeJob(name).catch((err) => {
62
+ if (global.sails) {
63
+ sails.log.error(`Error running job "${name}":`, err)
64
+ }
65
+ })
66
+
67
+ // If it's a recurring job, schedule the next run
68
+ if (job.interval || job.cron) {
69
+ scheduleJob(name, context)
70
+ }
71
+ }, delay)
72
+
73
+ timers.set(name, timer)
74
+
75
+ if (global.sails) {
76
+ sails.log.verbose(`Job "${name}" scheduled for ${nextRun.toISOString()}`)
77
+ }
78
+ }
79
+
80
+ /**
81
+ * Stop a single job
82
+ * @param {String} name - Job name
83
+ * @param {Object} context - Context with timers map
84
+ */
85
+ function stopJob(name, context = {}) {
86
+ const { timers = new Map() } = context
87
+
88
+ const timer = timers.get(name)
89
+ if (timer) {
90
+ clearTimeout(timer)
91
+ clearInterval(timer)
92
+ timers.delete(name)
93
+ if (global.sails) {
94
+ sails.log.verbose(`Job "${name}" stopped`)
95
+ }
96
+ }
97
+ }
98
+
99
+ /**
100
+ * Start scheduling jobs
101
+ * @param {String|Array} jobNames - Job names to start (optional)
102
+ * @param {Object} context - Context with jobs map and scheduleJob function
103
+ */
104
+ async function startJobs(jobNames, context = {}) {
105
+ const { jobs = new Map(), scheduleJob } = context
106
+
107
+ const names = !jobNames
108
+ ? Array.from(jobs.keys())
109
+ : Array.isArray(jobNames)
110
+ ? jobNames
111
+ : [jobNames]
112
+
113
+ for (const name of names) {
114
+ scheduleJob(name)
115
+ }
116
+ }
117
+
118
+ /**
119
+ * Stop scheduling jobs
120
+ * @param {String|Array} jobNames - Job names to stop (optional)
121
+ * @param {Object} context - Context with jobs and timers maps
122
+ */
123
+ function stopJobs(jobNames, context = {}) {
124
+ const { jobs = new Map(), timers = new Map() } = context
125
+
126
+ const names = !jobNames
127
+ ? Array.from(jobs.keys())
128
+ : Array.isArray(jobNames)
129
+ ? jobNames
130
+ : [jobNames]
131
+
132
+ for (const name of names) {
133
+ stopJob(name, { timers })
134
+ }
135
+ }
136
+
137
+ /**
138
+ * Run jobs immediately
139
+ * @param {String|Array} jobNames - Job names to run
140
+ * @param {Object} inputs - Custom inputs
141
+ * @param {Object} context - Context with executeJob function
142
+ */
143
+ async function runJobs(jobNames, inputs, context = {}) {
144
+ const { jobs = new Map(), executeJob } = context
145
+
146
+ const names = !jobNames
147
+ ? Array.from(jobs.keys())
148
+ : Array.isArray(jobNames)
149
+ ? jobNames
150
+ : [jobNames]
151
+
152
+ const promises = names.map((name) => executeJob(name, inputs))
153
+ return Promise.all(promises)
154
+ }
155
+
156
+ /**
157
+ * Pause a job
158
+ * @param {String} name - Job name
159
+ * @param {Map} jobs - Jobs map
160
+ */
161
+ function pauseJob(name, jobs = new Map()) {
162
+ const job = jobs.get(name)
163
+ if (job) {
164
+ job.paused = true
165
+ return true
166
+ }
167
+ return false
168
+ }
169
+
170
+ /**
171
+ * Resume a job
172
+ * @param {String} name - Job name
173
+ * @param {Map} jobs - Jobs map
174
+ */
175
+ function resumeJob(name, jobs = new Map()) {
176
+ const job = jobs.get(name)
177
+ if (job) {
178
+ job.paused = false
179
+ return true
180
+ }
181
+ return false
182
+ }
183
+
184
+ module.exports = {
185
+ scheduleJob,
186
+ stopJob,
187
+ startJobs,
188
+ stopJobs,
189
+ runJobs,
190
+ pauseJob,
191
+ resumeJob
192
+ }
@@ -0,0 +1,196 @@
1
+ /**
2
+ * core/loader.js
3
+ *
4
+ * Functions for loading and managing job definitions
5
+ */
6
+
7
+ const path = require('path')
8
+ const includeAll = require('include-all')
9
+
10
+ /**
11
+ * Extract default values from a script's inputs schema
12
+ * @param {Object} inputs - Script's inputs definition (Sails machine format)
13
+ * @returns {Object} Object with input names and their defaultsTo values
14
+ */
15
+ function extractScriptInputDefaults(inputs) {
16
+ const defaults = {}
17
+ if (!inputs || typeof inputs !== 'object') return defaults
18
+
19
+ for (const [key, def] of Object.entries(inputs)) {
20
+ if (def && def.defaultsTo !== undefined) {
21
+ defaults[key] = def.defaultsTo
22
+ }
23
+ }
24
+ return defaults
25
+ }
26
+
27
+ /**
28
+ * Load jobs from scripts directory and config
29
+ * @param {Object} config - Quest configuration
30
+ * @param {Map} jobs - Jobs map to populate
31
+ * @returns {Promise<Map>} Populated jobs map
32
+ */
33
+ async function loadJobs(config, jobs = new Map()) {
34
+ // First, load scripts from the scripts directory
35
+ const scriptsDir = config.scriptsDir || 'scripts'
36
+ const appPath = config.appPath || process.cwd()
37
+ const fullPath = path.resolve(appPath, scriptsDir)
38
+
39
+ let scripts = {}
40
+
41
+ try {
42
+ scripts = includeAll({
43
+ dirname: fullPath,
44
+ filter: /(.+)\.js$/,
45
+ excludeDirs: /^\.(git|svn)$/,
46
+ flatten: true
47
+ })
48
+ } catch (e) {
49
+ if (global.sails) {
50
+ sails.log.verbose('No scripts directory found, skipping script jobs')
51
+ }
52
+ }
53
+
54
+ // First, add jobs from config (provides base inputs)
55
+ if (Array.isArray(config.jobs)) {
56
+ const configJobNames = new Set()
57
+
58
+ for (const jobDef of config.jobs) {
59
+ // Handle string shorthand (just job name)
60
+ const job = typeof jobDef === 'string' ? { name: jobDef } : jobDef
61
+
62
+ // Check for duplicate job names in config
63
+ if (configJobNames.has(job.name)) {
64
+ throw new Error(
65
+ `Duplicate job name "${job.name}" in config/quest.js. Each job must have a unique name.`
66
+ )
67
+ }
68
+ configJobNames.add(job.name)
69
+
70
+ // Try to get script inputs if the script exists
71
+ const scriptDef = scripts[job.name]
72
+ if (scriptDef && !job.scriptInputs) {
73
+ job.scriptInputs = extractScriptInputDefaults(scriptDef.inputs)
74
+ }
75
+
76
+ addJobDefinition(job, jobs, config)
77
+ }
78
+ }
79
+
80
+ // Then process scripts with quest config (script scheduling takes priority)
81
+ for (const [scriptFile, scriptDef] of Object.entries(scripts)) {
82
+ if (!scriptDef.quest) continue
83
+
84
+ const scriptName = scriptFile.replace(/\.js$/, '')
85
+ const questConfig = scriptDef.quest
86
+ const jobName = questConfig.name || scriptName
87
+
88
+ // Extract default values from script's inputs schema
89
+ const scriptInputs = extractScriptInputDefaults(scriptDef.inputs)
90
+
91
+ // Check if job was already defined in config
92
+ const existingJob = jobs.get(jobName)
93
+
94
+ // Merge: config as base, script quest config takes priority
95
+ const jobDef = {
96
+ name: jobName,
97
+ friendlyName: scriptDef.friendlyName,
98
+ description: scriptDef.description,
99
+ // Preserve config's withoutOverlapping if script doesn't specify
100
+ withoutOverlapping: existingJob?.withoutOverlapping,
101
+ inputs: { ...existingJob?.inputs, ...questConfig.inputs },
102
+ ...questConfig,
103
+ scriptInputs
104
+ }
105
+
106
+ addJobDefinition(jobDef, jobs, config)
107
+ }
108
+
109
+ return jobs
110
+ }
111
+
112
+ /**
113
+ * Add a job definition to the jobs Map
114
+ * @param {Object} jobDef - Job definition
115
+ * @param {Map} jobs - Jobs map
116
+ * @param {Object} config - Quest configuration
117
+ * @returns {Object} Normalized job
118
+ */
119
+ function addJobDefinition(jobDef, jobs = new Map(), config = {}) {
120
+ const name = jobDef.name
121
+ if (!name) {
122
+ if (global.sails) {
123
+ sails.log.warn('Job definition missing name, skipping:', jobDef)
124
+ }
125
+ return null
126
+ }
127
+
128
+ // Parse and normalize the job definition
129
+ const job = {
130
+ name,
131
+ friendlyName: jobDef.friendlyName || name,
132
+ description: jobDef.description,
133
+
134
+ // Scheduling options
135
+ interval: jobDef.interval,
136
+ timeout: jobDef.timeout,
137
+ cron: jobDef.cron,
138
+ cronOptions: jobDef.cronOptions,
139
+ date: jobDef.date,
140
+ timezone: jobDef.timezone,
141
+
142
+ // Input data to pass to the script
143
+ inputs: jobDef.inputs || {},
144
+ scriptInputs: jobDef.scriptInputs || {},
145
+
146
+ // Control options
147
+ paused: false,
148
+ withoutOverlapping:
149
+ jobDef.withoutOverlapping ?? config.withoutOverlapping ?? true
150
+ }
151
+
152
+ jobs.set(name, job)
153
+
154
+ if (global.sails) {
155
+ const schedule = {}
156
+ if (job.interval !== undefined) schedule.interval = job.interval
157
+ if (job.cron !== undefined) schedule.cron = job.cron
158
+ if (job.timeout !== undefined) schedule.timeout = job.timeout
159
+ sails.log.verbose(`Registered job: ${name}`, schedule)
160
+ }
161
+
162
+ return job
163
+ }
164
+
165
+ /**
166
+ * Remove a job from the jobs map
167
+ * @param {String} name - Job name
168
+ * @param {Map} jobs - Jobs map
169
+ * @param {Map} timers - Timers map
170
+ * @returns {Boolean} Success
171
+ */
172
+ function removeJob(name, jobs = new Map(), timers = new Map()) {
173
+ // Clear any associated timer
174
+ if (timers.has(name)) {
175
+ const timer = timers.get(name)
176
+ clearTimeout(timer)
177
+ clearInterval(timer)
178
+ timers.delete(name)
179
+ }
180
+
181
+ // Remove from jobs map
182
+ const existed = jobs.delete(name)
183
+
184
+ if (existed && global.sails) {
185
+ sails.log.verbose(`Job "${name}" removed`)
186
+ }
187
+
188
+ return existed
189
+ }
190
+
191
+ module.exports = {
192
+ loadJobs,
193
+ addJobDefinition,
194
+ removeJob,
195
+ extractScriptInputDefaults
196
+ }
@@ -0,0 +1,203 @@
1
+ /**
2
+ * core/scheduler.js
3
+ *
4
+ * Functions for parsing schedules and calculating next run times
5
+ */
6
+
7
+ const later = require('@breejs/later')
8
+ const humanInterval = require('human-interval')
9
+ const { CronExpressionParser } = require('cron-parser')
10
+
11
+ /**
12
+ * Parse various schedule formats and return next run time
13
+ * @param {Object} job - Job configuration
14
+ * @param {Object} config - Quest configuration
15
+ * @returns {Date|null} Next run time or null if invalid
16
+ */
17
+ function getNextRunTime(job, config = {}) {
18
+ const now = new Date()
19
+ const timezone = job.timezone || config.timezone
20
+
21
+ // Validate: cannot combine date and timeout
22
+ if (job.date && job.timeout !== undefined && job.timeout !== false) {
23
+ throw new Error(
24
+ `Job "${job.name}": Cannot combine 'date' and 'timeout'. Use one or the other.`
25
+ )
26
+ }
27
+
28
+ // Handle cron expressions
29
+ if (job.cron) {
30
+ try {
31
+ const options = { tz: timezone }
32
+ // Merge any cron-parser options (currentDate, startDate, endDate, etc.)
33
+ if (job.cronOptions) {
34
+ Object.assign(options, job.cronOptions)
35
+ }
36
+ const interval = CronExpressionParser.parse(job.cron, options)
37
+ return interval.next().toDate()
38
+ } catch (err) {
39
+ if (global.sails) {
40
+ sails.log.error(
41
+ `Invalid cron expression for job "${job.name}": ${job.cron}`,
42
+ err.message
43
+ )
44
+ }
45
+ return null
46
+ }
47
+ }
48
+
49
+ // Handle human-readable intervals
50
+ if (job.interval && typeof job.interval === 'string') {
51
+ const nextTime = parseInterval(job.interval, now)
52
+ if (nextTime) return nextTime
53
+
54
+ if (global.sails) {
55
+ sails.log.error(`Invalid interval for job "${job.name}": ${job.interval}`)
56
+ }
57
+ return null
58
+ }
59
+
60
+ // Handle numeric intervals (milliseconds)
61
+ if (typeof job.interval === 'number') {
62
+ return new Date(now.getTime() + job.interval)
63
+ }
64
+
65
+ // Handle timeout (one-time delay)
66
+ if (job.timeout !== undefined && job.timeout !== false) {
67
+ return parseTimeout(job.timeout, now)
68
+ }
69
+
70
+ // Handle specific date
71
+ if (job.date) {
72
+ const date = new Date(job.date)
73
+ if (date > now) {
74
+ return date
75
+ }
76
+ }
77
+
78
+ return null
79
+ }
80
+
81
+ /**
82
+ * Parse an interval string into a Date
83
+ * @param {String} intervalStr - Interval string like "5 minutes" or "every 2 hours"
84
+ * @param {Date} fromDate - Calculate from this date
85
+ * @returns {Date|null} Next run time or null if can't parse
86
+ */
87
+ function parseInterval(intervalStr, fromDate = new Date()) {
88
+ // Convert shorthand format (5s, 10m) to human-interval format
89
+ let processedStr = convertShorthand(intervalStr)
90
+
91
+ // Check if it's "every X seconds/minutes" format
92
+ const everyMatch = processedStr.match(
93
+ /^every\s+(\d+)\s+(seconds?|minutes?|hours?|days?)$/i
94
+ )
95
+ if (everyMatch) {
96
+ const amount = parseInt(everyMatch[1])
97
+ const unit = everyMatch[2].replace(/s$/, '') // Remove plural 's'
98
+ const msMap = {
99
+ second: 1000,
100
+ minute: 60000,
101
+ hour: 3600000,
102
+ day: 86400000
103
+ }
104
+ const ms = amount * msMap[unit]
105
+ if (ms) {
106
+ return new Date(fromDate.getTime() + ms)
107
+ }
108
+ }
109
+
110
+ // Check if it's a later.js text expression
111
+ if (processedStr.includes('at') || processedStr.includes('on the')) {
112
+ try {
113
+ const schedule = later.parse.text(processedStr)
114
+ if (schedule.error) {
115
+ throw new Error(schedule.error)
116
+ }
117
+ const next = later.schedule(schedule).next(1)
118
+ if (next) {
119
+ return new Date(next)
120
+ }
121
+ } catch (err) {
122
+ // Silently continue to try other parsers
123
+ }
124
+ }
125
+
126
+ // Try human-interval
127
+ try {
128
+ const ms = humanInterval(processedStr)
129
+ if (ms) {
130
+ return new Date(fromDate.getTime() + ms)
131
+ }
132
+ } catch (err) {
133
+ // Return null if can't parse
134
+ }
135
+
136
+ return null
137
+ }
138
+
139
+ /**
140
+ * Parse a timeout value into a Date
141
+ * @param {String|Number} timeout - Timeout value
142
+ * @param {Date} fromDate - Calculate from this date
143
+ * @returns {Date|null} Next run time or null
144
+ */
145
+ function parseTimeout(timeout, fromDate = new Date()) {
146
+ // String timeout (human-readable)
147
+ if (typeof timeout === 'string') {
148
+ // Check for "at" expressions (e.g., "at 10:00 am")
149
+ if (timeout.startsWith('at ')) {
150
+ try {
151
+ const schedule = later.parse.text(timeout)
152
+ if (!schedule.error) {
153
+ const next = later.schedule(schedule).next(1)
154
+ return next
155
+ }
156
+ } catch (err) {}
157
+ }
158
+
159
+ // Try human-interval
160
+ try {
161
+ const ms = humanInterval(timeout)
162
+ if (ms) {
163
+ return new Date(fromDate.getTime() + ms)
164
+ }
165
+ } catch (err) {}
166
+ }
167
+
168
+ // Numeric timeout
169
+ if (typeof timeout === 'number') {
170
+ return new Date(fromDate.getTime() + timeout)
171
+ }
172
+
173
+ return null
174
+ }
175
+
176
+ /**
177
+ * Convert shorthand format to full format
178
+ * @param {String} str - Input string
179
+ * @returns {String} Converted string
180
+ */
181
+ function convertShorthand(str) {
182
+ const shorthandMap = {
183
+ s: ' seconds',
184
+ m: ' minutes',
185
+ h: ' hours',
186
+ d: ' days'
187
+ }
188
+
189
+ // Check for shorthand format like '5s', '10m'
190
+ const shorthandMatch = str.match(/^(\d+)([smhd])$/)
191
+ if (shorthandMatch) {
192
+ return shorthandMatch[1] + shorthandMap[shorthandMatch[2]]
193
+ }
194
+
195
+ return str
196
+ }
197
+
198
+ module.exports = {
199
+ getNextRunTime,
200
+ parseInterval,
201
+ parseTimeout,
202
+ convertShorthand
203
+ }
package/lib/index.js ADDED
@@ -0,0 +1,140 @@
1
+ /**
2
+ * sails-hook-quest
3
+ *
4
+ * Elegant job scheduling for Sails.js with powerful scheduling syntax
5
+ * Execute scripts via `sails run` to maintain full Sails context
6
+ */
7
+
8
+ const scheduler = require('./core/scheduler')
9
+ const executor = require('./core/executor')
10
+ const loader = require('./core/loader')
11
+ const jobControl = require('./core/job-control')
12
+
13
+ module.exports = function defineQuestHook(sails) {
14
+ const jobs = new Map()
15
+ const timers = new Map()
16
+ const running = new Map()
17
+
18
+ // Create context object that will be passed to modules
19
+ const context = {
20
+ jobs,
21
+ timers,
22
+ running,
23
+ config: null, // Will be set after sails.config is available
24
+ scheduleJob: null, // Will be set after function is defined
25
+ executeJob: null, // Will be set after function is defined
26
+ getNextRunTime: null // Will be set after config is available
27
+ }
28
+
29
+ return {
30
+ defaults: {
31
+ quest: {
32
+ // Whether to start jobs automatically
33
+ autoStart: true,
34
+
35
+ // Timezone for cron expressions
36
+ timezone: 'UTC',
37
+
38
+ // Prevent overlapping runs by default
39
+ withoutOverlapping: true,
40
+
41
+ // Path to sails executable
42
+ sailsPath: './node_modules/.bin/sails',
43
+
44
+ // Environment to run jobs in (e.g., 'console' for minimal Sails lift)
45
+ environment: 'console',
46
+
47
+ // Directory containing job scripts
48
+ scriptsDir: 'scripts',
49
+
50
+ // Jobs defined in config
51
+ jobs: []
52
+ }
53
+ },
54
+
55
+ initialize: async function () {
56
+ sails.log.info('Initializing Quest job scheduler')
57
+
58
+ sails.after('hook:orm:loaded', async () => {
59
+ // Set up context with config
60
+ context.config = sails.config.quest
61
+ context.getNextRunTime = (job) =>
62
+ scheduler.getNextRunTime(job, sails.config.quest)
63
+ context.scheduleJob = (name) => jobControl.scheduleJob(name, context)
64
+ context.executeJob = (name, customInputs) => {
65
+ const job = jobs.get(name)
66
+ if (!job) {
67
+ // Try to run as a regular script without quest config
68
+ const minimalJob = {
69
+ name,
70
+ withoutOverlapping: false,
71
+ inputs: {}
72
+ }
73
+ return executor.executeJob(name, minimalJob, customInputs, context)
74
+ }
75
+ return executor.executeJob(name, job, customInputs, context)
76
+ }
77
+
78
+ // Load jobs from scripts and config
79
+ await loader.loadJobs(sails.config.quest, jobs)
80
+
81
+ // Start all jobs if autoStart is enabled
82
+ if (sails.config.quest.autoStart) {
83
+ await jobControl.startJobs(null, context)
84
+ }
85
+
86
+ // Expose the Quest API
87
+ sails.quest = {
88
+ // Core job control
89
+ start: (jobNames) => jobControl.startJobs(jobNames, context),
90
+ stop: (jobNames) => jobControl.stopJobs(jobNames, context),
91
+ run: (jobNames, inputs) =>
92
+ jobControl.runJobs(jobNames, inputs, context),
93
+ add: (jobDefs) => {
94
+ const defs = Array.isArray(jobDefs) ? jobDefs : [jobDefs]
95
+ const added = []
96
+ for (const def of defs) {
97
+ const job = typeof def === 'string' ? { name: def } : def
98
+ loader.addJobDefinition(job, jobs, context.config)
99
+ added.push(job.name)
100
+ if (context.config.autoStart) {
101
+ jobControl.scheduleJob(job.name, context)
102
+ }
103
+ }
104
+ return added
105
+ },
106
+ remove: (jobNames) => {
107
+ const names = Array.isArray(jobNames) ? jobNames : [jobNames]
108
+ const removed = []
109
+ for (const name of names) {
110
+ if (loader.removeJob(name, jobs, timers)) {
111
+ removed.push(name)
112
+ }
113
+ }
114
+ return removed
115
+ },
116
+
117
+ // Job information
118
+ jobs: Array.from(jobs.values()),
119
+
120
+ // Additional helpers
121
+ list: () => Array.from(jobs.values()),
122
+ get: (name) => jobs.get(name),
123
+ isRunning: (name) => running.has(name),
124
+
125
+ // Pause/resume
126
+ pause: (name) => jobControl.pauseJob(name, jobs),
127
+ resume: (name) => jobControl.resumeJob(name, jobs)
128
+ }
129
+
130
+ sails.log.info(`Quest started with ${jobs.size} scheduled job(s)`)
131
+ })
132
+
133
+ // Graceful shutdown
134
+ sails.on('lower', async () => {
135
+ sails.log.info('Stopping Quest jobs...')
136
+ await jobControl.stopJobs(null, context)
137
+ })
138
+ }
139
+ }
140
+ }
package/package.json CHANGED
@@ -1,27 +1,57 @@
1
1
  {
2
2
  "name": "sails-hook-quest",
3
- "version": "0.0.0",
4
- "description": "Seamlessly schedule tasks and scripts in your Sails applications.",
5
- "main": "index.js",
3
+ "version": "0.0.2",
4
+ "description": "Elegant job scheduling for Sails.js applications with human-readable intervals, cron expressions, and full Sails context",
5
+ "main": "lib/index.js",
6
6
  "scripts": {
7
- "test": "echo \"Error: no test specified\" && exit 1"
7
+ "lint": "prettier --check .",
8
+ "lint:fix": "prettier --write .",
9
+ "prepare": "husky"
8
10
  },
11
+ "repository": {
12
+ "type": "git",
13
+ "url": "git://github.com/sailscastshq/sails-hook-quest.git"
14
+ },
15
+ "bugs": "https://github.com/sailscastshq/sails-hook-quest/issues",
9
16
  "keywords": [
10
17
  "sails",
11
18
  "sails.js",
19
+ "sailsjs",
12
20
  "hook",
21
+ "sails-hook",
13
22
  "scheduler",
14
- "task automation",
15
- "scheduling",
16
- "automated tasks",
17
- "task management",
18
- "dynamic scheduling",
19
- "Sails.js scripts"
23
+ "job-scheduler",
24
+ "cron",
25
+ "cron-jobs",
26
+ "task-scheduler",
27
+ "background-jobs",
28
+ "job-queue",
29
+ "automated-tasks",
30
+ "scheduled-tasks",
31
+ "human-readable",
32
+ "sails-scripts"
20
33
  ],
21
- "author": "",
22
- "icense": "MIT",
34
+ "author": "Kelvin Omereshone <kelvin@sailscasts.com>",
35
+ "license": "MIT",
23
36
  "sails": {
24
37
  "isHook": true,
25
38
  "hookName": "quest"
39
+ },
40
+ "lint-staged": {
41
+ "**/*": "prettier --write --ignore-unknown"
42
+ },
43
+ "dependencies": {
44
+ "@breejs/later": "^4.2.0",
45
+ "@sailshq/lodash": "^3.10.6",
46
+ "cron-parser": "^5.4.0",
47
+ "human-interval": "^2.0.1",
48
+ "include-all": "^4.0.3"
49
+ },
50
+ "devDependencies": {
51
+ "@commitlint/cli": "^19.5.0",
52
+ "@commitlint/config-conventional": "^19.5.0",
53
+ "husky": "^9.1.6",
54
+ "lint-staged": "^15.2.10",
55
+ "prettier": "^3.3.3"
26
56
  }
27
57
  }
Binary file