sails-hook-quest 0.0.4 → 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.
@@ -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
+ }
@@ -1,3 +1,5 @@
1
+ // @ts-check
2
+
1
3
  /**
2
4
  * core/scheduler.js
3
5
  *
@@ -7,75 +9,120 @@
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
22
+ * @param {(assessment: import('../types').AnyRecord) => void} [report] Bounded parser assessment
15
23
  * @returns {Date|null} Next run time or null if invalid
16
24
  */
17
- function getNextRunTime(job, config = {}) {
25
+ function getNextRunTime(job, config = {}, report = () => {}) {
18
26
  const now = new Date()
19
- 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)
20
38
 
21
- // Validate: cannot combine date and timeout
39
+ // Preserve the existing conflict exception, but retain its assessment first.
22
40
  if (job.date && job.timeout !== undefined && job.timeout !== false) {
41
+ invalid('E_SCHEDULE_CONFLICT')
23
42
  throw new Error(
24
43
  `Job "${job.name}": Cannot combine 'date' and 'timeout'. Use one or the other.`
25
44
  )
26
45
  }
27
-
28
- // Handle cron expressions
29
46
  if (job.cron) {
47
+ let interval
30
48
  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()
49
+ const options = { tz: effectiveTimezone(job, config) }
50
+ if (job.cronOptions) Object.assign(options, job.cronOptions)
51
+ interval = CronExpressionParser.parse(job.cron, options)
38
52
  } 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
53
+ const sails = getGlobalSails()
54
+ if (sails) sails.log.error(`Invalid cron schedule for job "${job.name}"`)
55
+ return invalid('E_SCHEDULE_CRON')
46
56
  }
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}`)
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')
56
62
  }
57
- return null
58
63
  }
59
-
60
- // Handle numeric intervals (milliseconds)
61
- if (typeof job.interval === 'number') {
62
- 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')
63
77
  }
64
-
65
- // Handle timeout (one-time delay)
66
78
  if (job.timeout !== undefined && job.timeout !== false) {
67
- 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')
68
88
  }
69
-
70
- // Handle specific date
71
- 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')
72
96
  const date = new Date(job.date)
73
- if (date > now) {
74
- return date
75
- }
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
+ )
76
103
  }
104
+ return assess(null, 'not_checked', 'no_schedule')
105
+ }
77
106
 
78
- 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
79
126
  }
80
127
 
81
128
  /**
@@ -95,6 +142,7 @@ function parseInterval(intervalStr, fromDate = new Date()) {
95
142
  if (everyMatch) {
96
143
  const amount = parseInt(everyMatch[1])
97
144
  const unit = everyMatch[2].replace(/s$/, '') // Remove plural 's'
145
+ /** @type {Record<string, number>} */
98
146
  const msMap = {
99
147
  second: 1000,
100
148
  minute: 60000,
@@ -138,7 +186,7 @@ function parseInterval(intervalStr, fromDate = new Date()) {
138
186
 
139
187
  /**
140
188
  * Parse a timeout value into a Date
141
- * @param {String|Number} timeout - Timeout value
189
+ * @param {QuestTimeout} timeout - Timeout value
142
190
  * @param {Date} fromDate - Calculate from this date
143
191
  * @returns {Date|null} Next run time or null
144
192
  */
@@ -197,6 +245,7 @@ function convertShorthand(str) {
197
245
 
198
246
  module.exports = {
199
247
  getNextRunTime,
248
+ effectiveTimezone,
200
249
  parseInterval,
201
250
  parseTimeout,
202
251
  convertShorthand
package/lib/index.js CHANGED
@@ -1,3 +1,5 @@
1
+ // @ts-check
2
+
1
3
  /**
2
4
  * sails-hook-quest
3
5
  *
@@ -9,17 +11,45 @@ const scheduler = require('./core/scheduler')
9
11
  const executor = require('./core/executor')
10
12
  const loader = require('./core/loader')
11
13
  const jobControl = require('./core/job-control')
14
+ const resident = require('./core/runtime')
15
+ const { isExecutionChild } = require('./core/execution-child')
16
+
17
+ /** @typedef {import('./types').AnyRecord} AnyRecord */
18
+ /** @typedef {import('./types').QuestApi} QuestApi */
19
+ /** @typedef {import('./types').QuestConfig} QuestConfig */
20
+ /** @typedef {import('./types').QuestContext} QuestContext */
21
+ /** @typedef {import('./types').QuestExecutableJob} QuestExecutableJob */
22
+ /** @typedef {import('./types').QuestJobsMap} QuestJobsMap */
23
+ /** @typedef {import('./types').QuestNamedJobDefinition} QuestNamedJobDefinition */
24
+ /** @typedef {import('./types').QuestRunningMap} QuestRunningMap */
25
+ /** @typedef {import('./types').QuestSailsApp} QuestSailsApp */
26
+ /** @typedef {import('./types').QuestSailsHook} QuestSailsHook */
27
+ /** @typedef {import('./types').QuestTimersMap} QuestTimersMap */
12
28
 
13
- module.exports = function defineQuestHook(sails) {
29
+ /**
30
+ * Sails hook factory.
31
+ *
32
+ * @param {QuestSailsApp} sails
33
+ * @returns {QuestSailsHook}
34
+ */
35
+ function defineQuestHook(sails) {
36
+ /** @type {QuestJobsMap} */
14
37
  const jobs = new Map()
38
+ /** @type {QuestTimersMap} */
15
39
  const timers = new Map()
40
+ /** @type {QuestRunningMap} */
16
41
  const running = new Map()
17
42
 
18
43
  // Create context object that will be passed to modules
44
+ /** @type {QuestContext} */
19
45
  const context = {
20
46
  jobs,
21
47
  timers,
22
48
  running,
49
+ sails,
50
+ runtime: resident.createRuntime(),
51
+ dueTimes: new Map(),
52
+ scheduleStates: new Map(),
23
53
  config: null, // Will be set after sails.config is available
24
54
  scheduleJob: null, // Will be set after function is defined
25
55
  executeJob: null, // Will be set after function is defined
@@ -47,6 +77,12 @@ module.exports = function defineQuestHook(sails) {
47
77
  // Directory containing job scripts
48
78
  scriptsDir: 'scripts',
49
79
 
80
+ // Maximum child output retained on failure (live output remains unbounded)
81
+ diagnosticTailBytes: 64 * 1024,
82
+
83
+ resultBytes: 64 * 1024,
84
+ inputBytes: 64 * 1024,
85
+
50
86
  // Jobs defined in config
51
87
  jobs: []
52
88
  }
@@ -55,36 +91,64 @@ module.exports = function defineQuestHook(sails) {
55
91
  initialize: async function () {
56
92
  sails.log.info('Initializing Quest job scheduler')
57
93
 
58
- sails.after('hook:orm:loaded', async () => {
94
+ // Register cleanup before awaiting dependencies so failed lift also stops timers.
95
+ sails.on('lower', async () => {
96
+ sails.log.info('Stopping Quest jobs...')
97
+ await jobControl.stopJobs(null, context)
98
+ })
99
+
100
+ const initializeAfterOrm = async () => {
101
+ // The owned CLI child needs the app's hooks/context, but must not start
102
+ // another copy of its resident schedules, even if source config enables it.
103
+ if (isExecutionChild()) sails.config.quest.autoStart = false
104
+ const questConfig = {
105
+ ...sails.config.quest,
106
+ appPath:
107
+ sails.config.quest.appPath || sails.config.appPath || process.cwd()
108
+ }
109
+
59
110
  // Set up context with config
60
- context.config = sails.config.quest
111
+ context.config = questConfig
61
112
  context.getNextRunTime = (job) =>
62
- scheduler.getNextRunTime(job, sails.config.quest)
113
+ scheduler.getNextRunTime(job, questConfig, (assessment) => {
114
+ context.scheduleStates.set(job.name, {
115
+ ...context.scheduleStates.get(job.name),
116
+ ...assessment
117
+ })
118
+ })
63
119
  context.scheduleJob = (name) => jobControl.scheduleJob(name, context)
64
- context.executeJob = (name, customInputs) => {
120
+ context.executeJob = (name, customInputs, trigger = 'manual') => {
65
121
  const job = jobs.get(name)
66
122
  if (!job) {
67
123
  // Try to run as a regular script without quest config
124
+ /** @type {QuestExecutableJob} */
68
125
  const minimalJob = {
69
126
  name,
70
127
  withoutOverlapping: false,
71
128
  inputs: {}
72
129
  }
73
- return executor.executeJob(name, minimalJob, customInputs, context)
130
+ return executor.executeJob(name, minimalJob, customInputs, {
131
+ ...context,
132
+ trigger
133
+ })
74
134
  }
75
- return executor.executeJob(name, job, customInputs, context)
135
+ return executor.executeJob(name, job, customInputs, {
136
+ ...context,
137
+ trigger
138
+ })
76
139
  }
77
140
 
78
141
  // Load jobs from scripts and config
79
- await loader.loadJobs(sails.config.quest, jobs)
142
+ await loader.loadJobs(questConfig, jobs)
80
143
 
81
144
  // Start all jobs if autoStart is enabled
82
- if (sails.config.quest.autoStart) {
145
+ if (questConfig.autoStart) {
83
146
  await jobControl.startJobs(null, context)
84
147
  }
85
148
 
86
149
  // Expose the Quest API
87
- sails.quest = {
150
+ /** @type {QuestApi} */
151
+ const questApi = {
88
152
  // Core job control
89
153
  start: (jobNames) => jobControl.startJobs(jobNames, context),
90
154
  stop: (jobNames) => jobControl.stopJobs(jobNames, context),
@@ -92,12 +156,15 @@ module.exports = function defineQuestHook(sails) {
92
156
  jobControl.runJobs(jobNames, inputs, context),
93
157
  add: (jobDefs) => {
94
158
  const defs = Array.isArray(jobDefs) ? jobDefs : [jobDefs]
159
+ /** @type {string[]} */
95
160
  const added = []
96
161
  for (const def of defs) {
162
+ /** @type {QuestNamedJobDefinition} */
97
163
  const job = typeof def === 'string' ? { name: def } : def
98
- loader.addJobDefinition(job, jobs, context.config)
164
+ loader.addJobDefinition(job, jobs, context.config || {})
165
+ context.scheduleStates.delete(job.name)
99
166
  added.push(job.name)
100
- if (context.config.autoStart) {
167
+ if (context.config?.autoStart) {
101
168
  jobControl.scheduleJob(job.name, context)
102
169
  }
103
170
  }
@@ -108,12 +175,17 @@ module.exports = function defineQuestHook(sails) {
108
175
  const removed = []
109
176
  for (const name of names) {
110
177
  if (loader.removeJob(name, jobs, timers)) {
178
+ context.dueTimes.delete(name)
179
+ context.scheduleStates.delete(name)
111
180
  removed.push(name)
112
181
  }
113
182
  }
114
183
  return removed
115
184
  },
116
185
 
186
+ getRuntime: () => resident.runtimeInfo(context),
187
+ metadata: (name) => resident.metadata(context, name),
188
+
117
189
  // Job information
118
190
  jobs: Array.from(jobs.values()),
119
191
 
@@ -127,14 +199,19 @@ module.exports = function defineQuestHook(sails) {
127
199
  resume: (name) => jobControl.resumeJob(name, jobs)
128
200
  }
129
201
 
202
+ sails.quest = questApi
130
203
  sails.log.info(`Quest started with ${jobs.size} scheduled job(s)`)
131
- })
204
+ }
132
205
 
133
- // Graceful shutdown
134
- sails.on('lower', async () => {
135
- sails.log.info('Stopping Quest jobs...')
136
- await jobControl.stopJobs(null, context)
206
+ // Sails waits for this Promise before marking Quest loaded. ORM initializes
207
+ // independently; after() also handles an ORM event that already occurred.
208
+ await new Promise((resolve, reject) => {
209
+ sails.after('hook:orm:loaded', () => {
210
+ initializeAfterOrm().then(resolve, reject)
211
+ })
137
212
  })
138
213
  }
139
214
  }
140
215
  }
216
+
217
+ module.exports = defineQuestHook