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.
package/lib/index.js CHANGED
@@ -11,6 +11,8 @@ const scheduler = require('./core/scheduler')
11
11
  const executor = require('./core/executor')
12
12
  const loader = require('./core/loader')
13
13
  const jobControl = require('./core/job-control')
14
+ const resident = require('./core/runtime')
15
+ const { isExecutionChild } = require('./core/execution-child')
14
16
 
15
17
  /** @typedef {import('./types').AnyRecord} AnyRecord */
16
18
  /** @typedef {import('./types').QuestApi} QuestApi */
@@ -44,6 +46,10 @@ function defineQuestHook(sails) {
44
46
  jobs,
45
47
  timers,
46
48
  running,
49
+ sails,
50
+ runtime: resident.createRuntime(),
51
+ dueTimes: new Map(),
52
+ scheduleStates: new Map(),
47
53
  config: null, // Will be set after sails.config is available
48
54
  scheduleJob: null, // Will be set after function is defined
49
55
  executeJob: null, // Will be set after function is defined
@@ -74,6 +80,9 @@ function defineQuestHook(sails) {
74
80
  // Maximum child output retained on failure (live output remains unbounded)
75
81
  diagnosticTailBytes: 64 * 1024,
76
82
 
83
+ resultBytes: 64 * 1024,
84
+ inputBytes: 64 * 1024,
85
+
77
86
  // Jobs defined in config
78
87
  jobs: []
79
88
  }
@@ -82,15 +91,33 @@ function defineQuestHook(sails) {
82
91
  initialize: async function () {
83
92
  sails.log.info('Initializing Quest job scheduler')
84
93
 
85
- sails.after('hook:orm:loaded', async () => {
86
- const questConfig = sails.config.quest
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
+ }
87
109
 
88
110
  // Set up context with config
89
111
  context.config = questConfig
90
112
  context.getNextRunTime = (job) =>
91
- scheduler.getNextRunTime(job, questConfig)
113
+ scheduler.getNextRunTime(job, questConfig, (assessment) => {
114
+ context.scheduleStates.set(job.name, {
115
+ ...context.scheduleStates.get(job.name),
116
+ ...assessment
117
+ })
118
+ })
92
119
  context.scheduleJob = (name) => jobControl.scheduleJob(name, context)
93
- context.executeJob = (name, customInputs) => {
120
+ context.executeJob = (name, customInputs, trigger = 'manual') => {
94
121
  const job = jobs.get(name)
95
122
  if (!job) {
96
123
  // Try to run as a regular script without quest config
@@ -100,9 +127,15 @@ function defineQuestHook(sails) {
100
127
  withoutOverlapping: false,
101
128
  inputs: {}
102
129
  }
103
- return executor.executeJob(name, minimalJob, customInputs, context)
130
+ return executor.executeJob(name, minimalJob, customInputs, {
131
+ ...context,
132
+ trigger
133
+ })
104
134
  }
105
- return executor.executeJob(name, job, customInputs, context)
135
+ return executor.executeJob(name, job, customInputs, {
136
+ ...context,
137
+ trigger
138
+ })
106
139
  }
107
140
 
108
141
  // Load jobs from scripts and config
@@ -129,6 +162,7 @@ function defineQuestHook(sails) {
129
162
  /** @type {QuestNamedJobDefinition} */
130
163
  const job = typeof def === 'string' ? { name: def } : def
131
164
  loader.addJobDefinition(job, jobs, context.config || {})
165
+ context.scheduleStates.delete(job.name)
132
166
  added.push(job.name)
133
167
  if (context.config?.autoStart) {
134
168
  jobControl.scheduleJob(job.name, context)
@@ -141,12 +175,17 @@ function defineQuestHook(sails) {
141
175
  const removed = []
142
176
  for (const name of names) {
143
177
  if (loader.removeJob(name, jobs, timers)) {
178
+ context.dueTimes.delete(name)
179
+ context.scheduleStates.delete(name)
144
180
  removed.push(name)
145
181
  }
146
182
  }
147
183
  return removed
148
184
  },
149
185
 
186
+ getRuntime: () => resident.runtimeInfo(context),
187
+ metadata: (name) => resident.metadata(context, name),
188
+
150
189
  // Job information
151
190
  jobs: Array.from(jobs.values()),
152
191
 
@@ -162,12 +201,14 @@ function defineQuestHook(sails) {
162
201
 
163
202
  sails.quest = questApi
164
203
  sails.log.info(`Quest started with ${jobs.size} scheduled job(s)`)
165
- })
204
+ }
166
205
 
167
- // Graceful shutdown
168
- sails.on('lower', async () => {
169
- sails.log.info('Stopping Quest jobs...')
170
- 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
+ })
171
212
  })
172
213
  }
173
214
  }
package/lib/types.js CHANGED
@@ -10,11 +10,11 @@
10
10
  *
11
11
  * @typedef {JsonPrimitive | any[] | AnyRecord} JsonValue
12
12
  *
13
- * @typedef {string | number} QuestInterval
13
+ * @typedef {string | number | false} QuestInterval
14
14
  *
15
15
  * @typedef {string | number | false} QuestTimeout
16
16
  *
17
- * @typedef {string | number | Date} QuestDateInput
17
+ * @typedef {string | number | Date | false} QuestDateInput
18
18
  *
19
19
  * @typedef {Record<string, any>} QuestCronOptions
20
20
  *
@@ -52,6 +52,8 @@
52
52
  *
53
53
  * @typedef {{
54
54
  * name: string,
55
+ * script?: string,
56
+ * inputSchema?: QuestMachineInputs,
55
57
  * friendlyName: string,
56
58
  * description?: string,
57
59
  * interval?: QuestInterval,
@@ -68,6 +70,8 @@
68
70
  *
69
71
  * @typedef {{
70
72
  * name: string,
73
+ * script?: string,
74
+ * inputSchema?: QuestMachineInputs,
71
75
  * inputs?: AnyRecord,
72
76
  * scriptInputs?: AnyRecord,
73
77
  * paused?: boolean,
@@ -101,23 +105,66 @@
101
105
  * @typedef {{
102
106
  * skipped: true,
103
107
  * reason: 'already_running' | 'paused',
108
+ * runId?: string,
109
+ * runtimeId?: string,
110
+ * startedAt?: Date,
111
+ * trigger?: 'manual' | 'scheduled',
112
+ * finishedAt?: Date,
113
+ * exitCode?: number | null,
114
+ * signal?: string | null,
104
115
  * }} QuestSkippedResult
105
116
  *
106
117
  * @typedef {{
107
118
  * success: true,
108
119
  * duration: number,
120
+ * runId?: string,
121
+ * runtimeId?: string,
122
+ * sequence?: number,
123
+ * startedAt?: Date,
124
+ * trigger?: 'manual' | 'scheduled',
125
+ * finishedAt?: Date,
126
+ * exitCode?: number | null,
127
+ * signal?: string | null,
128
+ * result?: QuestBusinessResult,
129
+ * logs?: QuestLogs,
109
130
  * }} QuestSuccessResult
110
131
  *
132
+ * @typedef {{status: string, value?: any, reason?: string, exit?: string}} QuestBusinessResult
133
+ *
134
+ * @typedef {{stdout: string, stderr: string, stdoutTruncated: boolean, stderrTruncated: boolean, limitBytes: number}} QuestLogs
135
+ *
136
+ * @typedef {{runtimeId: string, sequence: number, active: Map<string, Map<string, number>>}} QuestRuntime
137
+ *
111
138
  * @typedef {QuestSkippedResult | QuestSuccessResult} QuestExecutionResult
112
139
  *
113
140
  * @typedef {{
114
141
  * name: string,
142
+ * runId?: string,
143
+ * runtimeId?: string,
144
+ * sequence?: number,
145
+ * startedAt?: Date,
146
+ * trigger?: 'manual' | 'scheduled',
147
+ * finishedAt?: Date,
148
+ * exitCode?: number | null,
149
+ * signal?: string | null,
150
+ * result?: QuestBusinessResult,
151
+ * logs?: QuestLogs,
115
152
  * inputs: AnyRecord,
116
153
  * timestamp: Date,
117
154
  * }} QuestJobStartEvent
118
155
  *
119
156
  * @typedef {{
120
157
  * name: string,
158
+ * runId?: string,
159
+ * runtimeId?: string,
160
+ * sequence?: number,
161
+ * startedAt?: Date,
162
+ * trigger?: 'manual' | 'scheduled',
163
+ * finishedAt?: Date,
164
+ * exitCode?: number | null,
165
+ * signal?: string | null,
166
+ * result?: QuestBusinessResult,
167
+ * logs?: QuestLogs,
121
168
  * inputs: AnyRecord,
122
169
  * duration: number,
123
170
  * timestamp: Date,
@@ -132,6 +179,16 @@
132
179
  *
133
180
  * @typedef {{
134
181
  * name: string,
182
+ * runId?: string,
183
+ * runtimeId?: string,
184
+ * sequence?: number,
185
+ * startedAt?: Date,
186
+ * trigger?: 'manual' | 'scheduled',
187
+ * finishedAt?: Date,
188
+ * exitCode?: number | null,
189
+ * signal?: string | null,
190
+ * result?: QuestBusinessResult,
191
+ * logs?: QuestLogs,
135
192
  * inputs: AnyRecord,
136
193
  * error: QuestJobErrorDetails,
137
194
  * duration: number,
@@ -144,6 +201,8 @@
144
201
  * run(jobNames?: QuestJobNameInput, inputs?: AnyRecord): Promise<QuestExecutionResult[]>,
145
202
  * add(jobDefs: QuestJobDefinitionInput | QuestJobDefinitionInput[]): string[],
146
203
  * remove(jobNames: QuestJobNameInput): string[],
204
+ * getRuntime(): AnyRecord,
205
+ * metadata(name?: string): AnyRecord | AnyRecord[] | undefined,
147
206
  * jobs: QuestJob[],
148
207
  * list(): QuestJob[],
149
208
  * get(name: string): QuestJob | undefined,
@@ -162,17 +221,23 @@
162
221
  * jobs: QuestJobsMap,
163
222
  * timers: QuestTimersMap,
164
223
  * running: QuestRunningMap,
224
+ * sails: QuestSailsApp,
225
+ * runtime: QuestRuntime,
226
+ * dueTimes: Map<string, Date>,
227
+ * scheduleStates?: Map<string, AnyRecord>,
165
228
  * config: QuestConfig | null,
166
229
  * scheduleJob: ((name: string) => void) | null,
167
- * executeJob: ((name: string, customInputs?: AnyRecord) => Promise<QuestExecutionResult>) | null,
230
+ * executeJob: ((name: string, customInputs?: AnyRecord, trigger?: 'manual' | 'scheduled') => Promise<QuestExecutionResult>) | null,
168
231
  * getNextRunTime: ((job: QuestJob) => Date | null) | null,
169
232
  * }} QuestContext
170
233
  *
171
234
  * @typedef {{
172
235
  * jobs?: QuestJobsMap,
173
236
  * timers?: QuestTimersMap,
237
+ * dueTimes?: Map<string, Date>,
238
+ * scheduleStates?: Map<string, AnyRecord>,
174
239
  * getNextRunTime?: (job: QuestJob) => Date | null,
175
- * executeJob?: (name: string, customInputs?: AnyRecord) => Promise<QuestExecutionResult>,
240
+ * executeJob?: (name: string, customInputs?: AnyRecord, trigger?: 'manual' | 'scheduled') => Promise<QuestExecutionResult>,
176
241
  * }} QuestScheduleContext
177
242
  *
178
243
  * @typedef {{
@@ -183,15 +248,20 @@
183
248
  * @typedef {{
184
249
  * jobs?: QuestJobsMap,
185
250
  * timers?: QuestTimersMap,
251
+ * dueTimes?: Map<string, Date>,
252
+ * scheduleStates?: Map<string, AnyRecord>,
186
253
  * }} QuestStopContext
187
254
  *
188
255
  * @typedef {{
189
256
  * jobs?: QuestJobsMap,
190
- * executeJob?: (name: string, customInputs?: AnyRecord) => Promise<QuestExecutionResult>,
257
+ * executeJob?: (name: string, customInputs?: AnyRecord, trigger?: 'manual' | 'scheduled') => Promise<QuestExecutionResult>,
191
258
  * }} QuestRunContext
192
259
  *
193
260
  * @typedef {{
261
+ * trigger?: 'manual' | 'scheduled',
194
262
  * running?: QuestRunningMap,
263
+ * sails?: QuestSailsApp,
264
+ * runtime?: QuestRuntime,
195
265
  * config?: QuestConfig | null,
196
266
  * stdout?: NodeJS.WritableStream,
197
267
  * stderr?: NodeJS.WritableStream,
package/package.json CHANGED
@@ -1,8 +1,15 @@
1
1
  {
2
2
  "name": "sails-hook-quest",
3
- "version": "0.0.5",
3
+ "version": "0.0.6",
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
+ "files": [
7
+ "lib",
8
+ "README.md",
9
+ "CONTRACT.md",
10
+ "CHANGELOG.md",
11
+ "LICENSE"
12
+ ],
6
13
  "scripts": {
7
14
  "test": "sounding test",
8
15
  "lint": "prettier --check .",
@@ -56,6 +63,8 @@
56
63
  "husky": "^9.1.6",
57
64
  "lint-staged": "^15.2.10",
58
65
  "prettier": "^3.3.3",
66
+ "sails": "^1.5.18",
67
+ "sails-hook-orm": "^4.0.3",
59
68
  "sounding": "^0.2.0",
60
69
  "typescript": "^5.6.2"
61
70
  }
@@ -1,7 +0,0 @@
1
- {
2
- "permissions": {
3
- "allow": ["WebFetch(domain:github.com)"],
4
- "deny": [],
5
- "ask": []
6
- }
7
- }
package/.commitlintrc.js DELETED
@@ -1 +0,0 @@
1
- module.exports = { extends: ['@commitlint/config-conventional'] }
@@ -1 +0,0 @@
1
- github: DominusKelvin
@@ -1,22 +0,0 @@
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 .
package/.husky/pre-commit DELETED
@@ -1 +0,0 @@
1
- npx lint-staged
package/.prettierrc.js DELETED
@@ -1,5 +0,0 @@
1
- module.exports = {
2
- semi: false,
3
- singleQuote: true,
4
- trailingComma: 'none'
5
- }
package/jsconfig.json DELETED
@@ -1,17 +0,0 @@
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,95 +0,0 @@
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
- })
@@ -1,84 +0,0 @@
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' })