@sqlane/core 0.0.1 → 0.1.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/README.md CHANGED
@@ -4,15 +4,53 @@
4
4
 
5
5
  Transactional SQL job queue for Node.js, backed by MySQL or PostgreSQL.
6
6
 
7
- > Work in progress: this version is a name placeholder, with no code yet. See the [design](docs/design.md).
8
-
9
- ## Goals
7
+ > Early version (0.0.x): the API can still change. See the [design](docs/design.md).
10
8
 
11
9
  - Enqueue a job in your business transaction: no commit, no job.
12
- - Many queues at low cost: idle queues add no load to the database.
10
+ - Many job names at low cost: idle names add no load to the database.
13
11
  - Low latency: a job starts in milliseconds after the commit.
14
- - MySQL and PostgreSQL.
15
- - MIT license.
12
+ - Mutex keys: jobs with a common key never run at the same time, in the whole cluster.
13
+ - MySQL 8.4+ and PostgreSQL 15+, with `mysql2` or `pg`. Node.js 22+. MIT license.
14
+
15
+ ## Usage
16
+
17
+ ```sh
18
+ npm install @sqlane/core mysql2 # or pg
19
+ ```
20
+
21
+ ```js
22
+ import mysql from 'mysql2/promise';
23
+ import { createSqlane, defineJob } from '@sqlane/core';
24
+ import { mysql2Adapter } from '@sqlane/core/mysql2'; // or pgAdapter from '@sqlane/core/pg'
25
+
26
+ // Shared by producers and workers
27
+ export const buildReport = defineJob({
28
+ name: 'build_report',
29
+ maxAttempts: 5,
30
+ backoff: { type: 'exponential', delay: '5s', maxDelay: '1h' },
31
+ });
32
+
33
+ const queuePool = mysql.createPool({ uri: process.env.DATABASE_URL, connectionLimit: 3 });
34
+ const sqlane = createSqlane({ adapter: mysql2Adapter(queuePool), jobs: [buildReport] });
35
+
36
+ await sqlane.migrate(); // or getMigrations({ dialect: 'mysql' }) for your own migration tool
37
+
38
+ // Producer: with the connection of your transaction
39
+ await connection.beginTransaction();
40
+ await connection.query('INSERT INTO reports (id) VALUES (?)', [reportId]);
41
+ await sqlane.enqueue(connection, buildReport, { reportId }, { jobId: reportId });
42
+ await connection.commit();
43
+
44
+ // Worker
45
+ sqlane.handle(buildReport, async ({ payload, signal }) => {
46
+ await build(payload.reportId, { signal });
47
+ }, { concurrency: 2, timeout: '5m' });
48
+ await sqlane.start();
49
+
50
+ process.once('SIGTERM', () => sqlane.close({ deadline: '30s' }));
51
+ ```
52
+
53
+ In tests, run the due jobs without `start()`: `await sqlane.drain()`. More in [`examples/`](examples).
16
54
 
17
55
  ## Development
18
56
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sqlane/core",
3
- "version": "0.0.1",
3
+ "version": "0.1.0",
4
4
  "description": "Transactional SQL job queue for Node.js (MySQL, PostgreSQL)",
5
5
  "keywords": [
6
6
  "background-jobs",
@@ -26,11 +26,17 @@
26
26
  ],
27
27
  "type": "module",
28
28
  "exports": {
29
- ".": "./src/index.js"
29
+ ".": "./src/index.js",
30
+ "./mysql2": "./src/adapters/mysql2.js",
31
+ "./pg": "./src/adapters/pg.js"
30
32
  },
31
33
  "publishConfig": {
32
34
  "access": "public"
33
35
  },
36
+ "dependencies": {
37
+ "parse-duration": "^2.1.11",
38
+ "ulid": "^3.0.2"
39
+ },
34
40
  "devDependencies": {
35
41
  "@changesets/changelog-github": "1.0.1",
36
42
  "@changesets/cli": "3.0.3",
@@ -49,6 +55,18 @@
49
55
  "typescript": "7.0.2",
50
56
  "vitest": "5.0.5"
51
57
  },
58
+ "peerDependencies": {
59
+ "mysql2": "^3.11.0",
60
+ "pg": "^8.13.0"
61
+ },
62
+ "peerDependenciesMeta": {
63
+ "mysql2": {
64
+ "optional": true
65
+ },
66
+ "pg": {
67
+ "optional": true
68
+ }
69
+ },
52
70
  "engines": {
53
71
  "node": ">=22.12.0"
54
72
  },
@@ -0,0 +1,96 @@
1
+ /**
2
+ * @typedef {import('mysql2/promise').Pool
3
+ * | import('mysql2/promise').PoolConnection
4
+ * | import('mysql2/promise').Connection} Mysql2Connection
5
+ */
6
+
7
+ /**
8
+ * @param {Mysql2Connection} connection
9
+ * @returns {import('../types.js').Executor}
10
+ */
11
+ function toExecutor(connection) {
12
+ return {
13
+ query: async (sql, params) => {
14
+ // The three types have the same query(); TS cannot call a method of a union of overloads.
15
+ const queryable = /** @type {import('mysql2/promise').Connection} */ (connection);
16
+ const [rows] = await queryable.query(sql, [...params]);
17
+
18
+ return Array.isArray(rows) ? /** @type {Record<string, unknown>[]} */ (rows) : [];
19
+ },
20
+ run: async (sql, params) => {
21
+ const queryable = /** @type {import('mysql2/promise').Connection} */ (connection);
22
+ const [result] = await queryable.query(sql, [...params]);
23
+
24
+ return /** @type {import('mysql2/promise').ResultSetHeader} */ (result).affectedRows;
25
+ },
26
+ };
27
+ }
28
+
29
+ /**
30
+ * @param {Mysql2Connection} connection
31
+ * @returns {connection is import('mysql2/promise').Pool}
32
+ */
33
+ function isPool(connection) {
34
+ return typeof (/** @type {{ getConnection?: unknown }} */ (connection).getConnection) === 'function';
35
+ }
36
+
37
+ /**
38
+ * Runs func in a transaction of its own, on a connection of the pool.
39
+ * @template T
40
+ * @param {import('mysql2/promise').Pool} pool
41
+ * @param {(executor: import('../types.js').Executor) => Promise<T>} func
42
+ * @param {boolean} [readCommitted] Default: false: the isolation level of the session.
43
+ * @returns {Promise<T>}
44
+ */
45
+ async function inOwnTransaction(pool, func, readCommitted = false) {
46
+ const connection = await pool.getConnection();
47
+
48
+ try {
49
+ if (readCommitted) {
50
+ // For the next transaction only
51
+ await connection.query('SET TRANSACTION ISOLATION LEVEL READ COMMITTED');
52
+ }
53
+
54
+ await connection.beginTransaction();
55
+
56
+ try {
57
+ const result = await func(toExecutor(connection));
58
+
59
+ await connection.commit();
60
+
61
+ return result;
62
+ } catch (error) {
63
+ await connection.rollback();
64
+ throw error;
65
+ }
66
+ } finally {
67
+ connection.release();
68
+ }
69
+ }
70
+
71
+ /**
72
+ * The adapter for mysql2 (its promise API). `enqueue` then takes a `Connection`, a `PoolConnection` (in a
73
+ * transaction) or a `Pool`.
74
+ * @param {import('mysql2/promise').Pool} pool The queue's own pool: claims, leases, results.
75
+ * @returns {import('../types.js').Adapter<Mysql2Connection>}
76
+ */
77
+ export function mysql2Adapter(pool) {
78
+ return {
79
+ dialect: 'mysql',
80
+ wrap: toExecutor,
81
+ queue: toExecutor(pool),
82
+ withConnection: async (func) => {
83
+ const connection = await pool.getConnection();
84
+
85
+ try {
86
+ return await func(toExecutor(connection));
87
+ } finally {
88
+ connection.release();
89
+ }
90
+ },
91
+ queueTransaction: func => inOwnTransaction(pool, func, true),
92
+ inTransaction: (connection, func) => (isPool(connection)
93
+ ? inOwnTransaction(connection, func)
94
+ : func(toExecutor(connection))),
95
+ };
96
+ }
@@ -0,0 +1,164 @@
1
+ /** @typedef {import('pg').Pool | import('pg').PoolClient | import('pg').Client} PgConnection */
2
+
3
+ /**
4
+ * @typedef {object} PgAdapterOptions
5
+ * @property {boolean} [notify] Hints by LISTEN/NOTIFY: `pg_notify` in the enqueue transaction, delivered on commit.
6
+ * Default: false.
7
+ */
8
+
9
+ /**
10
+ * @param {PgConnection} connection
11
+ * @returns {import('../types.js').Executor}
12
+ */
13
+ function toExecutor(connection) {
14
+ return {
15
+ query: async (sql, params) => {
16
+ const result = await connection.query(sql, [...params]);
17
+
18
+ return result.rows;
19
+ },
20
+ run: async (sql, params) => {
21
+ const result = await connection.query(sql, [...params]);
22
+
23
+ return result.rowCount ?? 0;
24
+ },
25
+ };
26
+ }
27
+
28
+ /**
29
+ * @param {PgConnection} connection
30
+ * @returns {connection is import('pg').Pool}
31
+ */
32
+ function isPool(connection) {
33
+ return typeof (/** @type {{ totalCount?: unknown }} */ (connection).totalCount) === 'number';
34
+ }
35
+
36
+ /**
37
+ * Runs func in a transaction of its own, on a client of the pool.
38
+ * @template T
39
+ * @param {import('pg').Pool} pool
40
+ * @param {(executor: import('../types.js').Executor) => Promise<T>} func
41
+ * @param {boolean} [readCommitted] Default: false: the isolation level of the session.
42
+ * @returns {Promise<T>}
43
+ */
44
+ async function inOwnTransaction(pool, func, readCommitted = false) {
45
+ const client = await pool.connect();
46
+
47
+ try {
48
+ await client.query(readCommitted ? 'BEGIN ISOLATION LEVEL READ COMMITTED' : 'BEGIN');
49
+
50
+ try {
51
+ const result = await func(toExecutor(client));
52
+
53
+ await client.query('COMMIT');
54
+
55
+ return result;
56
+ } catch (error) {
57
+ await client.query('ROLLBACK');
58
+ throw error;
59
+ }
60
+ } finally {
61
+ client.release();
62
+ }
63
+ }
64
+
65
+ /**
66
+ * LISTEN on a client of its own, taken from the pool; on an error of the client, listens again on a new one.
67
+ * @param {import('pg').Pool} pool
68
+ * @param {string} channel A safe identifier: `<tablePrefix>ready`.
69
+ * @param {(message: string) => void} onMessage
70
+ * @returns {Promise<() => Promise<void>>} Unlisten.
71
+ */
72
+ async function listen(pool, channel, onMessage) {
73
+ /** @type {import('pg').PoolClient | null} */
74
+ let client = null;
75
+ let stopped = false;
76
+
77
+ /** @param {import('pg').Notification} notification */
78
+ const onNotification = (notification) => {
79
+ if (notification.channel === channel) {
80
+ onMessage(notification.payload ?? '');
81
+ }
82
+ };
83
+
84
+ /** @returns {Promise<void>} */
85
+ const connect = async () => {
86
+ const next = await pool.connect();
87
+
88
+ next.on('notification', onNotification);
89
+ next.once('error', (error) => {
90
+ next.release(error);
91
+
92
+ if (client === next) {
93
+ client = null;
94
+ }
95
+
96
+ reconnectLater();
97
+ });
98
+ await next.query(`LISTEN "${channel}"`);
99
+ client = next;
100
+ };
101
+
102
+ /** Listens again in 1 s, until it works or the listener stops. */
103
+ function reconnectLater() {
104
+ if (stopped) {
105
+ return;
106
+ }
107
+
108
+ setTimeout(async () => {
109
+ try {
110
+ await connect();
111
+ } catch {
112
+ reconnectLater();
113
+ }
114
+ }, 1000).unref();
115
+ }
116
+
117
+ await connect();
118
+
119
+ return async () => {
120
+ stopped = true;
121
+
122
+ const current = client;
123
+
124
+ client = null;
125
+
126
+ if (current) {
127
+ try {
128
+ await current.query(`UNLISTEN "${channel}"`);
129
+ } finally {
130
+ current.removeListener('notification', onNotification);
131
+ current.release();
132
+ }
133
+ }
134
+ };
135
+ }
136
+
137
+ /**
138
+ * The adapter for pg. `enqueue` then takes a `Client`, a `PoolClient` (in a transaction) or a `Pool`.
139
+ * @param {import('pg').Pool} pool The queue's own pool: claims, leases, results.
140
+ * @param {PgAdapterOptions} [options]
141
+ * @returns {import('../types.js').Adapter<PgConnection>}
142
+ */
143
+ export function pgAdapter(pool, options = {}) {
144
+ return {
145
+ dialect: 'postgres',
146
+ wrap: toExecutor,
147
+ queue: toExecutor(pool),
148
+ withConnection: async (func) => {
149
+ const client = await pool.connect();
150
+
151
+ try {
152
+ return await func(toExecutor(client));
153
+ } finally {
154
+ client.release();
155
+ }
156
+ },
157
+ queueTransaction: func => inOwnTransaction(pool, func, true),
158
+ inTransaction: (connection, func) => (isPool(connection)
159
+ ? inOwnTransaction(connection, func)
160
+ : func(toExecutor(connection))),
161
+ notify: options.notify ?? false,
162
+ listen: (channel, onMessage) => listen(pool, channel, onMessage),
163
+ };
164
+ }
package/src/backoff.js ADDED
@@ -0,0 +1,24 @@
1
+ import { parseDuration } from './duration.js';
2
+
3
+ /**
4
+ * The delay before the next attempt.
5
+ * @param {import('./types.js').Backoff} backoff Checked.
6
+ * @param {number} attempt The attempt that failed: 1 for the first.
7
+ * @param {() => number} [random] For tests. Default: `Math.random`.
8
+ * @returns {number} Milliseconds.
9
+ */
10
+ export function getBackoffMs(backoff, attempt, random = Math.random) {
11
+ const delayMs = /** @type {number} */ (parseDuration(backoff.delay));
12
+ const jitter = backoff.jitter ?? 0.2;
13
+ let baseMs = delayMs;
14
+
15
+ if (backoff.type === 'exponential') {
16
+ const maxDelayMs = backoff.maxDelay === undefined
17
+ ? Infinity
18
+ : /** @type {number} */ (parseDuration(backoff.maxDelay));
19
+
20
+ baseMs = Math.min(delayMs * 2 ** (attempt - 1), maxDelayMs);
21
+ }
22
+
23
+ return Math.round(baseMs * (1 - jitter * random()));
24
+ }
@@ -0,0 +1,210 @@
1
+ import { resolveDefaults } from './defaults.js';
2
+ import { createEnqueuer } from './enqueue.js';
3
+ import { createHintChannel } from './hint_channel.js';
4
+ import { ClosedError, ConfigError } from './errors.js';
5
+ import { runMigrations } from './migrations.js';
6
+ import { DEFAULT_MAX_PAYLOAD_BYTES } from './payload.js';
7
+ import { createRegistry } from './registry.js';
8
+ import { createRunner } from './runner.js';
9
+ import { createStore } from './store/store.js';
10
+ import { checkDuration, checkInteger, checkObject, checkTablePrefix, isPlainObject } from './validate.js';
11
+
12
+ const OPTION_NAMES = [
13
+ 'adapter', 'jobs', 'concurrency', 'lanes', 'defaults', 'unknownJobs', 'hint', 'logger', 'onEvent', 'tablePrefix',
14
+ 'pollInterval', 'maxPayloadBytes', 'cleanup', 'timings',
15
+ ];
16
+
17
+ /**
18
+ * @param {unknown} jobs
19
+ * @param {import('./validate.js').Scope} scope
20
+ * @returns {Map<string, import('./types.js').JobDefinition>} The definitions by name.
21
+ */
22
+ function indexJobs(jobs, scope) {
23
+ if (!Array.isArray(jobs)) {
24
+ throw new ConfigError(`${scope.where}: "jobs" must be an array of job definitions`);
25
+ }
26
+
27
+ /** @type {Map<string, import('./types.js').JobDefinition>} */
28
+ const byName = new Map();
29
+
30
+ for (const job of jobs) {
31
+ if (!isPlainObject(job) || typeof job.name !== 'string') {
32
+ throw new ConfigError(`${scope.where}: "jobs" must be an array of job definitions`);
33
+ }
34
+
35
+ const known = byName.get(job.name);
36
+
37
+ if (known && known !== job) {
38
+ throw new ConfigError(`${scope.where}: two different definitions of "${job.name}" in "jobs"`);
39
+ }
40
+
41
+ byName.set(job.name, /** @type {import('./types.js').JobDefinition} */ (job));
42
+ }
43
+
44
+ return byName;
45
+ }
46
+
47
+ /**
48
+ * The hint of an instance: the `hint` option, or the LISTEN/NOTIFY of a pg adapter with `notify`.
49
+ * @param {import('./types.js').Adapter<any>} adapter
50
+ * @param {unknown} hint
51
+ * @returns {import('./types.js').Hint | null}
52
+ */
53
+ function resolveHint(adapter, hint) {
54
+ if (hint !== undefined && hint !== null) {
55
+ if (!isPlainObject(hint) || typeof hint.publish !== 'function' || typeof hint.subscribe !== 'function') {
56
+ throw new ConfigError('createSqlane: "hint" must be { publish(channel, message), subscribe(channel, onMessage) }');
57
+ }
58
+
59
+ if (adapter.notify) {
60
+ throw new ConfigError('createSqlane: "hint" or pgAdapter(pool, { notify: true }), not both');
61
+ }
62
+
63
+ return /** @type {import('./types.js').Hint} */ (hint);
64
+ }
65
+
66
+ if (!adapter.notify || !adapter.listen) {
67
+ return null;
68
+ }
69
+
70
+ const { listen } = adapter;
71
+
72
+ return {
73
+ publish: async (channel, message) => {
74
+ await adapter.queue.query('SELECT pg_notify($1, $2)', [channel, message]);
75
+ },
76
+ subscribe: listen,
77
+ };
78
+ }
79
+
80
+ /**
81
+ * Creates a sqlane instance. It connects, migrates and starts nothing.
82
+ * @template C The connections that `enqueue` accepts, from the adapter.
83
+ * @template {string} [L=never] The lane names, from `lanes`.
84
+ * @param {import('./types.js').SqlaneOptions<C, L>} options
85
+ * @returns {import('./types.js').Sqlane<C, L>}
86
+ */
87
+ export function createSqlane(options) {
88
+ const scope = { where: 'createSqlane', ErrorClass: ConfigError };
89
+
90
+ checkObject(options, OPTION_NAMES, 'options', scope);
91
+
92
+ if (!options.adapter) {
93
+ throw new ConfigError('createSqlane: "adapter" is required');
94
+ }
95
+
96
+ const {
97
+ adapter,
98
+ tablePrefix = 'sqlane_',
99
+ unknownJobs = 'allow',
100
+ maxPayloadBytes = DEFAULT_MAX_PAYLOAD_BYTES,
101
+ } = options;
102
+
103
+ checkTablePrefix(tablePrefix, scope);
104
+ checkInteger(maxPayloadBytes, '"maxPayloadBytes"', scope);
105
+
106
+ if (unknownJobs !== 'allow' && unknownJobs !== 'reject') {
107
+ throw new ConfigError(`createSqlane: "unknownJobs" must be 'allow' or 'reject'`);
108
+ }
109
+
110
+ const { concurrency = 10, cleanup = true } = options;
111
+
112
+ checkInteger(concurrency, '"concurrency"', scope);
113
+
114
+ if (typeof cleanup !== 'boolean') {
115
+ throw new ConfigError('createSqlane: "cleanup" must be a boolean');
116
+ }
117
+
118
+ const pollIntervalMs = checkDuration(options.pollInterval ?? '5s', '"pollInterval"', scope, true);
119
+ const jobsByName = indexJobs(options.jobs ?? [], scope);
120
+ const defaults = resolveDefaults(options.defaults);
121
+ const registry = createRegistry({
122
+ jobsByName,
123
+ lanes: options.lanes,
124
+ concurrency,
125
+ defaultTimeoutMs: defaults.timeoutMs,
126
+ });
127
+ const hint = resolveHint(adapter, options.hint);
128
+ const hintChannel = createHintChannel({
129
+ hint,
130
+ channel: `${tablePrefix}ready`,
131
+ reportError: (operation, error) => {
132
+ options.logger?.warn({ error, operation }, `sqlane: ${operation} failed`);
133
+ options.onEvent?.({ type: 'error', operation, error });
134
+ },
135
+ });
136
+ // NOTIFY in the enqueue transaction: no publish after the enqueue
137
+ const notifyChannel = adapter.notify ? `${tablePrefix}ready` : null;
138
+ // Undocumented, for the tests of sqlane
139
+ const testTimings = /** @type {{ timings?: object }} */ (options).timings;
140
+ const store = createStore(adapter, tablePrefix);
141
+ const runner = createRunner({
142
+ store,
143
+ registry,
144
+ concurrency,
145
+ defaults,
146
+ cleanup,
147
+ logger: options.logger,
148
+ onEvent: options.onEvent,
149
+ hintChannel,
150
+ timings: { pollIntervalMs, ...testTimings },
151
+ });
152
+ const enqueuer = createEnqueuer({
153
+ adapter,
154
+ tablePrefix,
155
+ findDefinition: registry.findDefinition,
156
+ unknownJobs,
157
+ defaults,
158
+ maxPayloadBytes,
159
+ notifyChannel,
160
+ });
161
+ /** @param {string} name */
162
+ const onCreated = (name) => {
163
+ runner.onEnqueued([name]);
164
+
165
+ // A process sends a hint only for the jobs that it cannot start itself
166
+ if (!notifyChannel && !runner.canStartNow(name)) {
167
+ hintChannel.publish(name);
168
+ }
169
+ };
170
+
171
+ return {
172
+ enqueue: async (connection, job, payload, enqueueOptions) => {
173
+ const result = await enqueuer.enqueue(connection, job, payload, enqueueOptions);
174
+
175
+ if (result.created) {
176
+ onCreated(typeof job === 'string' ? job : job.name);
177
+ }
178
+
179
+ return result;
180
+ },
181
+ enqueueMany: async (connection, job, items) => {
182
+ const results = await enqueuer.enqueueMany(connection, job, items);
183
+
184
+ if (results.some(result => result.created)) {
185
+ onCreated(typeof job === 'string' ? job : job.name);
186
+ }
187
+
188
+ return results;
189
+ },
190
+ handle: (job, handler, handleOptions) => {
191
+ if (runner.isClosed()) {
192
+ throw new ClosedError('handle() after close()');
193
+ }
194
+
195
+ if (runner.isStarted()) {
196
+ throw new ConfigError('handle() after start(): register all the handlers before start()');
197
+ }
198
+
199
+ registry.handle(job, handler, handleOptions);
200
+ },
201
+ start: runner.start,
202
+ drain: runner.drain,
203
+ close: runner.close,
204
+ migrate: async migrateOptions => ({
205
+ applied: await runMigrations(adapter, tablePrefix, migrateOptions),
206
+ }),
207
+ getStats: () => store.getStats(),
208
+ wake: name => runner.wake(name),
209
+ };
210
+ }
@@ -0,0 +1,38 @@
1
+ import { parseDuration } from './duration.js';
2
+ import { ConfigError } from './errors.js';
3
+ import { checkJobFields, checkObject } from './validate.js';
4
+
5
+ /**
6
+ * The job defaults of an instance, merged with the built-in ones and checked.
7
+ * @typedef {object} ResolvedDefaults
8
+ * @property {number} maxAttempts
9
+ * @property {number} maxStalls
10
+ * @property {import('./types.js').Backoff} backoff
11
+ * @property {{ completedMs: number, failedMs: number }} retention
12
+ * @property {number | null} timeoutMs Null: no timeout.
13
+ */
14
+
15
+ /** @type {Required<import('./types.js').JobDefaults>['backoff']} */
16
+ const DEFAULT_BACKOFF = Object.freeze({ type: 'exponential', delay: '5s', maxDelay: '1h', jitter: 0.2 });
17
+
18
+ /**
19
+ * @param {import('./types.js').JobDefaults} [defaults]
20
+ * @returns {ResolvedDefaults}
21
+ */
22
+ export function resolveDefaults(defaults = {}) {
23
+ const scope = { where: 'createSqlane: "defaults"', ErrorClass: ConfigError };
24
+
25
+ checkObject(defaults, ['maxAttempts', 'maxStalls', 'backoff', 'retention', 'timeout'], 'defaults', scope);
26
+ checkJobFields(defaults, scope);
27
+
28
+ return {
29
+ maxAttempts: defaults.maxAttempts ?? 3,
30
+ maxStalls: defaults.maxStalls ?? 2,
31
+ backoff: defaults.backoff ?? DEFAULT_BACKOFF,
32
+ retention: {
33
+ completedMs: /** @type {number} */ (parseDuration(defaults.retention?.completed ?? '1h')),
34
+ failedMs: /** @type {number} */ (parseDuration(defaults.retention?.failed ?? '30d')),
35
+ },
36
+ timeoutMs: defaults.timeout === undefined ? null : parseDuration(defaults.timeout),
37
+ };
38
+ }
@@ -0,0 +1,40 @@
1
+ import { ConfigError } from './errors.js';
2
+ import { checkFailureJob, checkJobFields, checkObject, JOB_NAME_REGEXP } from './validate.js';
3
+
4
+ const JOB_OPTION_NAMES = [
5
+ 'name', 'maxAttempts', 'maxStalls', 'backoff', 'retention', 'timeout', 'mutexKeys', 'failureJob',
6
+ ];
7
+
8
+ /**
9
+ * Defines a job name and its options. Producers and workers import the definition.
10
+ * @param {import('./types.js').JobOptions} options
11
+ * @returns {import('./types.js').JobDefinition} Frozen.
12
+ */
13
+ export function defineJob(options) {
14
+ const name = typeof options?.name === 'string' ? options.name : '';
15
+ const where = `defineJob(${JSON.stringify(name)})`;
16
+
17
+ const scope = { where, ErrorClass: ConfigError };
18
+
19
+ checkObject(options, JOB_OPTION_NAMES, 'options', scope);
20
+
21
+ if (!JOB_NAME_REGEXP.test(name)) {
22
+ throw new ConfigError(`${where}: "name" must match ${JOB_NAME_REGEXP}`);
23
+ }
24
+
25
+ checkJobFields(options, scope);
26
+
27
+ if (options.mutexKeys !== undefined && typeof options.mutexKeys !== 'function') {
28
+ throw new ConfigError(`${where}: "mutexKeys" must be a function of the payload`);
29
+ }
30
+
31
+ if (options.failureJob !== undefined) {
32
+ checkFailureJob(options.failureJob, name, scope);
33
+ }
34
+
35
+ return Object.freeze({
36
+ ...options,
37
+ ...(options.backoff && { backoff: Object.freeze({ ...options.backoff }) }),
38
+ ...(options.retention && { retention: Object.freeze({ ...options.retention }) }),
39
+ });
40
+ }