sails-hook-quest 0.0.5 → 0.0.6

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.
@@ -25,8 +25,9 @@ const MAX_SAFE_TIMEOUT = 2_147_483_647 // 2^31 - 1, ~24.8 days
25
25
  * Schedule a job to run at its next scheduled time
26
26
  * @param {String} name - Job name
27
27
  * @param {QuestScheduleContext} [context] - Context with jobs, timers maps and helper functions
28
+ * @param {Date} [targetTime] - Original target for a long-delay intermediate timer
28
29
  */
29
- function scheduleJob(name, context = {}) {
30
+ function scheduleJob(name, context = {}, targetTime) {
30
31
  const { jobs = new Map(), timers = new Map() } = context
31
32
  const getNextRunTime =
32
33
  /** @type {NonNullable<QuestScheduleContext['getNextRunTime']>} */ (
@@ -47,10 +48,33 @@ function scheduleJob(name, context = {}) {
47
48
  }
48
49
 
49
50
  // Clear any existing timer
50
- stopJob(name, { timers })
51
+ stopJob(name, context)
51
52
 
52
53
  // Get the next run time
53
- const nextRun = getNextRunTime(job)
54
+ const states = context.scheduleStates
55
+ const update = (registration) =>
56
+ states?.set(name, {
57
+ validation: 'not_checked',
58
+ validationErrors: [],
59
+ reason: null,
60
+ ...states.get(name),
61
+ registration
62
+ })
63
+ if (states && !targetTime)
64
+ states.set(name, {
65
+ validation: 'not_checked',
66
+ validationErrors: [],
67
+ reason: null,
68
+ lastAttemptAt: new Date().toISOString(),
69
+ registration: 'not_registered'
70
+ })
71
+ let nextRun
72
+ try {
73
+ nextRun = targetTime || getNextRunTime(job)
74
+ } catch (err) {
75
+ update('failed')
76
+ throw err
77
+ }
54
78
  if (!nextRun) {
55
79
  const sails = getGlobalSails()
56
80
  if (sails) {
@@ -61,11 +85,30 @@ function scheduleJob(name, context = {}) {
61
85
  return
62
86
  }
63
87
 
88
+ if (!Number.isFinite(nextRun.getTime())) {
89
+ states?.set(name, {
90
+ ...states.get(name),
91
+ registration: 'failed',
92
+ validation: 'invalid',
93
+ reason: null,
94
+ validationErrors: [
95
+ {
96
+ code: 'E_SCHEDULE_TARGET',
97
+ message: 'Schedule target must be a finite date.'
98
+ }
99
+ ]
100
+ })
101
+ return
102
+ }
64
103
  const delay = nextRun.getTime() - Date.now()
104
+ context.dueTimes?.set(name, nextRun)
65
105
 
66
106
  // If delay is negative (past time), run immediately
67
107
  if (delay <= 0) {
68
- executeJob(name).catch((err) => {
108
+ timers.delete(name)
109
+ context.dueTimes?.delete(name)
110
+ update('consumed')
111
+ executeJob(name, undefined, 'scheduled').catch((err) => {
69
112
  const sails = getGlobalSails()
70
113
  if (sails) {
71
114
  sails.log.error(`Error running job "${name}":`, err)
@@ -83,10 +126,11 @@ function scheduleJob(name, context = {}) {
83
126
  // that rechecks when we're closer to the target time
84
127
  if (delay > MAX_SAFE_TIMEOUT) {
85
128
  const timer = setTimeout(() => {
86
- scheduleJob(name, context)
129
+ scheduleJob(name, context, nextRun)
87
130
  }, MAX_SAFE_TIMEOUT)
88
131
 
89
132
  timers.set(name, timer)
133
+ update('registered')
90
134
 
91
135
  const sails = getGlobalSails()
92
136
  if (sails) {
@@ -99,7 +143,10 @@ function scheduleJob(name, context = {}) {
99
143
 
100
144
  // Set timer for the next execution
101
145
  const timer = setTimeout(() => {
102
- executeJob(name).catch((err) => {
146
+ timers.delete(name)
147
+ context.dueTimes?.delete(name)
148
+ update('consumed')
149
+ executeJob(name, undefined, 'scheduled').catch((err) => {
103
150
  const sails = getGlobalSails()
104
151
  if (sails) {
105
152
  sails.log.error(`Error running job "${name}":`, err)
@@ -113,6 +160,7 @@ function scheduleJob(name, context = {}) {
113
160
  }, delay)
114
161
 
115
162
  timers.set(name, timer)
163
+ update('registered')
116
164
 
117
165
  const sails = getGlobalSails()
118
166
  if (sails) {
@@ -128,6 +176,19 @@ function scheduleJob(name, context = {}) {
128
176
  function stopJob(name, context = {}) {
129
177
  const { timers = new Map() } = context
130
178
 
179
+ context.dueTimes?.delete(name)
180
+ if (
181
+ context.scheduleStates &&
182
+ (context.scheduleStates.has(name) || context.jobs?.has(name))
183
+ )
184
+ context.scheduleStates.set(name, {
185
+ validation: 'not_checked',
186
+ validationErrors: [],
187
+ reason: null,
188
+ lastAttemptAt: null,
189
+ ...context.scheduleStates.get(name),
190
+ registration: 'stopped'
191
+ })
131
192
  const timer = timers.get(name)
132
193
  if (timer) {
133
194
  clearTimeout(timer)
@@ -180,7 +241,7 @@ function stopJobs(jobNames, context = {}) {
180
241
  : [jobNames]
181
242
 
182
243
  for (const name of names) {
183
- stopJob(name, { timers })
244
+ stopJob(name, context)
184
245
  }
185
246
  }
186
247
 
@@ -203,7 +264,7 @@ async function runJobs(jobNames, inputs, context = {}) {
203
264
  ? jobNames
204
265
  : [jobNames]
205
266
 
206
- const promises = names.map((name) => executeJob(name, inputs))
267
+ const promises = names.map((name) => executeJob(name, inputs, 'manual'))
207
268
  return Promise.all(promises)
208
269
  }
209
270
 
@@ -75,7 +75,7 @@ async function loadJobs(config, jobs = new Map()) {
75
75
  for (const jobDef of config.jobs) {
76
76
  // Handle string shorthand (just job name)
77
77
  /** @type {QuestJobDefinition} */
78
- const job = typeof jobDef === 'string' ? { name: jobDef } : jobDef
78
+ const job = typeof jobDef === 'string' ? { name: jobDef } : { ...jobDef }
79
79
  const jobName = job.name
80
80
 
81
81
  // Check for duplicate job names in config
@@ -89,11 +89,13 @@ async function loadJobs(config, jobs = new Map()) {
89
89
  }
90
90
 
91
91
  // Try to get script inputs if the script exists
92
- const scriptDef = jobName ? scripts[jobName] : undefined
92
+ const scriptDef = jobName ? scripts[job.script || jobName] : undefined
93
93
  if (scriptDef && !job.scriptInputs) {
94
94
  job.scriptInputs = extractScriptInputDefaults(scriptDef.inputs)
95
95
  }
96
96
 
97
+ job.script = job.script || jobName
98
+ job.inputSchema = scriptDef ? scriptDef.inputs || {} : undefined
97
99
  addJobDefinition(job, jobs, config)
98
100
  }
99
101
  }
@@ -122,7 +124,9 @@ async function loadJobs(config, jobs = new Map()) {
122
124
  withoutOverlapping: existingJob?.withoutOverlapping,
123
125
  inputs: { ...(existingJob?.inputs || {}), ...(questConfig.inputs || {}) },
124
126
  ...questConfig,
125
- scriptInputs
127
+ scriptInputs,
128
+ script: scriptName,
129
+ inputSchema: scriptDef.inputs || {}
126
130
  }
127
131
 
128
132
  addJobDefinition(jobDef, jobs, config)
@@ -152,6 +156,8 @@ function addJobDefinition(jobDef, jobs = new Map(), config = {}) {
152
156
  /** @type {QuestJob} */
153
157
  const job = {
154
158
  name,
159
+ script: jobDef.script || name,
160
+ inputSchema: jobDef.inputSchema,
155
161
  friendlyName: jobDef.friendlyName || name,
156
162
  description: jobDef.description,
157
163
 
@@ -0,0 +1,91 @@
1
+ // @ts-check
2
+
3
+ const DEFAULT_RESULT_BYTES = 64 * 1024
4
+ const MAX_RESULT_BYTES = 1024 * 1024
5
+
6
+ /** @param {number} [value] */
7
+ function byteLimit(value) {
8
+ return Number.isFinite(value) && value > 0
9
+ ? Math.min(MAX_RESULT_BYTES, Math.floor(value))
10
+ : DEFAULT_RESULT_BYTES
11
+ }
12
+
13
+ /**
14
+ * Encode plain JSON without executing getters/toJSON or allocating an unbounded
15
+ * intermediate JSON string. Depth/node limits bound even tiny nested values.
16
+ * @param {any} value
17
+ * @param {number} [requestedBytes]
18
+ * @returns {{status: string, value?: any, json?: string, exit?: string}}
19
+ */
20
+ function encodeResult(value, requestedBytes) {
21
+ if (value === undefined) return { status: 'undefined' }
22
+ const limit = byteLimit(requestedBytes)
23
+ const parts = []
24
+ const ancestors = new Set()
25
+ let bytes = 0
26
+ let nodes = 0
27
+ function append(text) {
28
+ bytes += Buffer.byteLength(text)
29
+ if (bytes > limit) throw new RangeError('too_large')
30
+ parts.push(text)
31
+ }
32
+ function visit(item, depth) {
33
+ if (++nodes > 10000 || depth > 64) throw new RangeError('too_large')
34
+ if (item === null) return append('null')
35
+ if (typeof item === 'boolean') return append(String(item))
36
+ if (typeof item === 'number' && Number.isFinite(item))
37
+ return append(String(item))
38
+ if (typeof item === 'string') {
39
+ if (
40
+ item.length > limit - bytes ||
41
+ Buffer.byteLength(item) > limit - bytes
42
+ )
43
+ throw new RangeError('too_large')
44
+ return append(JSON.stringify(item))
45
+ }
46
+ if (typeof item !== 'object') throw new TypeError('non_json')
47
+ if (ancestors.has(item)) throw new TypeError('circular')
48
+ const array = Array.isArray(item)
49
+ if (
50
+ !array &&
51
+ Object.getPrototypeOf(item) !== Object.prototype &&
52
+ Object.getPrototypeOf(item) !== null
53
+ ) {
54
+ throw new TypeError('non_plain_object')
55
+ }
56
+ ancestors.add(item)
57
+ append(array ? '[' : '{')
58
+ const keys = array ? null : Object.keys(item)
59
+ const count = array ? item.length : keys.length
60
+ if (count > 10000) throw new RangeError('too_large')
61
+ for (let i = 0; i < count; i++) {
62
+ if (i) append(',')
63
+ const key = array ? String(i) : keys[i]
64
+ if (!array) {
65
+ if (
66
+ key.length > limit - bytes ||
67
+ Buffer.byteLength(key) > limit - bytes
68
+ )
69
+ throw new RangeError('too_large')
70
+ append(JSON.stringify(key) + ':')
71
+ }
72
+ const descriptor = Object.getOwnPropertyDescriptor(item, key)
73
+ if (!descriptor || !('value' in descriptor))
74
+ throw new TypeError('non_json_property')
75
+ visit(descriptor.value, depth + 1)
76
+ }
77
+ append(array ? ']' : '}')
78
+ ancestors.delete(item)
79
+ }
80
+ try {
81
+ visit(value, 0)
82
+ const json = parts.join('')
83
+ return { status: 'available', value: JSON.parse(json), json }
84
+ } catch (error) {
85
+ return {
86
+ status: error instanceof RangeError ? 'too_large' : 'serialization_error'
87
+ }
88
+ }
89
+ }
90
+
91
+ module.exports = { encodeResult, byteLimit }
@@ -0,0 +1,240 @@
1
+ // @ts-check
2
+ const { randomUUID } = require('node:crypto')
3
+ const { encodeResult } = require('./result')
4
+ const { effectiveTimezone } = require('./scheduler')
5
+
6
+ function createRuntime() {
7
+ return { runtimeId: randomUUID(), sequence: 0, active: new Map() }
8
+ }
9
+
10
+ /** @param {import('../types').QuestContext} context */
11
+ function runtimeInfo(context) {
12
+ return {
13
+ contractVersion: 1,
14
+ runtimeId: context.runtime.runtimeId,
15
+ scope: 'process',
16
+ capabilities: {
17
+ inputMetadata: true,
18
+ runIdentity: true,
19
+ businessResults: true,
20
+ residentState: true,
21
+ childSchedulerSuppression: true,
22
+ triggerProvenance: true,
23
+ terminalExitCode: true,
24
+ terminalSignal: true,
25
+ scheduleDiagnostics: true,
26
+ cancellation: false,
27
+ durableHistory: false
28
+ }
29
+ }
30
+ }
31
+
32
+ const fields = [
33
+ 'type',
34
+ 'description',
35
+ 'friendlyName',
36
+ 'required',
37
+ 'allowNull',
38
+ 'isIn',
39
+ 'min',
40
+ 'max',
41
+ 'minLength',
42
+ 'maxLength',
43
+ 'isEmail',
44
+ 'isURL',
45
+ 'isInteger',
46
+ 'regex',
47
+ 'example'
48
+ ]
49
+ /** @param {import('../types').QuestMachineInputs} [schema] */
50
+ function inputMetadata(schema = {}) {
51
+ const result = Object.create(null)
52
+ for (const [name, def] of Object.entries(schema)) {
53
+ if (!def || typeof def !== 'object') continue
54
+ const field = Object.create(null)
55
+ field.sensitive = Boolean(
56
+ def.protect ||
57
+ def.secret ||
58
+ def.sensitive ||
59
+ /password|secret|token|credential/i.test(name)
60
+ )
61
+ field.customValidation = typeof def.custom === 'function'
62
+ for (const key of fields) {
63
+ if (field.sensitive && key === 'example') continue
64
+ if (Object.prototype.hasOwnProperty.call(def, key)) {
65
+ const encoded = encodeResult(def[key], 4096)
66
+ if (encoded.status === 'available') field[key] = encoded.value
67
+ }
68
+ }
69
+ if (
70
+ !field.sensitive &&
71
+ Object.prototype.hasOwnProperty.call(def, 'defaultsTo')
72
+ ) {
73
+ const encoded = encodeResult(def.defaultsTo, 4096)
74
+ if (encoded.status === 'available') field.defaultsTo = encoded.value
75
+ }
76
+ result[name] = field
77
+ }
78
+ return result
79
+ }
80
+
81
+ /** Describe the raw effective scheduled values; validation is still Sails' authority.
82
+ * @param {import('../types').QuestJob} job
83
+ */
84
+ function scheduledInputs(job) {
85
+ const schema = job.inputSchema || {}
86
+ const safeSchema = inputMetadata(schema)
87
+ const configured = job.inputs || {}
88
+ const scriptInputs = job.scriptInputs || {}
89
+ const names = new Set([
90
+ ...Object.keys(schema),
91
+ ...Object.keys(configured),
92
+ ...Object.keys(scriptInputs)
93
+ ])
94
+ const values = Object.create(null)
95
+ const fields = Object.create(null)
96
+ let remaining = 64 * 1024
97
+ for (const name of names) {
98
+ const own = (object) => Object.prototype.hasOwnProperty.call(object, name)
99
+ const source = own(scriptInputs)
100
+ ? 'script_input'
101
+ : own(configured)
102
+ ? 'job_input'
103
+ : schema[name]?.defaultsTo !== undefined
104
+ ? 'schema_default'
105
+ : 'omitted'
106
+ const value =
107
+ source === 'script_input'
108
+ ? scriptInputs[name]
109
+ : source === 'job_input'
110
+ ? configured[name]
111
+ : source === 'schema_default'
112
+ ? schema[name].defaultsTo
113
+ : undefined
114
+ const sensitive =
115
+ safeSchema[name]?.sensitive ||
116
+ /password|secret|token|credential/i.test(name)
117
+ /** @type {import('../types').AnyRecord} */
118
+ const field = {
119
+ source,
120
+ sensitive: Boolean(sensitive),
121
+ available: false,
122
+ missingRequired: Boolean(schema[name]?.required && value === undefined)
123
+ }
124
+ if (value !== undefined && !sensitive) {
125
+ const encoded = encodeResult(
126
+ value,
127
+ Math.max(1, Math.min(4096, remaining))
128
+ )
129
+ if (
130
+ encoded.status === 'available' &&
131
+ Buffer.byteLength(encoded.json) <= remaining
132
+ ) {
133
+ values[name] = encoded.value
134
+ field.available = true
135
+ remaining -= Buffer.byteLength(encoded.json)
136
+ } else
137
+ field.reason =
138
+ encoded.status === 'available' ? 'metadata_limit' : encoded.status
139
+ } else if (sensitive) field.reason = 'sensitive'
140
+ fields[name] = field
141
+ }
142
+ return { values, fields, validation: 'not_checked', limitBytes: 64 * 1024 }
143
+ }
144
+
145
+ /** Mirror parser precedence; unused lower-priority fields do not change restart semantics. */
146
+ function restartSemantics(job) {
147
+ let timing = 'none'
148
+ let oneShot = false
149
+ if (job.cron) timing = 'wall_clock'
150
+ else if (job.interval !== undefined && job.interval !== false)
151
+ timing =
152
+ typeof job.interval === 'string' && /at|on the/.test(job.interval)
153
+ ? 'wall_clock'
154
+ : 'relative_to_registration'
155
+ else if (job.timeout !== undefined && job.timeout !== false) {
156
+ timing =
157
+ typeof job.timeout === 'string' && job.timeout.startsWith('at ')
158
+ ? 'wall_clock'
159
+ : 'relative_to_registration'
160
+ oneShot = true
161
+ } else if (job.date !== undefined && job.date !== false) {
162
+ timing = 'wall_clock'
163
+ oneShot = true
164
+ }
165
+ return {
166
+ persistence: 'memory_only',
167
+ timing,
168
+ oneShot,
169
+ missedRuns: 'not_replayed'
170
+ }
171
+ }
172
+
173
+ /** @param {import('../types').QuestContext} context @param {string} [name] */
174
+ function metadata(context, name) {
175
+ const describe = (job) => {
176
+ const schedule = {}
177
+ for (const key of [
178
+ 'interval',
179
+ 'cron',
180
+ 'cronOptions',
181
+ 'timeout',
182
+ 'date',
183
+ 'timezone'
184
+ ]) {
185
+ if (job[key] !== undefined) {
186
+ const encoded = encodeResult(
187
+ job[key] instanceof Date
188
+ ? Number.isFinite(job[key].getTime())
189
+ ? job[key].toISOString()
190
+ : null
191
+ : job[key],
192
+ 4096
193
+ )
194
+ if (encoded.status === 'available') schedule[key] = encoded.value
195
+ }
196
+ }
197
+ schedule.timezone = effectiveTimezone(job, context.config || {})
198
+ return {
199
+ name: job.name,
200
+ script: job.script || job.name,
201
+ friendlyName: job.friendlyName,
202
+ description: job.description,
203
+ inputMetadataAvailable: Boolean(job.inputSchema),
204
+ inputs: inputMetadata(job.inputSchema),
205
+ scheduledInputs: scheduledInputs(job),
206
+ schedule,
207
+ scheduleState: {
208
+ registration: 'not_attempted',
209
+ validation: 'not_checked',
210
+ reason: null,
211
+ lastAttemptAt: null,
212
+ ...context.scheduleStates?.get(job.name),
213
+ validationErrors: (
214
+ context.scheduleStates?.get(job.name)?.validationErrors || []
215
+ ).map(({ code, message }) => ({ code, message })),
216
+ restart: restartSemantics(job)
217
+ },
218
+ paused: job.paused,
219
+ withoutOverlapping: job.withoutOverlapping,
220
+ runningCount: context.runtime.active.get(job.name)?.size || 0,
221
+ scheduled: context.timers.has(job.name),
222
+ nextRunAt: context.timers.has(job.name)
223
+ ? context.dueTimes.get(job.name)?.toISOString() || null
224
+ : null,
225
+ runtimeId: context.runtime.runtimeId
226
+ }
227
+ }
228
+ if (name !== undefined) {
229
+ const job = context.jobs.get(name)
230
+ return job ? describe(job) : undefined
231
+ }
232
+ return Array.from(context.jobs.values(), describe)
233
+ }
234
+ module.exports = {
235
+ createRuntime,
236
+ runtimeInfo,
237
+ inputMetadata,
238
+ scheduledInputs,
239
+ metadata
240
+ }
@@ -19,72 +19,110 @@ const { getGlobalSails } = require('./global-sails')
19
19
  * Parse various schedule formats and return next run time
20
20
  * @param {QuestJobDefinition} job - Job configuration
21
21
  * @param {QuestConfig} [config] - Quest configuration
22
+ * @param {(assessment: import('../types').AnyRecord) => void} [report] Bounded parser assessment
22
23
  * @returns {Date|null} Next run time or null if invalid
23
24
  */
24
- function getNextRunTime(job, config = {}) {
25
+ function getNextRunTime(job, config = {}, report = () => {}) {
25
26
  const now = new Date()
26
- const timezone = job.timezone || config.timezone
27
+ const assess = (nextRun, validation, reason = null, code = null) => {
28
+ report({
29
+ validation,
30
+ reason,
31
+ validationErrors: code
32
+ ? [{ code, message: scheduleErrorMessages[code] }]
33
+ : []
34
+ })
35
+ return nextRun
36
+ }
37
+ const invalid = (code) => assess(null, 'invalid', null, code)
27
38
 
28
- // Validate: cannot combine date and timeout
39
+ // Preserve the existing conflict exception, but retain its assessment first.
29
40
  if (job.date && job.timeout !== undefined && job.timeout !== false) {
41
+ invalid('E_SCHEDULE_CONFLICT')
30
42
  throw new Error(
31
43
  `Job "${job.name}": Cannot combine 'date' and 'timeout'. Use one or the other.`
32
44
  )
33
45
  }
34
-
35
- // Handle cron expressions
36
46
  if (job.cron) {
47
+ let interval
37
48
  try {
38
- const options = { tz: timezone }
39
- // Merge any cron-parser options (currentDate, startDate, endDate, etc.)
40
- if (job.cronOptions) {
41
- Object.assign(options, job.cronOptions)
42
- }
43
- const interval = CronExpressionParser.parse(job.cron, options)
44
- return interval.next().toDate()
49
+ const options = { tz: effectiveTimezone(job, config) }
50
+ if (job.cronOptions) Object.assign(options, job.cronOptions)
51
+ interval = CronExpressionParser.parse(job.cron, options)
45
52
  } catch (err) {
46
53
  const sails = getGlobalSails()
47
- if (sails) {
48
- sails.log.error(
49
- `Invalid cron expression for job "${job.name}": ${job.cron}`,
50
- err instanceof Error ? err.message : err
51
- )
52
- }
53
- return null
54
+ if (sails) sails.log.error(`Invalid cron schedule for job "${job.name}"`)
55
+ return invalid('E_SCHEDULE_CRON')
54
56
  }
55
- }
56
-
57
- // Handle human-readable intervals
58
- if (job.interval && typeof job.interval === 'string') {
59
- const nextTime = parseInterval(job.interval, now)
60
- if (nextTime) return nextTime
61
-
62
- const sails = getGlobalSails()
63
- if (sails) {
64
- sails.log.error(`Invalid interval for job "${job.name}": ${job.interval}`)
57
+ try {
58
+ return assess(interval.next().toDate(), 'valid')
59
+ } catch (err) {
60
+ // A parsed cron can be exhausted by its endDate; that is not invalid.
61
+ return assess(null, 'valid', 'no_future_run')
65
62
  }
66
- return null
67
63
  }
68
-
69
- // Handle numeric intervals (milliseconds)
70
- if (typeof job.interval === 'number') {
71
- return new Date(now.getTime() + job.interval)
64
+ if (job.interval !== undefined && job.interval !== false) {
65
+ let nextTime = null
66
+ if (typeof job.interval === 'string')
67
+ nextTime = parseInterval(job.interval, now)
68
+ if (
69
+ typeof job.interval === 'number' &&
70
+ Number.isFinite(job.interval) &&
71
+ job.interval > 0
72
+ )
73
+ nextTime = new Date(now.getTime() + job.interval)
74
+ if (!nextTime || !Number.isFinite(nextTime.getTime()) || nextTime <= now)
75
+ return invalid('E_SCHEDULE_INTERVAL')
76
+ return assess(nextTime, 'valid')
72
77
  }
73
-
74
- // Handle timeout (one-time delay)
75
78
  if (job.timeout !== undefined && job.timeout !== false) {
76
- return parseTimeout(job.timeout, now)
79
+ if (
80
+ typeof job.timeout === 'number' &&
81
+ (!Number.isFinite(job.timeout) || job.timeout < 0)
82
+ )
83
+ return invalid('E_SCHEDULE_TIMEOUT')
84
+ const nextTime = parseTimeout(job.timeout, now)
85
+ if (!nextTime || !Number.isFinite(nextTime.getTime()) || nextTime < now)
86
+ return invalid('E_SCHEDULE_TIMEOUT')
87
+ return assess(nextTime, 'valid')
77
88
  }
78
-
79
- // Handle specific date
80
- if (job.date) {
89
+ if (job.date !== undefined && job.date !== false) {
90
+ if (
91
+ !(job.date instanceof Date) &&
92
+ typeof job.date !== 'string' &&
93
+ typeof job.date !== 'number'
94
+ )
95
+ return invalid('E_SCHEDULE_DATE')
81
96
  const date = new Date(job.date)
82
- if (date > now) {
83
- return date
84
- }
97
+ if (!Number.isFinite(date.getTime())) return invalid('E_SCHEDULE_DATE')
98
+ return assess(
99
+ date > now ? date : null,
100
+ 'valid',
101
+ date > now ? null : 'no_future_run'
102
+ )
85
103
  }
104
+ return assess(null, 'not_checked', 'no_schedule')
105
+ }
86
106
 
87
- return null
107
+ const scheduleErrorMessages = {
108
+ E_SCHEDULE_CONFLICT: 'Date and timeout cannot be combined.',
109
+ E_SCHEDULE_CRON: 'Cron expression, timezone, or parser options are invalid.',
110
+ E_SCHEDULE_INTERVAL:
111
+ 'Interval must produce a finite future time; numeric intervals must be positive.',
112
+ E_SCHEDULE_TIMEOUT:
113
+ 'Timeout must produce a finite time; numeric timeouts must be nonnegative.',
114
+ E_SCHEDULE_DATE: 'Date must be a supported finite date value.'
115
+ }
116
+
117
+ /** Use the same precedence as cron-parser registration, including explicit tz overrides.
118
+ * @param {QuestJobDefinition} job @param {QuestConfig} [config]
119
+ */
120
+ function effectiveTimezone(job, config = {}) {
121
+ return job.cron &&
122
+ job.cronOptions &&
123
+ Object.prototype.hasOwnProperty.call(job.cronOptions, 'tz')
124
+ ? job.cronOptions.tz || null
125
+ : job.timezone || config.timezone || null
88
126
  }
89
127
 
90
128
  /**
@@ -207,6 +245,7 @@ function convertShorthand(str) {
207
245
 
208
246
  module.exports = {
209
247
  getNextRunTime,
248
+ effectiveTimezone,
210
249
  parseInterval,
211
250
  parseTimeout,
212
251
  convertShorthand