@usehenri/jobs 0.0.0 → 1.2.0

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/src/cron.js ADDED
@@ -0,0 +1,237 @@
1
+ /**
2
+ * A five field cron parser (minute hour day-of-month month day-of-week).
3
+ *
4
+ * Everything is computed in UTC: a recurring job runs at the same absolute
5
+ * moment wherever the runner is deployed, and no daylight saving change can
6
+ * make an hourly schedule fire twice or not at all. The documentation says
7
+ * so; if a schedule has to follow a wall clock, `every` is not the answer
8
+ * either -- enqueue from a job that knows the zone.
9
+ */
10
+
11
+ const { coded } = require('./errors');
12
+
13
+ const MONTHS = {
14
+ apr: 4,
15
+ aug: 8,
16
+ dec: 12,
17
+ feb: 2,
18
+ jan: 1,
19
+ jul: 7,
20
+ jun: 6,
21
+ mar: 3,
22
+ may: 5,
23
+ nov: 11,
24
+ oct: 10,
25
+ sep: 9,
26
+ };
27
+
28
+ const DAYS = { fri: 5, mon: 1, sat: 6, sun: 0, thu: 4, tue: 2, wed: 3 };
29
+
30
+ const ALIASES = {
31
+ '@annually': '0 0 1 1 *',
32
+ '@daily': '0 0 * * *',
33
+ '@hourly': '0 * * * *',
34
+ '@midnight': '0 0 * * *',
35
+ '@monthly': '0 0 1 * *',
36
+ '@weekly': '0 0 * * 0',
37
+ '@yearly': '0 0 1 1 *',
38
+ };
39
+
40
+ const MINUTE = 60000;
41
+
42
+ /** Four years of minutes is enough for `0 0 29 2 *` (a leap day) */
43
+ const HORIZON = 4 * 366 * 24 * 60;
44
+
45
+ /**
46
+ * The numeric value of one entry of a field
47
+ *
48
+ * @param {string} token The entry (a number or a name)
49
+ * @param {object} names The names this field accepts
50
+ * @param {string} field The field name, for the error
51
+ * @returns {number} The value
52
+ * @throws {Error} When the entry is not a number or a known name
53
+ */
54
+ const value = (token, names, field) => {
55
+ const lower = token.toLowerCase();
56
+
57
+ if (Object.prototype.hasOwnProperty.call(names, lower)) {
58
+ return names[lower];
59
+ }
60
+
61
+ if (!/^\d+$/.test(token)) {
62
+ throw coded(
63
+ 'HENRI_JOB_INVALID_CRON',
64
+ `Invalid ${field} "${token}" in cron expression`
65
+ );
66
+ }
67
+
68
+ return Number(token);
69
+ };
70
+
71
+ /**
72
+ * The allowed values of one cron field
73
+ *
74
+ * @param {string} spec The field (`*`, `1-5`, `0/15`, `mon,wed`)
75
+ * @param {number} min The lowest value of the field
76
+ * @param {number} max The highest value of the field
77
+ * @param {string} name The field name, for the errors
78
+ * @param {object} [names={}] The names the field accepts
79
+ * @returns {Set<number>} The values that match
80
+ * @throws {Error} When the field cannot be parsed
81
+ */
82
+ const field = (spec, min, max, name, names = {}) => {
83
+ const values = new Set();
84
+
85
+ for (const part of String(spec).split(',')) {
86
+ const [range, step = '1'] = part.split('/');
87
+
88
+ if (!/^\d+$/.test(step) || Number(step) < 1) {
89
+ throw coded(
90
+ 'HENRI_JOB_INVALID_CRON',
91
+ `Invalid step "${step}" in ${name} of a cron expression`
92
+ );
93
+ }
94
+
95
+ const by = Number(step);
96
+ let from = min;
97
+ let to = max;
98
+
99
+ if (range !== '*' && range !== '') {
100
+ const [start, end] = range.split('-');
101
+
102
+ from = value(start, names, name);
103
+ to = typeof end === 'undefined' ? from : value(end, names, name);
104
+
105
+ // `5/10` means "from 5 to the end of the field, every 10"
106
+ if (typeof end === 'undefined' && part.includes('/')) {
107
+ to = max;
108
+ }
109
+ }
110
+
111
+ if (from < min || to > max || from > to) {
112
+ throw coded(
113
+ 'HENRI_JOB_INVALID_CRON',
114
+ `Out of range "${part}" in ${name} of a cron expression (${min}-${max})`
115
+ );
116
+ }
117
+
118
+ for (let entry = from; entry <= to; entry += by) {
119
+ values.add(entry);
120
+ }
121
+ }
122
+
123
+ return values;
124
+ };
125
+
126
+ /**
127
+ * Parses a cron expression
128
+ *
129
+ * @param {string} expression Five fields, or `@daily`, `@hourly`, ...
130
+ * @returns {object} The parsed schedule
131
+ * @throws {Error} When the expression is invalid
132
+ */
133
+ const parse = (expression) => {
134
+ const text = String(expression || '').trim();
135
+ const normalized = ALIASES[text.toLowerCase()] || text;
136
+ const parts = normalized.split(/\s+/);
137
+
138
+ if (parts.length !== 5) {
139
+ throw coded(
140
+ 'HENRI_JOB_INVALID_CRON',
141
+ `Invalid cron expression "${expression}": expected 5 fields (minute hour day month weekday)`
142
+ );
143
+ }
144
+
145
+ const [minute, hour, day, month, weekday] = parts;
146
+ const weekdays = field(weekday, 0, 7, 'weekday', DAYS);
147
+
148
+ // Both 0 and 7 are Sunday
149
+ if (weekdays.has(7)) {
150
+ weekdays.add(0);
151
+ weekdays.delete(7);
152
+ }
153
+
154
+ return {
155
+ days: field(day, 1, 31, 'day of month'),
156
+ everyDay: day === '*',
157
+ everyWeekday: weekday === '*',
158
+ hours: field(hour, 0, 23, 'hour'),
159
+ minutes: field(minute, 0, 59, 'minute'),
160
+ months: field(month, 1, 12, 'month', MONTHS),
161
+ weekdays,
162
+ };
163
+ };
164
+
165
+ /**
166
+ * Does this date fall on a day the schedule wants?
167
+ *
168
+ * Cron's oddity: when both the day of the month and the weekday are
169
+ * restricted, either one matching is enough.
170
+ *
171
+ * @param {object} schedule A parsed schedule
172
+ * @param {Date} date The date to test (read in UTC)
173
+ * @returns {boolean} Whether the day matches
174
+ */
175
+ const matchesDay = (schedule, date) => {
176
+ const day = schedule.days.has(date.getUTCDate());
177
+ const weekday = schedule.weekdays.has(date.getUTCDay());
178
+
179
+ if (schedule.everyDay && schedule.everyWeekday) {
180
+ return true;
181
+ }
182
+
183
+ if (schedule.everyDay) {
184
+ return weekday;
185
+ }
186
+
187
+ if (schedule.everyWeekday) {
188
+ return day;
189
+ }
190
+
191
+ return day || weekday;
192
+ };
193
+
194
+ /**
195
+ * The first moment after `from` that the expression matches
196
+ *
197
+ * @param {(string|object)} expression A cron expression or a parsed schedule
198
+ * @param {number} [from=Date.now()] The moment to start from (exclusive)
199
+ * @returns {?number} A timestamp in milliseconds, or null when the
200
+ * expression can never match again (`0 0 30 2 *`)
201
+ * @throws {Error} When the expression is invalid
202
+ */
203
+ const next = (expression, from = Date.now()) => {
204
+ const schedule =
205
+ typeof expression === 'string' ? parse(expression) : expression;
206
+ const date = new Date(Math.floor(from / MINUTE) * MINUTE + MINUTE);
207
+
208
+ for (let guard = 0; guard < HORIZON; guard += 1) {
209
+ if (!schedule.months.has(date.getUTCMonth() + 1)) {
210
+ date.setUTCMonth(date.getUTCMonth() + 1, 1);
211
+ date.setUTCHours(0, 0, 0, 0);
212
+ continue;
213
+ }
214
+
215
+ if (!matchesDay(schedule, date)) {
216
+ date.setUTCDate(date.getUTCDate() + 1);
217
+ date.setUTCHours(0, 0, 0, 0);
218
+ continue;
219
+ }
220
+
221
+ if (!schedule.hours.has(date.getUTCHours())) {
222
+ date.setUTCHours(date.getUTCHours() + 1, 0, 0, 0);
223
+ continue;
224
+ }
225
+
226
+ if (!schedule.minutes.has(date.getUTCMinutes())) {
227
+ date.setUTCMinutes(date.getUTCMinutes() + 1, 0, 0);
228
+ continue;
229
+ }
230
+
231
+ return date.getTime();
232
+ }
233
+
234
+ return null;
235
+ };
236
+
237
+ module.exports = { next, parse };
@@ -0,0 +1,236 @@
1
+ const path = require('path');
2
+ const { globSync } = require('glob');
3
+
4
+ const { duration } = require('./duration');
5
+ const { JobError } = require('./errors');
6
+
7
+ /**
8
+ * Job definitions live in `app/jobs`, in the shape henri already uses for
9
+ * models and controllers: a file exports an object. The name of a job is its
10
+ * path under `app/jobs` without the extension, so `app/jobs/mail/welcome.js`
11
+ * is the job `mail/welcome`.
12
+ */
13
+
14
+ /** The widest a concurrency key may be: the column that holds it */
15
+ const KEY_LENGTH = 190;
16
+
17
+ /**
18
+ * Reads a job's `concurrency` declaration
19
+ *
20
+ * `1` is `{ limit: 1 }`; a `key` is a field of the arguments or a function
21
+ * of them, and a `group` is the name several jobs share a bound under. The
22
+ * default group is the job's own name, so a limit is that job's alone
23
+ * unless it says otherwise.
24
+ *
25
+ * The limit belongs to the **job**, never to the call: an option of
26
+ * `perform()` would let one caller step outside a bound the job declared,
27
+ * which is the one thing a bound is for.
28
+ *
29
+ * @param {string} name The job name
30
+ * @param {(number|object|null)} value What the file declared
31
+ * @returns {?object} `{ group, key, limit }`, or null
32
+ * @throws {JobError} HENRI_JOB_INVALID_CONCURRENCY on anything else
33
+ */
34
+ const concurrency = (name, value) => {
35
+ if (value === null || typeof value === 'undefined' || value === false) {
36
+ return null;
37
+ }
38
+
39
+ const declared = typeof value === 'number' ? { limit: value } : value;
40
+ const refuse = (why) => {
41
+ throw new JobError(
42
+ 'HENRI_JOB_INVALID_CONCURRENCY',
43
+ `The job "${name}" declares a concurrency limit that cannot be read: ${why}`,
44
+ {
45
+ hint: '`concurrency: 3`, or `concurrency: { limit: 3, key: "tenantId" }` to bound each key of its own',
46
+ job: name,
47
+ }
48
+ );
49
+ };
50
+
51
+ if (typeof declared !== 'object') {
52
+ refuse('it is neither a number nor an object');
53
+ }
54
+
55
+ const limit = Number(declared.limit);
56
+
57
+ if (!Number.isInteger(limit) || limit < 1) {
58
+ refuse(
59
+ `its limit is ${JSON.stringify(declared.limit)}, not a whole number above zero`
60
+ );
61
+ }
62
+
63
+ const group =
64
+ typeof declared.group === 'undefined' || declared.group === null
65
+ ? name
66
+ : declared.group;
67
+
68
+ if (typeof group !== 'string' || group === '') {
69
+ refuse('its group is not a name');
70
+ }
71
+
72
+ if (group.length > KEY_LENGTH) {
73
+ refuse(`its group is longer than ${KEY_LENGTH} characters`);
74
+ }
75
+
76
+ const { key } = declared;
77
+
78
+ if (
79
+ typeof key !== 'undefined' &&
80
+ key !== null &&
81
+ typeof key !== 'string' &&
82
+ typeof key !== 'function'
83
+ ) {
84
+ refuse('its key is neither the name of an argument nor a function of them');
85
+ }
86
+
87
+ return {
88
+ group,
89
+ key:
90
+ typeof key === 'string'
91
+ ? (args) => (args ? args[key] : null)
92
+ : key || null,
93
+ limit,
94
+ };
95
+ };
96
+
97
+ /**
98
+ * The concurrency key of one call, or null when the job is unbounded
99
+ *
100
+ * A key that resolves to nothing is the group's own bucket, which is also
101
+ * where a job enqueued before the limit was declared sits: the two mean the
102
+ * same thing, so they share a bound rather than each getting one.
103
+ *
104
+ * @param {object} definition A validated definition
105
+ * @param {*} args What perform() will receive
106
+ * @returns {?string} The key to store
107
+ * @throws {JobError} HENRI_JOB_INVALID_CONCURRENCY when the key cannot be read
108
+ */
109
+ const keyOf = (definition, args) => {
110
+ const bound = definition && definition.concurrency;
111
+
112
+ if (!bound) {
113
+ return null;
114
+ }
115
+
116
+ if (!bound.key) {
117
+ return bound.group;
118
+ }
119
+
120
+ let value;
121
+
122
+ try {
123
+ value = bound.key(args);
124
+ } catch (error) {
125
+ throw new JobError(
126
+ 'HENRI_JOB_INVALID_CONCURRENCY',
127
+ `The concurrency key of "${definition.name}" could not be read: ${error.message}`,
128
+ { cause: error, job: definition.name }
129
+ );
130
+ }
131
+
132
+ if (value === null || typeof value === 'undefined' || value === '') {
133
+ return bound.group;
134
+ }
135
+
136
+ if (typeof value === 'object') {
137
+ throw new JobError(
138
+ 'HENRI_JOB_INVALID_CONCURRENCY',
139
+ `The concurrency key of "${definition.name}" is an object; it has to be a value that names one bound`,
140
+ { job: definition.name }
141
+ );
142
+ }
143
+
144
+ const key = `${bound.group}:${String(value)}`;
145
+
146
+ if (key.length > KEY_LENGTH) {
147
+ throw new JobError(
148
+ 'HENRI_JOB_INVALID_CONCURRENCY',
149
+ `The concurrency key of "${definition.name}" is ${key.length} characters, over the ${KEY_LENGTH} that are stored`,
150
+ {
151
+ hint: 'A key names a bound, so it is an id or a tenant name; hash it yourself if it has to be longer',
152
+ job: definition.name,
153
+ }
154
+ );
155
+ }
156
+
157
+ return key;
158
+ };
159
+
160
+ /**
161
+ * Reads and checks one definition
162
+ *
163
+ * @param {string} name The job name
164
+ * @param {object} definition What the file exports
165
+ * @param {object} defaults The queue defaults (`queue`, `maxAttempts`, ...)
166
+ * @returns {object} The definition, with the defaults filled in
167
+ * @throws {JobError} HENRI_JOB_INVALID_DEFINITION without a `perform`
168
+ */
169
+ const validate = (name, definition, defaults) => {
170
+ if (!definition || typeof definition.perform !== 'function') {
171
+ throw new JobError(
172
+ 'HENRI_JOB_INVALID_DEFINITION',
173
+ `app/jobs/${name}.js does not export a perform(args, context) function`,
174
+ { job: name }
175
+ );
176
+ }
177
+
178
+ const backoff = definition.backoff || {};
179
+
180
+ return {
181
+ backoff: {
182
+ base: duration(backoff.base, defaults.backoff.base),
183
+ factor: Number(backoff.factor) || defaults.backoff.factor,
184
+ jitter:
185
+ typeof backoff.jitter === 'number'
186
+ ? backoff.jitter
187
+ : defaults.backoff.jitter,
188
+ max: duration(backoff.max, defaults.backoff.max),
189
+ },
190
+ concurrency: concurrency(name, definition.concurrency),
191
+ maxAttempts: Math.max(
192
+ 1,
193
+ Number(definition.maxAttempts) || defaults.maxAttempts
194
+ ),
195
+ name,
196
+ perform: definition.perform,
197
+ priority:
198
+ typeof definition.priority === 'number'
199
+ ? definition.priority
200
+ : defaults.priority,
201
+ queue: definition.queue || defaults.queue,
202
+ timeout: duration(definition.timeout, defaults.timeout),
203
+ };
204
+ };
205
+
206
+ /**
207
+ * Loads every job of an application
208
+ *
209
+ * @param {string} location The `app/jobs` directory
210
+ * @param {object} defaults The queue defaults
211
+ * @returns {object} The definitions, by name
212
+ * @throws {JobError} BAD_JOB when a file is not a job
213
+ */
214
+ const load = (location, defaults) => {
215
+ const dirname = path.resolve(location);
216
+ const definitions = {};
217
+ const files = globSync('**/*.js', {
218
+ cwd: dirname,
219
+ ignore: ['**/node_modules/**'],
220
+ nodir: true,
221
+ posix: true,
222
+ }).sort();
223
+
224
+ for (const file of files) {
225
+ const full = path.join(dirname, file);
226
+ const name = file.replace(/\.js$/, '');
227
+
228
+ delete require.cache[require.resolve(full)];
229
+
230
+ definitions[name] = validate(name, require(full), defaults);
231
+ }
232
+
233
+ return definitions;
234
+ };
235
+
236
+ module.exports = { KEY_LENGTH, concurrency, keyOf, load, validate };
@@ -0,0 +1,112 @@
1
+ /**
2
+ * Durations are written the way a human says them (`'30s'`, `'5m'`, `'2h'`,
3
+ * `'1d'`) or in milliseconds. Everything the queue stores is milliseconds.
4
+ */
5
+
6
+ const { coded } = require('./errors');
7
+
8
+ // A Map, not an object: the keys are one letter long on purpose
9
+ const UNITS = new Map([
10
+ ['ms', 1],
11
+ ['s', 1000],
12
+ ['m', 60000],
13
+ ['h', 3600000],
14
+ ['d', 86400000],
15
+ ['w', 604800000],
16
+ ]);
17
+
18
+ /**
19
+ * The written form: an amount, then a unit.
20
+ *
21
+ * The surrounding whitespace is trimmed before this runs rather than
22
+ * matched by it. `^\s*…\s*$` around an optional group is quadratic: on
23
+ * `'1' + ' '.repeat(60000) + '!'` the two star quantifiers split the run
24
+ * between them one position at a time, which measured 2 seconds before this
25
+ * was one `\s*` with nothing to share with. A duration reaches here from
26
+ * `--in=` and from whatever an application passes to `enqueue()`, so it is
27
+ * not always a value the author typed.
28
+ */
29
+ const PATTERN = /^(\d+(?:\.\d+)?)\s*(ms|[smhdw])?$/i;
30
+
31
+ /**
32
+ * Milliseconds of a duration
33
+ *
34
+ * @param {(number|string|null)} value `'5m'`, `300000`, or nothing
35
+ * @param {?number} [fallback=null] What to answer for an empty value
36
+ * @returns {?number} The duration in milliseconds
37
+ * @throws {Error} When the value is not a duration
38
+ */
39
+ const duration = (value, fallback = null) => {
40
+ if (value === null || typeof value === 'undefined' || value === '') {
41
+ return fallback;
42
+ }
43
+
44
+ if (typeof value === 'number') {
45
+ if (!Number.isFinite(value) || value < 0) {
46
+ throw coded('HENRI_JOB_INVALID_DURATION', `Invalid duration: ${value}`);
47
+ }
48
+
49
+ return Math.round(value);
50
+ }
51
+
52
+ const match = PATTERN.exec(String(value).trim());
53
+
54
+ if (!match) {
55
+ throw coded(
56
+ 'HENRI_JOB_INVALID_DURATION',
57
+ `Invalid duration "${value}": use a number of milliseconds or 30s, 5m, 2h, 1d`
58
+ );
59
+ }
60
+
61
+ const [, amount, unit] = match;
62
+
63
+ return Math.round(Number(amount) * UNITS.get((unit || 'ms').toLowerCase()));
64
+ };
65
+
66
+ /**
67
+ * The moment a job should run, from `at` or `wait`
68
+ *
69
+ * @param {object} [options={}] `at` (a Date, an ISO string or a timestamp)
70
+ * and `wait` (a duration from now)
71
+ * @param {number} [now=Date.now()] The current time
72
+ * @returns {number} A timestamp in milliseconds
73
+ * @throws {Error} When `at` is not a date
74
+ */
75
+ const runAt = (options = {}, now = Date.now()) => {
76
+ const { at, wait } = options;
77
+
78
+ if (typeof at !== 'undefined' && at !== null) {
79
+ const date = at instanceof Date ? at : new Date(at);
80
+ const time = date.getTime();
81
+
82
+ if (Number.isNaN(time)) {
83
+ throw coded('HENRI_JOB_INVALID_DURATION', `Invalid date: ${String(at)}`);
84
+ }
85
+
86
+ return time;
87
+ }
88
+
89
+ return now + (duration(wait, 0) || 0);
90
+ };
91
+
92
+ /**
93
+ * A stored moment, as the API hands it out
94
+ *
95
+ * Every moment the queue stores is a BIGINT of milliseconds, and every
96
+ * moment it answers with is an ISO string. Some drivers read a BIGINT back
97
+ * as a string, so the number is taken first.
98
+ *
99
+ * @param {*} value A timestamp in milliseconds
100
+ * @returns {?string} An ISO string, or null
101
+ */
102
+ const iso = (value) => {
103
+ if (value === null || typeof value === 'undefined' || value === '') {
104
+ return null;
105
+ }
106
+
107
+ const number = Number(value);
108
+
109
+ return Number.isNaN(number) ? null : new Date(number).toISOString();
110
+ };
111
+
112
+ module.exports = { duration, iso, runAt };
package/src/errors.js ADDED
@@ -0,0 +1,115 @@
1
+ /**
2
+ * The errors `@usehenri/jobs` throws.
3
+ *
4
+ * Every one of them carries a `code`, so an application (or a test) can
5
+ * branch on the reason instead of matching the message. The codes are
6
+ * henri's own, from the catalogue of `@usehenri/core/error-codes.json`: a
7
+ * code is a string, so nothing is imported to raise one.
8
+ */
9
+
10
+ /**
11
+ * Base error of the package
12
+ *
13
+ * @class JobError
14
+ * @extends {Error}
15
+ */
16
+ class JobError extends Error {
17
+ /**
18
+ * Creates an instance of JobError.
19
+ *
20
+ * @param {string} code A henri error code (ex: HENRI_JOB_UNKNOWN)
21
+ * @param {string} message What went wrong
22
+ * @param {object} [options={}] `cause` and any extra property to carry
23
+ * @memberof JobError
24
+ */
25
+ constructor(code, message, options = {}) {
26
+ const { cause, ...rest } = options;
27
+
28
+ super(message, cause ? { cause } : undefined);
29
+
30
+ this.name = 'JobError';
31
+ this.code = code;
32
+ Object.assign(this, rest);
33
+ }
34
+ }
35
+
36
+ /**
37
+ * An argument that cannot be stored (a function, a bigint, a cycle, ...)
38
+ *
39
+ * @class JobArgumentError
40
+ * @extends {JobError}
41
+ */
42
+ class JobArgumentError extends JobError {
43
+ /**
44
+ * Creates an instance of JobArgumentError.
45
+ *
46
+ * @param {string} message What went wrong
47
+ * @param {object} [options={}] `path` and the usual options
48
+ * @memberof JobArgumentError
49
+ */
50
+ constructor(message, options = {}) {
51
+ super('HENRI_JOB_INVALID_ARGUMENTS', message, options);
52
+ this.name = 'JobArgumentError';
53
+ }
54
+ }
55
+
56
+ /**
57
+ * An attempt that ran past the job's timeout
58
+ *
59
+ * @class JobTimeoutError
60
+ * @extends {JobError}
61
+ */
62
+ class JobTimeoutError extends JobError {
63
+ /**
64
+ * Creates an instance of JobTimeoutError.
65
+ *
66
+ * @param {string} name The job name
67
+ * @param {number} timeout The timeout in milliseconds
68
+ * @memberof JobTimeoutError
69
+ */
70
+ constructor(name, timeout) {
71
+ super('HENRI_JOB_TIMEOUT', `${name} timed out after ${timeout}ms`);
72
+ this.name = 'JobTimeoutError';
73
+ this.timeout = timeout;
74
+ }
75
+ }
76
+
77
+ /**
78
+ * A store that cannot back the queue
79
+ *
80
+ * @class JobStoreError
81
+ * @extends {JobError}
82
+ */
83
+ class JobStoreError extends JobError {
84
+ /**
85
+ * Creates an instance of JobStoreError.
86
+ *
87
+ * @param {string} message What went wrong
88
+ * @param {object} [options={}] The usual options
89
+ * @memberof JobStoreError
90
+ */
91
+ constructor(message, options = {}) {
92
+ super('HENRI_JOB_UNSUPPORTED_STORE', message, options);
93
+ this.name = 'JobStoreError';
94
+ }
95
+ }
96
+
97
+ /**
98
+ * An Error carrying one of henri's error codes
99
+ *
100
+ * A code is a string and nothing more (`@usehenri/core/error-codes.json` is
101
+ * the catalogue), so a failure names itself without importing anything.
102
+ *
103
+ * @param {string} code The henri error code (HENRI_JOB_UNKNOWN, ...)
104
+ * @param {string} message What went wrong
105
+ * @returns {Error} The error to throw
106
+ */
107
+ const coded = (code, message) => Object.assign(new Error(message), { code });
108
+
109
+ module.exports = {
110
+ JobArgumentError,
111
+ JobError,
112
+ JobStoreError,
113
+ JobTimeoutError,
114
+ coded,
115
+ };