sails-hook-quest 0.0.3 → 0.0.5

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/README.md CHANGED
@@ -79,6 +79,9 @@ module.exports.quest = {
79
79
  // Run jobs in console environment (minimal Sails lift)
80
80
  environment: 'console',
81
81
 
82
+ // Retain the final 64 KiB of child output when a job fails
83
+ diagnosticTailBytes: 64 * 1024,
84
+
82
85
  // Define additional jobs in config
83
86
  jobs: [
84
87
  {
@@ -215,10 +218,20 @@ Each event includes:
215
218
  inputs: { /* job inputs */ },
216
219
  timestamp: Date,
217
220
  duration: 1234, // milliseconds (complete/error only)
218
- error: { } // error details (error event only)
221
+ error: {
222
+ message: 'Job "job-name" exited with code 1',
223
+ code: 1,
224
+ stack: 'Error: Job "job-name" exited with code 1\\n...',
225
+ diagnostic: 'The final bounded section of the child process output'
226
+ }
219
227
  }
220
228
  ```
221
229
 
230
+ Job output still streams live to the parent process. On failure, Quest also
231
+ includes a bounded diagnostic tail and a parent stack in the error event so
232
+ observability tools can preserve the useful failure context without buffering
233
+ the complete job log.
234
+
222
235
  ## Console Environment
223
236
 
224
237
  Quest can run jobs in a minimal 'console' environment that skips unnecessary hooks for better performance:
package/jsconfig.json ADDED
@@ -0,0 +1,17 @@
1
+ {
2
+ "compilerOptions": {
3
+ "allowJs": true,
4
+ "checkJs": true,
5
+ "noEmit": true,
6
+ "target": "ES2022",
7
+ "module": "NodeNext",
8
+ "moduleResolution": "NodeNext",
9
+ "lib": ["ES2022"],
10
+ "types": ["node"],
11
+ "maxNodeModuleJsDepth": 0,
12
+ "strict": false,
13
+ "skipLibCheck": true
14
+ },
15
+ "include": ["lib/**/*.js", "typecheck/**/*.js"],
16
+ "exclude": ["node_modules"]
17
+ }
@@ -1,3 +1,5 @@
1
+ // @ts-check
2
+
1
3
  /**
2
4
  * core/executor.js
3
5
  *
@@ -7,21 +9,30 @@
7
9
  const { spawn } = require('child_process')
8
10
  const fs = require('fs')
9
11
  const path = require('path')
12
+ const { getGlobalSails } = require('./global-sails')
13
+
14
+ const DEFAULT_DIAGNOSTIC_TAIL_BYTES = 64 * 1024
15
+
16
+ /** @typedef {import('../types').AnyRecord} AnyRecord */
17
+ /** @typedef {import('../types').QuestExecutableJob} QuestExecutableJob */
18
+ /** @typedef {import('../types').QuestExecutionResult} QuestExecutionResult */
19
+ /** @typedef {import('../types').QuestExecutorContext} QuestExecutorContext */
10
20
 
11
21
  /**
12
22
  * Execute a job via `sails run`
13
23
  * @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
24
+ * @param {QuestExecutableJob} job - Job configuration
25
+ * @param {AnyRecord} [customInputs] - Custom input values
26
+ * @param {QuestExecutorContext} [context] - Execution context with running map, config, etc
27
+ * @returns {Promise<QuestExecutionResult>} Resolves when job completes
18
28
  */
19
29
  async function executeJob(name, job, customInputs = {}, context = {}) {
20
30
  const { running = new Map(), config = {} } = context
21
31
 
22
32
  // Check if job is already running (and overlapping is disabled)
23
33
  if (job.withoutOverlapping && running.has(name)) {
24
- if (global.sails) {
34
+ const sails = getGlobalSails()
35
+ if (sails) {
25
36
  sails.log.warn(`Job "${name}" is already running, skipping...`)
26
37
  }
27
38
  return { skipped: true, reason: 'already_running' }
@@ -29,23 +40,29 @@ async function executeJob(name, job, customInputs = {}, context = {}) {
29
40
 
30
41
  // Don't run if paused
31
42
  if (job.paused) {
32
- if (global.sails) {
43
+ const sails = getGlobalSails()
44
+ if (sails) {
33
45
  sails.log.verbose(`Job "${name}" is paused, skipping...`)
34
46
  }
35
47
  return { skipped: true, reason: 'paused' }
36
48
  }
37
49
 
38
- if (global.sails) {
50
+ const sails = getGlobalSails()
51
+ if (sails) {
39
52
  sails.log.info(`Running job: ${name}`)
40
53
  }
41
54
 
42
55
  running.set(name, Date.now())
43
56
 
44
57
  // Merge inputs with priority: jobInputs < scriptInputs < customInputs
45
- const inputs = { ...job.inputs, ...job.scriptInputs, ...customInputs }
58
+ const inputs = {
59
+ ...(job.inputs || {}),
60
+ ...(job.scriptInputs || {}),
61
+ ...customInputs
62
+ }
46
63
 
47
64
  // Emit job start event
48
- if (global.sails) {
65
+ if (sails) {
49
66
  sails.emit('quest:job:start', {
50
67
  name,
51
68
  inputs,
@@ -74,12 +91,12 @@ async function executeJob(name, job, customInputs = {}, context = {}) {
74
91
  const error = new Error(
75
92
  `Job "${name}" not found. Please check that the script exists at ${scriptsDir}/${name}.js`
76
93
  )
77
- if (global.sails) {
94
+ if (sails) {
78
95
  sails.log.error(error.message)
79
96
  sails.emit('quest:job:error', {
80
97
  name,
81
98
  inputs,
82
- error: { message: error.message },
99
+ error: { message: error.message, stack: error.stack },
83
100
  duration: 0,
84
101
  timestamp: new Date()
85
102
  })
@@ -87,19 +104,37 @@ async function executeJob(name, job, customInputs = {}, context = {}) {
87
104
  return reject(error)
88
105
  }
89
106
 
107
+ const diagnosticTail = createDiagnosticTail(config.diagnosticTailBytes)
90
108
  const child = spawn(sailsPath, args, {
91
109
  cwd,
92
110
  env,
93
- stdio: 'inherit'
111
+ stdio: ['inherit', 'pipe', 'pipe']
94
112
  })
95
113
 
96
- child.on('exit', (code) => {
97
- const startTime = running.get(name)
114
+ teeChildOutput(
115
+ child.stdout,
116
+ context.stdout || process.stdout,
117
+ diagnosticTail
118
+ )
119
+ teeChildOutput(
120
+ child.stderr,
121
+ context.stderr || process.stderr,
122
+ diagnosticTail
123
+ )
124
+
125
+ let settled = false
126
+
127
+ child.on('close', (code) => {
128
+ if (settled) {
129
+ return
130
+ }
131
+ settled = true
132
+ const startTime = /** @type {number} */ (running.get(name))
98
133
  const duration = Date.now() - startTime
99
134
  running.delete(name)
100
135
 
101
136
  if (code === 0) {
102
- if (global.sails) {
137
+ if (sails) {
103
138
  sails.log.info(`Job "${name}" completed successfully`)
104
139
 
105
140
  // Emit success event
@@ -115,7 +150,7 @@ async function executeJob(name, job, customInputs = {}, context = {}) {
115
150
  } else {
116
151
  const error = new Error(`Job "${name}" exited with code ${code}`)
117
152
 
118
- if (global.sails) {
153
+ if (sails) {
119
154
  sails.log.error(error)
120
155
 
121
156
  // Emit error event
@@ -124,7 +159,9 @@ async function executeJob(name, job, customInputs = {}, context = {}) {
124
159
  inputs,
125
160
  error: {
126
161
  message: error.message,
127
- code
162
+ code,
163
+ stack: error.stack,
164
+ diagnostic: diagnosticTail.value()
128
165
  },
129
166
  duration,
130
167
  timestamp: new Date()
@@ -136,11 +173,15 @@ async function executeJob(name, job, customInputs = {}, context = {}) {
136
173
  })
137
174
 
138
175
  child.on('error', (err) => {
176
+ if (settled) {
177
+ return
178
+ }
179
+ settled = true
139
180
  const startTime = running.get(name) || Date.now()
140
181
  const duration = Date.now() - startTime
141
182
  running.delete(name)
142
183
 
143
- if (global.sails) {
184
+ if (sails) {
144
185
  sails.log.error(`Job "${name}" failed to start:`, err)
145
186
 
146
187
  // Emit error event
@@ -149,7 +190,8 @@ async function executeJob(name, job, customInputs = {}, context = {}) {
149
190
  inputs,
150
191
  error: {
151
192
  message: err.message,
152
- stack: err.stack
193
+ stack: err.stack,
194
+ diagnostic: diagnosticTail.value()
153
195
  },
154
196
  duration,
155
197
  timestamp: new Date()
@@ -161,11 +203,57 @@ async function executeJob(name, job, customInputs = {}, context = {}) {
161
203
  })
162
204
  }
163
205
 
206
+ /**
207
+ * Retain only the end of child output so a failed job can explain itself
208
+ * without buffering an unbounded process log.
209
+ *
210
+ * @param {number} [requestedMaxBytes]
211
+ */
212
+ function createDiagnosticTail(requestedMaxBytes) {
213
+ const maxBytes =
214
+ Number.isFinite(requestedMaxBytes) && requestedMaxBytes > 0
215
+ ? Math.floor(requestedMaxBytes)
216
+ : DEFAULT_DIAGNOSTIC_TAIL_BYTES
217
+ let buffer = Buffer.alloc(0)
218
+
219
+ return {
220
+ /** @param {Buffer|string} chunk */
221
+ append(chunk) {
222
+ const incoming = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)
223
+ buffer = Buffer.concat([buffer, incoming])
224
+ if (buffer.length > maxBytes) {
225
+ buffer = buffer.subarray(buffer.length - maxBytes)
226
+ }
227
+ },
228
+ value() {
229
+ return buffer.toString('utf8').trim()
230
+ }
231
+ }
232
+ }
233
+
234
+ /**
235
+ * Mirror child output to the parent while retaining a bounded diagnostic tail.
236
+ *
237
+ * @param {NodeJS.ReadableStream|null} source
238
+ * @param {NodeJS.WritableStream} destination
239
+ * @param {ReturnType<typeof createDiagnosticTail>} diagnosticTail
240
+ */
241
+ function teeChildOutput(source, destination, diagnosticTail) {
242
+ if (!source) {
243
+ return
244
+ }
245
+
246
+ source.on('data', (chunk) => {
247
+ diagnosticTail.append(chunk)
248
+ destination.write(chunk)
249
+ })
250
+ }
251
+
164
252
  /**
165
253
  * Build command arguments for sails run
166
254
  * @param {String} scriptName - Name of the script
167
- * @param {Object} inputs - Input values
168
- * @returns {Array} Command arguments
255
+ * @param {AnyRecord} [inputs] - Input values
256
+ * @returns {string[]} Command arguments
169
257
  */
170
258
  function buildCommandArgs(scriptName, inputs = {}) {
171
259
  const args = ['run', scriptName]
@@ -186,5 +274,6 @@ function buildCommandArgs(scriptName, inputs = {}) {
186
274
 
187
275
  module.exports = {
188
276
  executeJob,
189
- buildCommandArgs
277
+ buildCommandArgs,
278
+ createDiagnosticTail
190
279
  }
@@ -0,0 +1,16 @@
1
+ // @ts-check
2
+
3
+ /** @typedef {import('../types').QuestSailsApp} QuestSailsApp */
4
+
5
+ /**
6
+ * Resolve the ambient Sails app when core modules run outside the hook factory.
7
+ *
8
+ * @returns {QuestSailsApp | undefined}
9
+ */
10
+ function getGlobalSails() {
11
+ return /** @type {{ sails?: QuestSailsApp }} */ (global).sails
12
+ }
13
+
14
+ module.exports = {
15
+ getGlobalSails
16
+ }
@@ -1,25 +1,46 @@
1
+ // @ts-check
2
+
1
3
  /**
2
4
  * core/job-control.js
3
5
  *
4
6
  * Functions for controlling job lifecycle (start, stop, pause, resume)
5
7
  */
6
8
 
9
+ const { getGlobalSails } = require('./global-sails')
10
+
11
+ /** @typedef {import('../types').AnyRecord} AnyRecord */
12
+ /** @typedef {import('../types').QuestExecutionResult} QuestExecutionResult */
13
+ /** @typedef {import('../types').QuestJobNameInput} QuestJobNameInput */
14
+ /** @typedef {import('../types').QuestJobsMap} QuestJobsMap */
15
+ /** @typedef {import('../types').QuestRunContext} QuestRunContext */
16
+ /** @typedef {import('../types').QuestScheduleContext} QuestScheduleContext */
17
+ /** @typedef {import('../types').QuestStartContext} QuestStartContext */
18
+ /** @typedef {import('../types').QuestStopContext} QuestStopContext */
19
+
20
+ // setTimeout uses a 32-bit signed integer internally.
21
+ // Delays larger than this overflow and fire immediately (~1ms), causing infinite loops.
22
+ const MAX_SAFE_TIMEOUT = 2_147_483_647 // 2^31 - 1, ~24.8 days
23
+
7
24
  /**
8
25
  * Schedule a job to run at its next scheduled time
9
26
  * @param {String} name - Job name
10
- * @param {Object} context - Context with jobs, timers maps and helper functions
27
+ * @param {QuestScheduleContext} [context] - Context with jobs, timers maps and helper functions
11
28
  */
12
29
  function scheduleJob(name, context = {}) {
13
- const {
14
- jobs = new Map(),
15
- timers = new Map(),
16
- getNextRunTime,
17
- executeJob
18
- } = context
30
+ const { jobs = new Map(), timers = new Map() } = context
31
+ const getNextRunTime =
32
+ /** @type {NonNullable<QuestScheduleContext['getNextRunTime']>} */ (
33
+ context.getNextRunTime
34
+ )
35
+ const executeJob =
36
+ /** @type {NonNullable<QuestScheduleContext['executeJob']>} */ (
37
+ context.executeJob
38
+ )
19
39
 
20
40
  const job = jobs.get(name)
21
41
  if (!job) {
22
- if (global.sails) {
42
+ const sails = getGlobalSails()
43
+ if (sails) {
23
44
  sails.log.warn(`Job "${name}" not found in jobs Map`)
24
45
  }
25
46
  return
@@ -31,7 +52,8 @@ function scheduleJob(name, context = {}) {
31
52
  // Get the next run time
32
53
  const nextRun = getNextRunTime(job)
33
54
  if (!nextRun) {
34
- if (global.sails) {
55
+ const sails = getGlobalSails()
56
+ if (sails) {
35
57
  sails.log.warn(
36
58
  `Job "${name}" has no valid schedule (interval: ${job.interval}, cron: ${job.cron}, timeout: ${job.timeout})`
37
59
  )
@@ -44,7 +66,8 @@ function scheduleJob(name, context = {}) {
44
66
  // If delay is negative (past time), run immediately
45
67
  if (delay <= 0) {
46
68
  executeJob(name).catch((err) => {
47
- if (global.sails) {
69
+ const sails = getGlobalSails()
70
+ if (sails) {
48
71
  sails.log.error(`Error running job "${name}":`, err)
49
72
  }
50
73
  })
@@ -56,10 +79,29 @@ function scheduleJob(name, context = {}) {
56
79
  return
57
80
  }
58
81
 
82
+ // If delay exceeds the 32-bit setTimeout max, set an intermediate timer
83
+ // that rechecks when we're closer to the target time
84
+ if (delay > MAX_SAFE_TIMEOUT) {
85
+ const timer = setTimeout(() => {
86
+ scheduleJob(name, context)
87
+ }, MAX_SAFE_TIMEOUT)
88
+
89
+ timers.set(name, timer)
90
+
91
+ const sails = getGlobalSails()
92
+ if (sails) {
93
+ sails.log.verbose(
94
+ `Job "${name}" scheduled for ${nextRun.toISOString()} (delay exceeds 24.8d, re-checking later)`
95
+ )
96
+ }
97
+ return
98
+ }
99
+
59
100
  // Set timer for the next execution
60
101
  const timer = setTimeout(() => {
61
102
  executeJob(name).catch((err) => {
62
- if (global.sails) {
103
+ const sails = getGlobalSails()
104
+ if (sails) {
63
105
  sails.log.error(`Error running job "${name}":`, err)
64
106
  }
65
107
  })
@@ -72,7 +114,8 @@ function scheduleJob(name, context = {}) {
72
114
 
73
115
  timers.set(name, timer)
74
116
 
75
- if (global.sails) {
117
+ const sails = getGlobalSails()
118
+ if (sails) {
76
119
  sails.log.verbose(`Job "${name}" scheduled for ${nextRun.toISOString()}`)
77
120
  }
78
121
  }
@@ -80,7 +123,7 @@ function scheduleJob(name, context = {}) {
80
123
  /**
81
124
  * Stop a single job
82
125
  * @param {String} name - Job name
83
- * @param {Object} context - Context with timers map
126
+ * @param {QuestStopContext} [context] - Context with timers map
84
127
  */
85
128
  function stopJob(name, context = {}) {
86
129
  const { timers = new Map() } = context
@@ -90,7 +133,8 @@ function stopJob(name, context = {}) {
90
133
  clearTimeout(timer)
91
134
  clearInterval(timer)
92
135
  timers.delete(name)
93
- if (global.sails) {
136
+ const sails = getGlobalSails()
137
+ if (sails) {
94
138
  sails.log.verbose(`Job "${name}" stopped`)
95
139
  }
96
140
  }
@@ -98,11 +142,16 @@ function stopJob(name, context = {}) {
98
142
 
99
143
  /**
100
144
  * Start scheduling jobs
101
- * @param {String|Array} jobNames - Job names to start (optional)
102
- * @param {Object} context - Context with jobs map and scheduleJob function
145
+ * @param {QuestJobNameInput} jobNames - Job names to start (optional)
146
+ * @param {QuestStartContext} [context] - Context with jobs map and scheduleJob function
147
+ * @returns {Promise<void>}
103
148
  */
104
149
  async function startJobs(jobNames, context = {}) {
105
- const { jobs = new Map(), scheduleJob } = context
150
+ const { jobs = new Map() } = context
151
+ const scheduleJob =
152
+ /** @type {NonNullable<QuestStartContext['scheduleJob']>} */ (
153
+ context.scheduleJob
154
+ )
106
155
 
107
156
  const names = !jobNames
108
157
  ? Array.from(jobs.keys())
@@ -117,8 +166,9 @@ async function startJobs(jobNames, context = {}) {
117
166
 
118
167
  /**
119
168
  * Stop scheduling jobs
120
- * @param {String|Array} jobNames - Job names to stop (optional)
121
- * @param {Object} context - Context with jobs and timers maps
169
+ * @param {QuestJobNameInput} jobNames - Job names to stop (optional)
170
+ * @param {QuestStopContext} [context] - Context with jobs and timers maps
171
+ * @returns {void}
122
172
  */
123
173
  function stopJobs(jobNames, context = {}) {
124
174
  const { jobs = new Map(), timers = new Map() } = context
@@ -136,12 +186,16 @@ function stopJobs(jobNames, context = {}) {
136
186
 
137
187
  /**
138
188
  * 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
189
+ * @param {QuestJobNameInput} jobNames - Job names to run
190
+ * @param {AnyRecord} [inputs] - Custom inputs
191
+ * @param {QuestRunContext} [context] - Context with executeJob function
192
+ * @returns {Promise<QuestExecutionResult[]>}
142
193
  */
143
194
  async function runJobs(jobNames, inputs, context = {}) {
144
- const { jobs = new Map(), executeJob } = context
195
+ const { jobs = new Map() } = context
196
+ const executeJob = /** @type {NonNullable<QuestRunContext['executeJob']>} */ (
197
+ context.executeJob
198
+ )
145
199
 
146
200
  const names = !jobNames
147
201
  ? Array.from(jobs.keys())
@@ -156,7 +210,8 @@ async function runJobs(jobNames, inputs, context = {}) {
156
210
  /**
157
211
  * Pause a job
158
212
  * @param {String} name - Job name
159
- * @param {Map} jobs - Jobs map
213
+ * @param {QuestJobsMap} [jobs] - Jobs map
214
+ * @returns {boolean}
160
215
  */
161
216
  function pauseJob(name, jobs = new Map()) {
162
217
  const job = jobs.get(name)
@@ -170,7 +225,8 @@ function pauseJob(name, jobs = new Map()) {
170
225
  /**
171
226
  * Resume a job
172
227
  * @param {String} name - Job name
173
- * @param {Map} jobs - Jobs map
228
+ * @param {QuestJobsMap} [jobs] - Jobs map
229
+ * @returns {boolean}
174
230
  */
175
231
  function resumeJob(name, jobs = new Map()) {
176
232
  const job = jobs.get(name)
@@ -1,3 +1,5 @@
1
+ // @ts-check
2
+
1
3
  /**
2
4
  * core/loader.js
3
5
  *
@@ -6,13 +8,24 @@
6
8
 
7
9
  const path = require('path')
8
10
  const includeAll = require('include-all')
11
+ const { getGlobalSails } = require('./global-sails')
12
+
13
+ /** @typedef {import('../types').AnyRecord} AnyRecord */
14
+ /** @typedef {import('../types').QuestConfig} QuestConfig */
15
+ /** @typedef {import('../types').QuestJob} QuestJob */
16
+ /** @typedef {import('../types').QuestJobDefinition} QuestJobDefinition */
17
+ /** @typedef {import('../types').QuestJobsMap} QuestJobsMap */
18
+ /** @typedef {import('../types').QuestMachineInputs} QuestMachineInputs */
19
+ /** @typedef {import('../types').QuestScriptDefinition} QuestScriptDefinition */
20
+ /** @typedef {import('../types').QuestTimersMap} QuestTimersMap */
9
21
 
10
22
  /**
11
23
  * 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
24
+ * @param {QuestMachineInputs | null | undefined} inputs - Script's inputs definition (Sails machine format)
25
+ * @returns {AnyRecord} Object with input names and their defaultsTo values
14
26
  */
15
27
  function extractScriptInputDefaults(inputs) {
28
+ /** @type {AnyRecord} */
16
29
  const defaults = {}
17
30
  if (!inputs || typeof inputs !== 'object') return defaults
18
31
 
@@ -26,9 +39,9 @@ function extractScriptInputDefaults(inputs) {
26
39
 
27
40
  /**
28
41
  * 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
42
+ * @param {QuestConfig} config - Quest configuration
43
+ * @param {QuestJobsMap} [jobs] - Jobs map to populate
44
+ * @returns {Promise<QuestJobsMap>} Populated jobs map
32
45
  */
33
46
  async function loadJobs(config, jobs = new Map()) {
34
47
  // First, load scripts from the scripts directory
@@ -36,17 +49,21 @@ async function loadJobs(config, jobs = new Map()) {
36
49
  const appPath = config.appPath || process.cwd()
37
50
  const fullPath = path.resolve(appPath, scriptsDir)
38
51
 
52
+ /** @type {Record<string, QuestScriptDefinition>} */
39
53
  let scripts = {}
40
54
 
41
55
  try {
42
- scripts = includeAll({
43
- dirname: fullPath,
44
- filter: /(.+)\.js$/,
45
- excludeDirs: /^\.(git|svn)$/,
46
- flatten: true
47
- })
56
+ scripts = /** @type {Record<string, QuestScriptDefinition>} */ (
57
+ includeAll({
58
+ dirname: fullPath,
59
+ filter: /(.+)\.js$/,
60
+ excludeDirs: /^\.(git|svn)$/,
61
+ flatten: true
62
+ })
63
+ )
48
64
  } catch (e) {
49
- if (global.sails) {
65
+ const sails = getGlobalSails()
66
+ if (sails) {
50
67
  sails.log.verbose('No scripts directory found, skipping script jobs')
51
68
  }
52
69
  }
@@ -57,18 +74,22 @@ async function loadJobs(config, jobs = new Map()) {
57
74
 
58
75
  for (const jobDef of config.jobs) {
59
76
  // Handle string shorthand (just job name)
77
+ /** @type {QuestJobDefinition} */
60
78
  const job = typeof jobDef === 'string' ? { name: jobDef } : jobDef
79
+ const jobName = job.name
61
80
 
62
81
  // Check for duplicate job names in config
63
- if (configJobNames.has(job.name)) {
82
+ if (jobName && configJobNames.has(jobName)) {
64
83
  throw new Error(
65
- `Duplicate job name "${job.name}" in config/quest.js. Each job must have a unique name.`
84
+ `Duplicate job name "${jobName}" in config/quest.js. Each job must have a unique name.`
66
85
  )
67
86
  }
68
- configJobNames.add(job.name)
87
+ if (jobName) {
88
+ configJobNames.add(jobName)
89
+ }
69
90
 
70
91
  // Try to get script inputs if the script exists
71
- const scriptDef = scripts[job.name]
92
+ const scriptDef = jobName ? scripts[jobName] : undefined
72
93
  if (scriptDef && !job.scriptInputs) {
73
94
  job.scriptInputs = extractScriptInputDefaults(scriptDef.inputs)
74
95
  }
@@ -92,13 +113,14 @@ async function loadJobs(config, jobs = new Map()) {
92
113
  const existingJob = jobs.get(jobName)
93
114
 
94
115
  // Merge: config as base, script quest config takes priority
116
+ /** @type {QuestJobDefinition} */
95
117
  const jobDef = {
96
118
  name: jobName,
97
119
  friendlyName: scriptDef.friendlyName,
98
120
  description: scriptDef.description,
99
121
  // Preserve config's withoutOverlapping if script doesn't specify
100
122
  withoutOverlapping: existingJob?.withoutOverlapping,
101
- inputs: { ...existingJob?.inputs, ...questConfig.inputs },
123
+ inputs: { ...(existingJob?.inputs || {}), ...(questConfig.inputs || {}) },
102
124
  ...questConfig,
103
125
  scriptInputs
104
126
  }
@@ -111,21 +133,23 @@ async function loadJobs(config, jobs = new Map()) {
111
133
 
112
134
  /**
113
135
  * 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
136
+ * @param {QuestJobDefinition} jobDef - Job definition
137
+ * @param {QuestJobsMap} [jobs] - Jobs map
138
+ * @param {QuestConfig} [config] - Quest configuration
139
+ * @returns {QuestJob | null} Normalized job
118
140
  */
119
141
  function addJobDefinition(jobDef, jobs = new Map(), config = {}) {
120
142
  const name = jobDef.name
121
143
  if (!name) {
122
- if (global.sails) {
144
+ const sails = getGlobalSails()
145
+ if (sails) {
123
146
  sails.log.warn('Job definition missing name, skipping:', jobDef)
124
147
  }
125
148
  return null
126
149
  }
127
150
 
128
151
  // Parse and normalize the job definition
152
+ /** @type {QuestJob} */
129
153
  const job = {
130
154
  name,
131
155
  friendlyName: jobDef.friendlyName || name,
@@ -151,7 +175,8 @@ function addJobDefinition(jobDef, jobs = new Map(), config = {}) {
151
175
 
152
176
  jobs.set(name, job)
153
177
 
154
- if (global.sails) {
178
+ const sails = getGlobalSails()
179
+ if (sails) {
155
180
  const schedule = {}
156
181
  if (job.interval !== undefined) schedule.interval = job.interval
157
182
  if (job.cron !== undefined) schedule.cron = job.cron
@@ -165,8 +190,8 @@ function addJobDefinition(jobDef, jobs = new Map(), config = {}) {
165
190
  /**
166
191
  * Remove a job from the jobs map
167
192
  * @param {String} name - Job name
168
- * @param {Map} jobs - Jobs map
169
- * @param {Map} timers - Timers map
193
+ * @param {QuestJobsMap} [jobs] - Jobs map
194
+ * @param {QuestTimersMap} [timers] - Timers map
170
195
  * @returns {Boolean} Success
171
196
  */
172
197
  function removeJob(name, jobs = new Map(), timers = new Map()) {
@@ -181,7 +206,8 @@ function removeJob(name, jobs = new Map(), timers = new Map()) {
181
206
  // Remove from jobs map
182
207
  const existed = jobs.delete(name)
183
208
 
184
- if (existed && global.sails) {
209
+ const sails = getGlobalSails()
210
+ if (existed && sails) {
185
211
  sails.log.verbose(`Job "${name}" removed`)
186
212
  }
187
213
 
@@ -1,3 +1,5 @@
1
+ // @ts-check
2
+
1
3
  /**
2
4
  * core/scheduler.js
3
5
  *
@@ -7,11 +9,16 @@
7
9
  const later = require('@breejs/later')
8
10
  const humanInterval = require('human-interval')
9
11
  const { CronExpressionParser } = require('cron-parser')
12
+ const { getGlobalSails } = require('./global-sails')
13
+
14
+ /** @typedef {import('../types').QuestConfig} QuestConfig */
15
+ /** @typedef {import('../types').QuestJobDefinition} QuestJobDefinition */
16
+ /** @typedef {import('../types').QuestTimeout} QuestTimeout */
10
17
 
11
18
  /**
12
19
  * Parse various schedule formats and return next run time
13
- * @param {Object} job - Job configuration
14
- * @param {Object} config - Quest configuration
20
+ * @param {QuestJobDefinition} job - Job configuration
21
+ * @param {QuestConfig} [config] - Quest configuration
15
22
  * @returns {Date|null} Next run time or null if invalid
16
23
  */
17
24
  function getNextRunTime(job, config = {}) {
@@ -36,10 +43,11 @@ function getNextRunTime(job, config = {}) {
36
43
  const interval = CronExpressionParser.parse(job.cron, options)
37
44
  return interval.next().toDate()
38
45
  } catch (err) {
39
- if (global.sails) {
46
+ const sails = getGlobalSails()
47
+ if (sails) {
40
48
  sails.log.error(
41
49
  `Invalid cron expression for job "${job.name}": ${job.cron}`,
42
- err.message
50
+ err instanceof Error ? err.message : err
43
51
  )
44
52
  }
45
53
  return null
@@ -51,7 +59,8 @@ function getNextRunTime(job, config = {}) {
51
59
  const nextTime = parseInterval(job.interval, now)
52
60
  if (nextTime) return nextTime
53
61
 
54
- if (global.sails) {
62
+ const sails = getGlobalSails()
63
+ if (sails) {
55
64
  sails.log.error(`Invalid interval for job "${job.name}": ${job.interval}`)
56
65
  }
57
66
  return null
@@ -95,6 +104,7 @@ function parseInterval(intervalStr, fromDate = new Date()) {
95
104
  if (everyMatch) {
96
105
  const amount = parseInt(everyMatch[1])
97
106
  const unit = everyMatch[2].replace(/s$/, '') // Remove plural 's'
107
+ /** @type {Record<string, number>} */
98
108
  const msMap = {
99
109
  second: 1000,
100
110
  minute: 60000,
@@ -138,7 +148,7 @@ function parseInterval(intervalStr, fromDate = new Date()) {
138
148
 
139
149
  /**
140
150
  * Parse a timeout value into a Date
141
- * @param {String|Number} timeout - Timeout value
151
+ * @param {QuestTimeout} timeout - Timeout value
142
152
  * @param {Date} fromDate - Calculate from this date
143
153
  * @returns {Date|null} Next run time or null
144
154
  */
package/lib/index.js CHANGED
@@ -1,3 +1,5 @@
1
+ // @ts-check
2
+
1
3
  /**
2
4
  * sails-hook-quest
3
5
  *
@@ -10,12 +12,34 @@ const executor = require('./core/executor')
10
12
  const loader = require('./core/loader')
11
13
  const jobControl = require('./core/job-control')
12
14
 
13
- module.exports = function defineQuestHook(sails) {
15
+ /** @typedef {import('./types').AnyRecord} AnyRecord */
16
+ /** @typedef {import('./types').QuestApi} QuestApi */
17
+ /** @typedef {import('./types').QuestConfig} QuestConfig */
18
+ /** @typedef {import('./types').QuestContext} QuestContext */
19
+ /** @typedef {import('./types').QuestExecutableJob} QuestExecutableJob */
20
+ /** @typedef {import('./types').QuestJobsMap} QuestJobsMap */
21
+ /** @typedef {import('./types').QuestNamedJobDefinition} QuestNamedJobDefinition */
22
+ /** @typedef {import('./types').QuestRunningMap} QuestRunningMap */
23
+ /** @typedef {import('./types').QuestSailsApp} QuestSailsApp */
24
+ /** @typedef {import('./types').QuestSailsHook} QuestSailsHook */
25
+ /** @typedef {import('./types').QuestTimersMap} QuestTimersMap */
26
+
27
+ /**
28
+ * Sails hook factory.
29
+ *
30
+ * @param {QuestSailsApp} sails
31
+ * @returns {QuestSailsHook}
32
+ */
33
+ function defineQuestHook(sails) {
34
+ /** @type {QuestJobsMap} */
14
35
  const jobs = new Map()
36
+ /** @type {QuestTimersMap} */
15
37
  const timers = new Map()
38
+ /** @type {QuestRunningMap} */
16
39
  const running = new Map()
17
40
 
18
41
  // Create context object that will be passed to modules
42
+ /** @type {QuestContext} */
19
43
  const context = {
20
44
  jobs,
21
45
  timers,
@@ -47,6 +71,9 @@ module.exports = function defineQuestHook(sails) {
47
71
  // Directory containing job scripts
48
72
  scriptsDir: 'scripts',
49
73
 
74
+ // Maximum child output retained on failure (live output remains unbounded)
75
+ diagnosticTailBytes: 64 * 1024,
76
+
50
77
  // Jobs defined in config
51
78
  jobs: []
52
79
  }
@@ -56,15 +83,18 @@ module.exports = function defineQuestHook(sails) {
56
83
  sails.log.info('Initializing Quest job scheduler')
57
84
 
58
85
  sails.after('hook:orm:loaded', async () => {
86
+ const questConfig = sails.config.quest
87
+
59
88
  // Set up context with config
60
- context.config = sails.config.quest
89
+ context.config = questConfig
61
90
  context.getNextRunTime = (job) =>
62
- scheduler.getNextRunTime(job, sails.config.quest)
91
+ scheduler.getNextRunTime(job, questConfig)
63
92
  context.scheduleJob = (name) => jobControl.scheduleJob(name, context)
64
93
  context.executeJob = (name, customInputs) => {
65
94
  const job = jobs.get(name)
66
95
  if (!job) {
67
96
  // Try to run as a regular script without quest config
97
+ /** @type {QuestExecutableJob} */
68
98
  const minimalJob = {
69
99
  name,
70
100
  withoutOverlapping: false,
@@ -76,15 +106,16 @@ module.exports = function defineQuestHook(sails) {
76
106
  }
77
107
 
78
108
  // Load jobs from scripts and config
79
- await loader.loadJobs(sails.config.quest, jobs)
109
+ await loader.loadJobs(questConfig, jobs)
80
110
 
81
111
  // Start all jobs if autoStart is enabled
82
- if (sails.config.quest.autoStart) {
112
+ if (questConfig.autoStart) {
83
113
  await jobControl.startJobs(null, context)
84
114
  }
85
115
 
86
116
  // Expose the Quest API
87
- sails.quest = {
117
+ /** @type {QuestApi} */
118
+ const questApi = {
88
119
  // Core job control
89
120
  start: (jobNames) => jobControl.startJobs(jobNames, context),
90
121
  stop: (jobNames) => jobControl.stopJobs(jobNames, context),
@@ -92,12 +123,14 @@ module.exports = function defineQuestHook(sails) {
92
123
  jobControl.runJobs(jobNames, inputs, context),
93
124
  add: (jobDefs) => {
94
125
  const defs = Array.isArray(jobDefs) ? jobDefs : [jobDefs]
126
+ /** @type {string[]} */
95
127
  const added = []
96
128
  for (const def of defs) {
129
+ /** @type {QuestNamedJobDefinition} */
97
130
  const job = typeof def === 'string' ? { name: def } : def
98
- loader.addJobDefinition(job, jobs, context.config)
131
+ loader.addJobDefinition(job, jobs, context.config || {})
99
132
  added.push(job.name)
100
- if (context.config.autoStart) {
133
+ if (context.config?.autoStart) {
101
134
  jobControl.scheduleJob(job.name, context)
102
135
  }
103
136
  }
@@ -127,6 +160,7 @@ module.exports = function defineQuestHook(sails) {
127
160
  resume: (name) => jobControl.resumeJob(name, jobs)
128
161
  }
129
162
 
163
+ sails.quest = questApi
130
164
  sails.log.info(`Quest started with ${jobs.size} scheduled job(s)`)
131
165
  })
132
166
 
@@ -138,3 +172,5 @@ module.exports = function defineQuestHook(sails) {
138
172
  }
139
173
  }
140
174
  }
175
+
176
+ module.exports = defineQuestHook
package/lib/types.js ADDED
@@ -0,0 +1,231 @@
1
+ /**
2
+ * Shared TypeScript-consumable JSDoc typedefs for Quest's public API.
3
+ *
4
+ * These comments are intentionally colocated with the JavaScript source so JSDoc
5
+ * stays the source of truth for editor autocomplete and local type checks.
6
+ *
7
+ * @typedef {Record<string, any>} AnyRecord
8
+ *
9
+ * @typedef {string | number | boolean | null} JsonPrimitive
10
+ *
11
+ * @typedef {JsonPrimitive | any[] | AnyRecord} JsonValue
12
+ *
13
+ * @typedef {string | number} QuestInterval
14
+ *
15
+ * @typedef {string | number | false} QuestTimeout
16
+ *
17
+ * @typedef {string | number | Date} QuestDateInput
18
+ *
19
+ * @typedef {Record<string, any>} QuestCronOptions
20
+ *
21
+ * @typedef {{
22
+ * defaultsTo?: any,
23
+ * type?: string,
24
+ * description?: string,
25
+ * required?: boolean,
26
+ * allowNull?: boolean,
27
+ * [key: string]: any,
28
+ * }} QuestMachineInputDefinition
29
+ *
30
+ * @typedef {Record<string, QuestMachineInputDefinition>} QuestMachineInputs
31
+ *
32
+ * @typedef {{
33
+ * name?: string,
34
+ * friendlyName?: string,
35
+ * description?: string,
36
+ * interval?: QuestInterval,
37
+ * timeout?: QuestTimeout,
38
+ * cron?: string,
39
+ * cronOptions?: QuestCronOptions,
40
+ * date?: QuestDateInput,
41
+ * timezone?: string,
42
+ * inputs?: AnyRecord,
43
+ * scriptInputs?: AnyRecord,
44
+ * withoutOverlapping?: boolean,
45
+ * paused?: boolean,
46
+ * [key: string]: any,
47
+ * }} QuestJobDefinition
48
+ *
49
+ * @typedef {QuestJobDefinition & { name: string }} QuestNamedJobDefinition
50
+ *
51
+ * @typedef {string | QuestNamedJobDefinition} QuestJobDefinitionInput
52
+ *
53
+ * @typedef {{
54
+ * name: string,
55
+ * friendlyName: string,
56
+ * description?: string,
57
+ * interval?: QuestInterval,
58
+ * timeout?: QuestTimeout,
59
+ * cron?: string,
60
+ * cronOptions?: QuestCronOptions,
61
+ * date?: QuestDateInput,
62
+ * timezone?: string,
63
+ * inputs: AnyRecord,
64
+ * scriptInputs: AnyRecord,
65
+ * paused: boolean,
66
+ * withoutOverlapping: boolean,
67
+ * }} QuestJob
68
+ *
69
+ * @typedef {{
70
+ * name: string,
71
+ * inputs?: AnyRecord,
72
+ * scriptInputs?: AnyRecord,
73
+ * paused?: boolean,
74
+ * withoutOverlapping?: boolean,
75
+ * }} QuestExecutableJob
76
+ *
77
+ * @typedef {{
78
+ * friendlyName?: string,
79
+ * description?: string,
80
+ * quest?: QuestJobDefinition,
81
+ * inputs?: QuestMachineInputs,
82
+ * fn?: Function,
83
+ * [key: string]: any,
84
+ * }} QuestScriptDefinition
85
+ *
86
+ * @typedef {string | string[] | null | undefined} QuestJobNameInput
87
+ *
88
+ * @typedef {{
89
+ * autoStart?: boolean,
90
+ * timezone?: string,
91
+ * withoutOverlapping?: boolean,
92
+ * sailsPath?: string,
93
+ * environment?: string,
94
+ * scriptsDir?: string,
95
+ * appPath?: string,
96
+ * diagnosticTailBytes?: number,
97
+ * jobs?: QuestJobDefinitionInput[],
98
+ * [key: string]: any,
99
+ * }} QuestConfig
100
+ *
101
+ * @typedef {{
102
+ * skipped: true,
103
+ * reason: 'already_running' | 'paused',
104
+ * }} QuestSkippedResult
105
+ *
106
+ * @typedef {{
107
+ * success: true,
108
+ * duration: number,
109
+ * }} QuestSuccessResult
110
+ *
111
+ * @typedef {QuestSkippedResult | QuestSuccessResult} QuestExecutionResult
112
+ *
113
+ * @typedef {{
114
+ * name: string,
115
+ * inputs: AnyRecord,
116
+ * timestamp: Date,
117
+ * }} QuestJobStartEvent
118
+ *
119
+ * @typedef {{
120
+ * name: string,
121
+ * inputs: AnyRecord,
122
+ * duration: number,
123
+ * timestamp: Date,
124
+ * }} QuestJobCompleteEvent
125
+ *
126
+ * @typedef {{
127
+ * message: string,
128
+ * code?: number | null,
129
+ * stack?: string,
130
+ * diagnostic?: string,
131
+ * }} QuestJobErrorDetails
132
+ *
133
+ * @typedef {{
134
+ * name: string,
135
+ * inputs: AnyRecord,
136
+ * error: QuestJobErrorDetails,
137
+ * duration: number,
138
+ * timestamp: Date,
139
+ * }} QuestJobErrorEvent
140
+ *
141
+ * @typedef {{
142
+ * start(jobNames?: QuestJobNameInput): Promise<void>,
143
+ * stop(jobNames?: QuestJobNameInput): void,
144
+ * run(jobNames?: QuestJobNameInput, inputs?: AnyRecord): Promise<QuestExecutionResult[]>,
145
+ * add(jobDefs: QuestJobDefinitionInput | QuestJobDefinitionInput[]): string[],
146
+ * remove(jobNames: QuestJobNameInput): string[],
147
+ * jobs: QuestJob[],
148
+ * list(): QuestJob[],
149
+ * get(name: string): QuestJob | undefined,
150
+ * isRunning(name: string): boolean,
151
+ * pause(name: string): boolean,
152
+ * resume(name: string): boolean,
153
+ * }} QuestApi
154
+ *
155
+ * @typedef {Map<string, QuestJob>} QuestJobsMap
156
+ *
157
+ * @typedef {Map<string, ReturnType<typeof setTimeout>>} QuestTimersMap
158
+ *
159
+ * @typedef {Map<string, number>} QuestRunningMap
160
+ *
161
+ * @typedef {{
162
+ * jobs: QuestJobsMap,
163
+ * timers: QuestTimersMap,
164
+ * running: QuestRunningMap,
165
+ * config: QuestConfig | null,
166
+ * scheduleJob: ((name: string) => void) | null,
167
+ * executeJob: ((name: string, customInputs?: AnyRecord) => Promise<QuestExecutionResult>) | null,
168
+ * getNextRunTime: ((job: QuestJob) => Date | null) | null,
169
+ * }} QuestContext
170
+ *
171
+ * @typedef {{
172
+ * jobs?: QuestJobsMap,
173
+ * timers?: QuestTimersMap,
174
+ * getNextRunTime?: (job: QuestJob) => Date | null,
175
+ * executeJob?: (name: string, customInputs?: AnyRecord) => Promise<QuestExecutionResult>,
176
+ * }} QuestScheduleContext
177
+ *
178
+ * @typedef {{
179
+ * jobs?: QuestJobsMap,
180
+ * scheduleJob?: (name: string) => void,
181
+ * }} QuestStartContext
182
+ *
183
+ * @typedef {{
184
+ * jobs?: QuestJobsMap,
185
+ * timers?: QuestTimersMap,
186
+ * }} QuestStopContext
187
+ *
188
+ * @typedef {{
189
+ * jobs?: QuestJobsMap,
190
+ * executeJob?: (name: string, customInputs?: AnyRecord) => Promise<QuestExecutionResult>,
191
+ * }} QuestRunContext
192
+ *
193
+ * @typedef {{
194
+ * running?: QuestRunningMap,
195
+ * config?: QuestConfig | null,
196
+ * stdout?: NodeJS.WritableStream,
197
+ * stderr?: NodeJS.WritableStream,
198
+ * }} QuestExecutorContext
199
+ *
200
+ * @typedef {{
201
+ * info(...args: any[]): void,
202
+ * warn(...args: any[]): void,
203
+ * error(...args: any[]): void,
204
+ * verbose(...args: any[]): void,
205
+ * [key: string]: any,
206
+ * }} QuestLogger
207
+ *
208
+ * @typedef {{
209
+ * config: {
210
+ * quest: QuestConfig,
211
+ * [key: string]: any,
212
+ * },
213
+ * log: QuestLogger,
214
+ * after(eventName: string, handler: Function): void,
215
+ * on(eventName: string, handler: Function): void,
216
+ * emit(eventName: 'quest:job:start', payload: QuestJobStartEvent): void,
217
+ * emit(eventName: 'quest:job:complete', payload: QuestJobCompleteEvent): void,
218
+ * emit(eventName: 'quest:job:error', payload: QuestJobErrorEvent): void,
219
+ * emit(eventName: string, payload?: any): void,
220
+ * quest?: QuestApi,
221
+ * [key: string]: any,
222
+ * }} QuestSailsApp
223
+ *
224
+ * @typedef {{
225
+ * defaults: { quest: QuestConfig },
226
+ * initialize(): Promise<void>,
227
+ * [key: string]: any,
228
+ * }} QuestSailsHook
229
+ */
230
+
231
+ module.exports = {}
package/package.json CHANGED
@@ -1,11 +1,13 @@
1
1
  {
2
2
  "name": "sails-hook-quest",
3
- "version": "0.0.3",
3
+ "version": "0.0.5",
4
4
  "description": "Elegant job scheduling for Sails.js applications with human-readable intervals, cron expressions, and full Sails context",
5
5
  "main": "lib/index.js",
6
6
  "scripts": {
7
+ "test": "sounding test",
7
8
  "lint": "prettier --check .",
8
9
  "lint:fix": "prettier --write .",
10
+ "typecheck": "tsc -p jsconfig.json --noEmit",
9
11
  "prepare": "husky"
10
12
  },
11
13
  "repository": {
@@ -50,8 +52,11 @@
50
52
  "devDependencies": {
51
53
  "@commitlint/cli": "^19.5.0",
52
54
  "@commitlint/config-conventional": "^19.5.0",
55
+ "@types/node": "^22.7.5",
53
56
  "husky": "^9.1.6",
54
57
  "lint-staged": "^15.2.10",
55
- "prettier": "^3.3.3"
58
+ "prettier": "^3.3.3",
59
+ "sounding": "^0.2.0",
60
+ "typescript": "^5.6.2"
56
61
  }
57
62
  }
@@ -0,0 +1,95 @@
1
+ const { EventEmitter } = require('node:events')
2
+ const fs = require('node:fs')
3
+ const os = require('node:os')
4
+ const path = require('node:path')
5
+ const { Writable } = require('node:stream')
6
+ const { createRuntime, createTestApi } = require('sounding')
7
+
8
+ const { createDiagnosticTail, executeJob } = require('../lib/core/executor')
9
+
10
+ const sails = new EventEmitter()
11
+ sails.config = {
12
+ appPath: process.cwd(),
13
+ environment: 'test',
14
+ datastores: {}
15
+ }
16
+ sails.hooks = {}
17
+ sails.helpers = {}
18
+ sails.models = {}
19
+ sails.log = { info() {}, warn() {}, error() {}, verbose() {} }
20
+
21
+ const test = createTestApi({ runtime: createRuntime(sails) })
22
+
23
+ test('failed jobs keep live output and emit bounded diagnostics', async ({
24
+ expect,
25
+ t
26
+ }) => {
27
+ const appPath = fs.mkdtempSync(path.join(os.tmpdir(), 'quest-diagnostics-'))
28
+ const scriptsPath = path.join(appPath, 'scripts')
29
+ const runnerPath = path.join(appPath, 'fake-sails')
30
+ fs.mkdirSync(scriptsPath)
31
+ fs.writeFileSync(path.join(scriptsPath, 'send-issue-notifications.js'), '')
32
+ fs.writeFileSync(
33
+ runnerPath,
34
+ [
35
+ '#!/usr/bin/env node',
36
+ "process.stdout.write('Preparing notifications\\n')",
37
+ "process.stderr.write('Error: database exploded\\n at sendIssueNotifications (/app/scripts/send-issue-notifications.js:12:3)\\n')",
38
+ 'process.exitCode = 1'
39
+ ].join('\n')
40
+ )
41
+ fs.chmodSync(runnerPath, 0o755)
42
+
43
+ const previousSails = global.sails
44
+ global.sails = sails
45
+
46
+ let liveOutput = ''
47
+ const output = new Writable({
48
+ write(chunk, encoding, callback) {
49
+ liveOutput += chunk.toString()
50
+ callback()
51
+ }
52
+ })
53
+ const failure = new Promise((resolve) =>
54
+ sails.once('quest:job:error', resolve)
55
+ )
56
+
57
+ t.after(() => {
58
+ global.sails = previousSails
59
+ fs.rmSync(appPath, { recursive: true, force: true })
60
+ })
61
+
62
+ let executionError
63
+ try {
64
+ await executeJob(
65
+ 'send-issue-notifications',
66
+ { name: 'send-issue-notifications' },
67
+ {},
68
+ {
69
+ config: { appPath, sailsPath: runnerPath, diagnosticTailBytes: 1024 },
70
+ stdout: output,
71
+ stderr: output
72
+ }
73
+ )
74
+ } catch (error) {
75
+ executionError = error
76
+ }
77
+
78
+ const event = await failure
79
+ expect(executionError.message).toContain('exited with code 1')
80
+ expect(liveOutput).toContain('Preparing notifications')
81
+ expect(liveOutput).toContain('database exploded')
82
+ expect(event.error.diagnostic).toContain('sendIssueNotifications')
83
+ expect(event.error.stack).toContain('executor.js')
84
+ })
85
+
86
+ test('diagnostic tails discard old output at the configured byte limit', ({
87
+ expect
88
+ }) => {
89
+ const tail = createDiagnosticTail(12)
90
+ tail.append('discard-this-')
91
+ tail.append('keep-this')
92
+
93
+ expect(Buffer.byteLength(tail.value())).toBe(12)
94
+ expect(tail.value()).toBe('is-keep-this')
95
+ })
@@ -0,0 +1,84 @@
1
+ // @ts-check
2
+
3
+ const defineQuestHook = require('../lib')
4
+ const scheduler = require('../lib/core/scheduler')
5
+ const loader = require('../lib/core/loader')
6
+
7
+ /** @typedef {import('../lib/types').QuestSailsApp} QuestSailsApp */
8
+ /** @typedef {import('../lib/types').QuestNamedJobDefinition} QuestNamedJobDefinition */
9
+
10
+ /** @type {QuestSailsApp} */
11
+ const sails = {
12
+ config: {
13
+ quest: {
14
+ autoStart: false,
15
+ timezone: 'UTC',
16
+ withoutOverlapping: true,
17
+ jobs: [
18
+ 'cleanup-sessions',
19
+ {
20
+ name: 'health-check',
21
+ interval: '5 minutes',
22
+ inputs: { url: 'https://example.com/health' }
23
+ }
24
+ ]
25
+ }
26
+ },
27
+ log: {
28
+ info() {},
29
+ warn() {},
30
+ error() {},
31
+ verbose() {}
32
+ },
33
+ after(_eventName, handler) {
34
+ handler()
35
+ },
36
+ on() {},
37
+ emit() {}
38
+ }
39
+
40
+ const hook = defineQuestHook(sails)
41
+ hook.defaults.quest.jobs.push({
42
+ name: 'daily-digest',
43
+ cron: '0 9 * * *',
44
+ timezone: 'Africa/Lagos'
45
+ })
46
+
47
+ /** @type {QuestNamedJobDefinition} */
48
+ const dynamicJob = {
49
+ name: 'dynamic-report',
50
+ interval: '1 hour',
51
+ inputs: { format: 'csv' }
52
+ }
53
+
54
+ sails.quest?.add(dynamicJob)
55
+ sails.quest?.start(['health-check', 'dynamic-report'])
56
+ sails.quest?.run('health-check', { url: 'https://example.com/ping' })
57
+ sails.quest?.pause('dynamic-report')
58
+ sails.quest?.resume('dynamic-report')
59
+ sails.quest?.remove(['dynamic-report'])
60
+
61
+ const jobs = sails.quest?.list() || []
62
+ const firstJob = jobs[0]
63
+ if (firstJob) {
64
+ scheduler.getNextRunTime(firstJob, sails.config.quest)
65
+ }
66
+
67
+ loader.addJobDefinition(
68
+ {
69
+ name: 'warm-cache',
70
+ timeout: '10 minutes',
71
+ withoutOverlapping: false
72
+ },
73
+ new Map(),
74
+ sails.config.quest
75
+ )
76
+
77
+ // @ts-expect-error Job names must be strings or string arrays.
78
+ sails.quest?.start(123)
79
+
80
+ // @ts-expect-error Dynamic jobs need a string name when provided as objects.
81
+ sails.quest?.add({ name: 123, interval: '5 minutes' })
82
+
83
+ // @ts-expect-error Dynamic job objects need an explicit name.
84
+ sails.quest?.add({ interval: '5 minutes' })
Binary file