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.
- package/.claude/settings.local.json +7 -0
- package/.commitlintrc.js +1 -0
- package/.github/FUNDING.yml +1 -0
- package/.github/workflows/prettier.yml +22 -0
- package/.husky/pre-commit +1 -0
- package/.prettierrc.js +5 -0
- package/LICENSE +21 -0
- package/README.md +350 -0
- package/lib/core/executor.js +184 -0
- package/lib/core/job-control.js +192 -0
- package/lib/core/loader.js +196 -0
- package/lib/core/scheduler.js +203 -0
- package/lib/index.js +140 -0
- package/package.json +42 -12
- package/sails-hook-quest-0.0.0.tgz +0 -0
package/.commitlintrc.js
ADDED
|
@@ -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
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.
|
|
4
|
-
"description": "
|
|
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
|
-
"
|
|
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
|
-
"
|
|
15
|
-
"
|
|
16
|
-
"
|
|
17
|
-
"task
|
|
18
|
-
"
|
|
19
|
-
"
|
|
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
|
-
"
|
|
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
|