@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/CHANGELOG.md +311 -0
- package/LICENSE +21 -0
- package/README.md +8 -1
- package/index.js +45 -0
- package/module.js +8 -0
- package/package.json +50 -10
- package/src/batch.js +379 -0
- package/src/config.js +186 -0
- package/src/cron.js +237 -0
- package/src/definitions.js +236 -0
- package/src/duration.js +112 -0
- package/src/errors.js +115 -0
- package/src/jobs.js +1839 -0
- package/src/keys.js +65 -0
- package/src/module.js +442 -0
- package/src/runner.js +918 -0
- package/src/serialize.js +177 -0
- package/src/store/index.js +37 -0
- package/src/store/mongo.js +1334 -0
- package/src/store/schema.js +499 -0
- package/src/store/sql.js +1744 -0
package/src/serialize.js
ADDED
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
const { JobArgumentError } = require('./errors');
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Arguments travel through the database as JSON, so they have to survive a
|
|
5
|
+
* round trip through `JSON.stringify`. `JSON.stringify` drops functions and
|
|
6
|
+
* `undefined` silently and turns a `Date` into a string without saying so,
|
|
7
|
+
* which is exactly the kind of thing that is discovered in production three
|
|
8
|
+
* weeks later: this module refuses what cannot be stored, naming the path.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
/** How big the serialized arguments of one job may get */
|
|
12
|
+
const MAX_BYTES = 512 * 1024;
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* A readable path for an error message (`args.user.id`)
|
|
16
|
+
*
|
|
17
|
+
* @param {Array<string>} parts The path segments
|
|
18
|
+
* @returns {string} The path
|
|
19
|
+
*/
|
|
20
|
+
const label = (parts) =>
|
|
21
|
+
parts.length === 0 ? 'args' : `args.${parts.join('.')}`;
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Walks a value and throws on anything JSON cannot carry
|
|
25
|
+
*
|
|
26
|
+
* @param {*} value The value to check
|
|
27
|
+
* @param {Array<string>} path Where it sits in the arguments
|
|
28
|
+
* @param {Set} seen The objects already visited on this branch
|
|
29
|
+
* @returns {void}
|
|
30
|
+
* @throws {JobArgumentError} When the value cannot be stored
|
|
31
|
+
*/
|
|
32
|
+
const walk = (value, path, seen) => {
|
|
33
|
+
const type = typeof value;
|
|
34
|
+
|
|
35
|
+
if (value === null || type === 'string' || type === 'boolean') {
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
if (type === 'number') {
|
|
40
|
+
if (!Number.isFinite(value)) {
|
|
41
|
+
throw new JobArgumentError(
|
|
42
|
+
`${label(path)} is ${String(value)}, which JSON stores as null`,
|
|
43
|
+
{ path: label(path) }
|
|
44
|
+
);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
if (type === 'undefined') {
|
|
51
|
+
throw new JobArgumentError(
|
|
52
|
+
`${label(path)} is undefined, which JSON drops silently: use null`,
|
|
53
|
+
{ path: label(path) }
|
|
54
|
+
);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
if (type === 'function' || type === 'symbol' || type === 'bigint') {
|
|
58
|
+
throw new JobArgumentError(
|
|
59
|
+
`${label(path)} is a ${type}, which cannot be stored: pass a plain value`,
|
|
60
|
+
{ path: label(path) }
|
|
61
|
+
);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
if (value instanceof Date) {
|
|
65
|
+
if (Number.isNaN(value.getTime())) {
|
|
66
|
+
throw new JobArgumentError(`${label(path)} is an invalid Date`, {
|
|
67
|
+
path: label(path),
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
if (seen.has(value)) {
|
|
75
|
+
throw new JobArgumentError(
|
|
76
|
+
`${label(path)} is a circular reference, which cannot be stored`,
|
|
77
|
+
{ path: label(path) }
|
|
78
|
+
);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
seen.add(value);
|
|
82
|
+
|
|
83
|
+
if (Array.isArray(value)) {
|
|
84
|
+
value.forEach((entry, index) => walk(entry, [...path, index], seen));
|
|
85
|
+
seen.delete(value);
|
|
86
|
+
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const prototype = Object.getPrototypeOf(value);
|
|
91
|
+
|
|
92
|
+
if (prototype !== Object.prototype && prototype !== null) {
|
|
93
|
+
// A model instance, a Map, a Buffer: JSON keeps a shape nobody expects
|
|
94
|
+
const kind = value.constructor ? value.constructor.name : 'object';
|
|
95
|
+
|
|
96
|
+
throw new JobArgumentError(
|
|
97
|
+
`${label(path)} is a ${kind} instance, which cannot be stored: pass its id or a plain object`,
|
|
98
|
+
{ path: label(path) }
|
|
99
|
+
);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
for (const key of Object.keys(value)) {
|
|
103
|
+
walk(value[key], [...path, key], seen);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
seen.delete(value);
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Turns the arguments of a job into the JSON stored in the queue
|
|
111
|
+
*
|
|
112
|
+
* Strings, finite numbers, booleans, null, plain objects, arrays and `Date`
|
|
113
|
+
* (stored as its ISO string) are accepted. Anything else -- `undefined`, a
|
|
114
|
+
* function, a symbol, a bigint, NaN, a circular reference, an instance of a
|
|
115
|
+
* class (a model, a Buffer, a Map) -- is refused with the path that holds it.
|
|
116
|
+
*
|
|
117
|
+
* @param {*} [args] The arguments given to perform()
|
|
118
|
+
* @param {object} [options={}] Options
|
|
119
|
+
* @param {number} [options.maxBytes=524288] The size limit of the JSON
|
|
120
|
+
* @returns {string} The JSON to store
|
|
121
|
+
* @throws {JobArgumentError} When the arguments cannot be stored
|
|
122
|
+
*/
|
|
123
|
+
const serialize = (args, options = {}) => {
|
|
124
|
+
const maxBytes = options.maxBytes || MAX_BYTES;
|
|
125
|
+
const value = typeof args === 'undefined' ? null : args;
|
|
126
|
+
|
|
127
|
+
walk(value, [], new Set());
|
|
128
|
+
|
|
129
|
+
const json = JSON.stringify(value);
|
|
130
|
+
const size = Buffer.byteLength(json, 'utf8');
|
|
131
|
+
|
|
132
|
+
if (size > maxBytes) {
|
|
133
|
+
throw new JobArgumentError(
|
|
134
|
+
`arguments are ${size} bytes, over the ${maxBytes} bytes limit: store the payload and pass its id`,
|
|
135
|
+
{ path: 'args' }
|
|
136
|
+
);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
return json;
|
|
140
|
+
};
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Reads back what serialize() stored
|
|
144
|
+
*
|
|
145
|
+
* Nothing readable answers null, which is what a listing wants. A runner
|
|
146
|
+
* asks for `strict`: performing a job with `null` arguments because the
|
|
147
|
+
* column truncated them would be worse than failing the attempt.
|
|
148
|
+
*
|
|
149
|
+
* @param {?string} json The stored JSON
|
|
150
|
+
* @param {object} [options={}] `strict` throws on unreadable JSON
|
|
151
|
+
* @returns {*} The arguments (null when there were none)
|
|
152
|
+
* @throws {JobArgumentError} With `strict`, when the JSON cannot be read
|
|
153
|
+
*/
|
|
154
|
+
const deserialize = (json, options = {}) => {
|
|
155
|
+
if (json === null || typeof json === 'undefined' || json === '') {
|
|
156
|
+
return null;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
if (typeof json !== 'string') {
|
|
160
|
+
return json;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
try {
|
|
164
|
+
return JSON.parse(json);
|
|
165
|
+
} catch (error) {
|
|
166
|
+
if (options.strict) {
|
|
167
|
+
throw new JobArgumentError(
|
|
168
|
+
`the stored arguments are not readable JSON (${json.length} bytes): they were written by an older version, or the column truncated them`,
|
|
169
|
+
{ cause: error, path: 'args' }
|
|
170
|
+
);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
return null;
|
|
174
|
+
}
|
|
175
|
+
};
|
|
176
|
+
|
|
177
|
+
module.exports = { MAX_BYTES, deserialize, serialize };
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
const mongo = require('./mongo');
|
|
2
|
+
const sql = require('./sql');
|
|
3
|
+
|
|
4
|
+
const { JobStoreError } = require('../errors');
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Picks the backend of a store adapter
|
|
8
|
+
*
|
|
9
|
+
* The queue talks to the database the application already runs, through the
|
|
10
|
+
* adapter's own surface: `query()` on the SQL adapters (sequelize and its
|
|
11
|
+
* dialect packages, drizzle), the MongoDB collections on the mongoose and
|
|
12
|
+
* disk adapters. No henri model is involved either way.
|
|
13
|
+
*
|
|
14
|
+
* @param {object} adapter A henri store adapter
|
|
15
|
+
* @param {object} tables `{ jobs, schedules }` table names
|
|
16
|
+
* @returns {object} A store backend
|
|
17
|
+
* @throws {JobStoreError} When the adapter cannot back a queue
|
|
18
|
+
*/
|
|
19
|
+
const storeFor = (adapter, tables) => {
|
|
20
|
+
if (!adapter) {
|
|
21
|
+
throw new JobStoreError('@usehenri/jobs: no store to back the queue');
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
if (adapter.mongoose) {
|
|
25
|
+
return mongo.create(adapter, tables);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
if (typeof adapter.query !== 'function') {
|
|
29
|
+
throw new JobStoreError(
|
|
30
|
+
`@usehenri/jobs: the ${adapter.adapterName || 'unknown'} adapter has neither query() nor a MongoDB connection`
|
|
31
|
+
);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
return sql.create(adapter, tables);
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
module.exports = { mongo, sql, storeFor };
|