@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.
@@ -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 };