@sqlane/core 0.0.2 → 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 +44 -6
- package/package.json +5 -1
- package/src/adapters/mysql2.js +61 -0
- package/src/adapters/pg.js +129 -1
- package/src/backoff.js +24 -0
- package/src/create_sqlane.js +191 -15
- package/src/defaults.js +38 -0
- package/src/define_job.js +34 -3
- package/src/duration.js +27 -0
- package/src/enqueue.js +132 -0
- package/src/error_json.js +30 -0
- package/src/errors.js +12 -0
- package/src/hint_channel.js +107 -0
- package/src/index.js +1 -0
- package/src/migrations.js +155 -2
- package/src/mutex_keys.js +34 -0
- package/src/new_job.js +128 -0
- package/src/payload.js +54 -0
- package/src/registry.js +169 -0
- package/src/runner.js +974 -0
- package/src/schema/mysql.js +74 -0
- package/src/schema/postgres.js +67 -0
- package/src/store/mysql_enqueue.js +98 -0
- package/src/store/mysql_final.js +424 -0
- package/src/store/mysql_worker.js +250 -0
- package/src/store/postgres_enqueue.js +54 -0
- package/src/store/postgres_final.js +407 -0
- package/src/store/postgres_worker.js +224 -0
- package/src/store/store.js +136 -0
- package/src/transient_error.js +22 -0
- package/src/types.js +14 -2
- package/src/validate.js +181 -0
package/src/enqueue.js
ADDED
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import { monotonicFactory } from 'ulid';
|
|
2
|
+
import { InvalidOptionsError, UnknownJobError } from './errors.js';
|
|
3
|
+
import { buildNewJob } from './new_job.js';
|
|
4
|
+
import { insertJobsMysql } from './store/mysql_enqueue.js';
|
|
5
|
+
import { insertJobsPostgres } from './store/postgres_enqueue.js';
|
|
6
|
+
import { isPlainObject } from './validate.js';
|
|
7
|
+
|
|
8
|
+
const enqueueToken = monotonicFactory();
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* @template C
|
|
12
|
+
* @typedef {object} EnqueuerOptions
|
|
13
|
+
* @property {import('./types.js').Adapter<C>} adapter
|
|
14
|
+
* @property {string} tablePrefix Checked.
|
|
15
|
+
* @property {(name: string) => import('./types.js').JobDefinition | null} findDefinition The known definition of
|
|
16
|
+
* a name.
|
|
17
|
+
* @property {'allow' | 'reject'} unknownJobs
|
|
18
|
+
* @property {import('./defaults.js').ResolvedDefaults} defaults
|
|
19
|
+
* @property {number} maxPayloadBytes
|
|
20
|
+
* @property {string | null} notifyChannel PostgreSQL NOTIFY: `pg_notify` in the enqueue transaction, delivered on
|
|
21
|
+
* commit. Null: no NOTIFY.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* @template C
|
|
26
|
+
* @typedef {object} Enqueuer
|
|
27
|
+
* @property {import('./types.js').Sqlane<C, never>['enqueue']} enqueue
|
|
28
|
+
* @property {import('./types.js').Sqlane<C, never>['enqueueMany']} enqueueMany
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* The enqueue side of an instance.
|
|
33
|
+
* @template C
|
|
34
|
+
* @param {EnqueuerOptions<C>} options
|
|
35
|
+
* @returns {Enqueuer<C>}
|
|
36
|
+
*/
|
|
37
|
+
export function createEnqueuer({
|
|
38
|
+
adapter,
|
|
39
|
+
tablePrefix,
|
|
40
|
+
findDefinition,
|
|
41
|
+
unknownJobs,
|
|
42
|
+
defaults,
|
|
43
|
+
maxPayloadBytes,
|
|
44
|
+
notifyChannel,
|
|
45
|
+
}) {
|
|
46
|
+
const insertJobs = adapter.dialect === 'mysql' ? insertJobsMysql : insertJobsPostgres;
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* @param {import('./types.js').JobDefinition | string} job
|
|
50
|
+
* @returns {{ name: string, definition: import('./types.js').JobDefinition | null }}
|
|
51
|
+
*/
|
|
52
|
+
function resolveJob(job) {
|
|
53
|
+
if (isPlainObject(job) && typeof job.name === 'string') {
|
|
54
|
+
return { name: job.name, definition: job };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
if (typeof job !== 'string') {
|
|
58
|
+
throw new InvalidOptionsError('enqueue: the job must be a job definition or a job name');
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const definition = findDefinition(job);
|
|
62
|
+
|
|
63
|
+
if (!definition && unknownJobs === 'reject') {
|
|
64
|
+
throw new UnknownJobError(`enqueue("${job}"): the job is not in "jobs" and not handled`);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
return { name: job, definition };
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* @param {C} connection
|
|
72
|
+
* @param {import('./types.js').JobDefinition | string} job
|
|
73
|
+
* @param {readonly { payload: unknown, options?: import('./types.js').EnqueueOptions }[]} items
|
|
74
|
+
* @returns {Promise<import('./types.js').EnqueueResult[]>}
|
|
75
|
+
*/
|
|
76
|
+
async function insert(connection, job, items) {
|
|
77
|
+
const { name, definition } = resolveJob(job);
|
|
78
|
+
// Checks all the items before the INSERT: a bad item adds no job.
|
|
79
|
+
const newJobs = items.map(item => buildNewJob({
|
|
80
|
+
name,
|
|
81
|
+
definition,
|
|
82
|
+
defaults,
|
|
83
|
+
payload: item.payload,
|
|
84
|
+
options: item.options,
|
|
85
|
+
maxPayloadBytes,
|
|
86
|
+
}));
|
|
87
|
+
// A jobId repeated in the call: its first item is the job, the next ones are duplicates.
|
|
88
|
+
const firstById = new Map();
|
|
89
|
+
|
|
90
|
+
for (const newJob of newJobs) {
|
|
91
|
+
if (!firstById.has(newJob.id)) {
|
|
92
|
+
firstById.set(newJob.id, newJob);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
if (!firstById.size) {
|
|
97
|
+
return [];
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const token = enqueueToken();
|
|
101
|
+
const created = await adapter.inTransaction(connection, async (executor) => {
|
|
102
|
+
const createdIds = await insertJobs(executor, tablePrefix, [...firstById.values()], token);
|
|
103
|
+
|
|
104
|
+
if (notifyChannel && createdIds.size) {
|
|
105
|
+
// One NOTIFY per transaction and name: PostgreSQL drops the repeated ones
|
|
106
|
+
await executor.query('SELECT pg_notify($1, $2)', [notifyChannel, name]);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
return createdIds;
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
return newJobs.map(newJob => ({
|
|
113
|
+
id: newJob.id,
|
|
114
|
+
created: created.has(newJob.id) && firstById.get(newJob.id) === newJob,
|
|
115
|
+
}));
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
return {
|
|
119
|
+
enqueue: async (connection, job, payload, options) => {
|
|
120
|
+
const [result] = await insert(connection, job, [{ payload, options }]);
|
|
121
|
+
|
|
122
|
+
return result;
|
|
123
|
+
},
|
|
124
|
+
enqueueMany: (connection, job, items) => {
|
|
125
|
+
if (!Array.isArray(items)) {
|
|
126
|
+
return Promise.reject(new InvalidOptionsError('enqueueMany: "items" must be an array'));
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
return insert(connection, job, items);
|
|
130
|
+
},
|
|
131
|
+
};
|
|
132
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/** The longest stack kept with a job error: the error row and the failure job stay small. */
|
|
2
|
+
export const MAX_STACK_LENGTH = 8 * 1024;
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* @typedef {object} ErrorJson
|
|
6
|
+
* @property {string} name
|
|
7
|
+
* @property {string} message
|
|
8
|
+
* @property {string} [stack]
|
|
9
|
+
* @property {string} [code]
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* The JSON of an error, for `last_error` and the failure job. A thrown non-Error becomes its string.
|
|
14
|
+
* @param {unknown} error
|
|
15
|
+
* @returns {ErrorJson}
|
|
16
|
+
*/
|
|
17
|
+
export function toErrorJson(error) {
|
|
18
|
+
if (!(error instanceof Error)) {
|
|
19
|
+
return { name: 'Error', message: String(error) };
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const { code } = /** @type {{ code?: unknown }} */ (error);
|
|
23
|
+
|
|
24
|
+
return {
|
|
25
|
+
name: error.name,
|
|
26
|
+
message: error.message,
|
|
27
|
+
...(error.stack && { stack: error.stack.slice(0, MAX_STACK_LENGTH) }),
|
|
28
|
+
...(typeof code === 'string' && { code }),
|
|
29
|
+
};
|
|
30
|
+
}
|
package/src/errors.js
CHANGED
|
@@ -174,3 +174,15 @@ export class RetryJobError extends SqlaneError {
|
|
|
174
174
|
this.delay = delay;
|
|
175
175
|
}
|
|
176
176
|
}
|
|
177
|
+
|
|
178
|
+
/** `migrate()` did not get the migration lock in its `lockTimeout`. */
|
|
179
|
+
export class MigrationLockTimeoutError extends SqlaneError {
|
|
180
|
+
/**
|
|
181
|
+
* @param {string} message
|
|
182
|
+
* @param {ErrorOptions} [options]
|
|
183
|
+
*/
|
|
184
|
+
constructor(message, options) {
|
|
185
|
+
super('MIGRATION_LOCK_TIMEOUT', message, options);
|
|
186
|
+
this.name = 'MigrationLockTimeoutError';
|
|
187
|
+
}
|
|
188
|
+
}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
// Hints between processes: "jobs of this name are ready". Only an optimization: a lost hint delays a job until the
|
|
2
|
+
// next poll, it never loses one.
|
|
3
|
+
|
|
4
|
+
/** At most one message per name in this window; the messages in it make one more at its end */
|
|
5
|
+
const THROTTLE_MS = 100;
|
|
6
|
+
/** The message after a release of mutex keys: every process probes all its names */
|
|
7
|
+
export const ANY_NAME = '*';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* @typedef {object} HintChannel
|
|
11
|
+
* @property {(name: string) => void} publish Fire-and-forget: never throws, never waits.
|
|
12
|
+
* @property {(onMessage: (name: string) => void) => Promise<void>} listen
|
|
13
|
+
* @property {() => Promise<void>} stop Stops listening; drops the pending messages.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* @param {object} params
|
|
18
|
+
* @param {import('./types.js').Hint | null} params.hint
|
|
19
|
+
* @param {string} params.channel
|
|
20
|
+
* @param {(operation: string, error: unknown) => void} params.reportError
|
|
21
|
+
* @returns {HintChannel}
|
|
22
|
+
*/
|
|
23
|
+
export function createHintChannel({ hint, channel, reportError }) {
|
|
24
|
+
/** @type {Map<string, { timer: NodeJS.Timeout, again: boolean }>} */
|
|
25
|
+
const windows = new Map();
|
|
26
|
+
/** @type {(() => Promise<void>) | null} */
|
|
27
|
+
let unsubscribe = null;
|
|
28
|
+
let stopped = false;
|
|
29
|
+
|
|
30
|
+
/** @param {string} name */
|
|
31
|
+
function send(name) {
|
|
32
|
+
if (!hint || stopped) {
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const { publish } = hint;
|
|
37
|
+
|
|
38
|
+
// Not awaited: the enqueue does not wait for the hint
|
|
39
|
+
(async () => {
|
|
40
|
+
try {
|
|
41
|
+
await publish(channel, name);
|
|
42
|
+
} catch (error) {
|
|
43
|
+
reportError('hint_publish', error);
|
|
44
|
+
}
|
|
45
|
+
})();
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** @param {string} name */
|
|
49
|
+
function openWindow(name) {
|
|
50
|
+
const timer = setTimeout(() => {
|
|
51
|
+
const window = windows.get(name);
|
|
52
|
+
|
|
53
|
+
windows.delete(name);
|
|
54
|
+
|
|
55
|
+
if (window?.again) {
|
|
56
|
+
send(name);
|
|
57
|
+
openWindow(name);
|
|
58
|
+
}
|
|
59
|
+
}, THROTTLE_MS);
|
|
60
|
+
|
|
61
|
+
timer.unref();
|
|
62
|
+
windows.set(name, { timer, again: false });
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
return {
|
|
66
|
+
publish: (name) => {
|
|
67
|
+
const window = windows.get(name);
|
|
68
|
+
|
|
69
|
+
if (window) {
|
|
70
|
+
window.again = true;
|
|
71
|
+
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
send(name);
|
|
76
|
+
openWindow(name);
|
|
77
|
+
},
|
|
78
|
+
listen: async (onMessage) => {
|
|
79
|
+
if (!hint) {
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
unsubscribe = await hint.subscribe(channel, (message) => {
|
|
84
|
+
try {
|
|
85
|
+
onMessage(message);
|
|
86
|
+
} catch (error) {
|
|
87
|
+
reportError('hint_message', error);
|
|
88
|
+
}
|
|
89
|
+
});
|
|
90
|
+
},
|
|
91
|
+
stop: async () => {
|
|
92
|
+
stopped = true;
|
|
93
|
+
|
|
94
|
+
for (const { timer } of windows.values()) {
|
|
95
|
+
clearTimeout(timer);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
windows.clear();
|
|
99
|
+
|
|
100
|
+
try {
|
|
101
|
+
await unsubscribe?.();
|
|
102
|
+
} catch (error) {
|
|
103
|
+
reportError('hint_unsubscribe', error);
|
|
104
|
+
}
|
|
105
|
+
},
|
|
106
|
+
};
|
|
107
|
+
}
|
package/src/index.js
CHANGED
package/src/migrations.js
CHANGED
|
@@ -1,3 +1,11 @@
|
|
|
1
|
+
import { parseDuration } from './duration.js';
|
|
2
|
+
import { ConfigError, InvalidOptionsError, MigrationLockTimeoutError } from './errors.js';
|
|
3
|
+
import { mysqlMigrations, mysqlMigrationsTable } from './schema/mysql.js';
|
|
4
|
+
import { postgresMigrations, postgresMigrationsTable } from './schema/postgres.js';
|
|
5
|
+
import { checkTablePrefix } from './validate.js';
|
|
6
|
+
|
|
7
|
+
/** @typedef {import('./types.js').Executor} Executor */
|
|
8
|
+
|
|
1
9
|
/**
|
|
2
10
|
* The schema of sqlane as versioned SQL, for the app's own migration tool.
|
|
3
11
|
* @param {object} options
|
|
@@ -5,6 +13,151 @@
|
|
|
5
13
|
* @param {string} [options.tablePrefix] Default: `'sqlane_'`.
|
|
6
14
|
* @returns {import('./types.js').Migration[]}
|
|
7
15
|
*/
|
|
8
|
-
export function getMigrations(
|
|
9
|
-
|
|
16
|
+
export function getMigrations({ dialect, tablePrefix = 'sqlane_' }) {
|
|
17
|
+
const scope = { where: 'getMigrations', ErrorClass: ConfigError };
|
|
18
|
+
|
|
19
|
+
checkTablePrefix(tablePrefix, scope);
|
|
20
|
+
|
|
21
|
+
if (dialect === 'mysql') {
|
|
22
|
+
return mysqlMigrations(tablePrefix);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
if (dialect === 'postgres') {
|
|
26
|
+
return postgresMigrations(tablePrefix);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
throw new ConfigError(`getMigrations: "dialect" must be 'mysql' or 'postgres'`);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Runs the statements one after the other: DDL in order.
|
|
34
|
+
* @param {Executor} executor
|
|
35
|
+
* @param {{ sql: string, params: unknown[] }[]} steps
|
|
36
|
+
* @returns {Promise<void>}
|
|
37
|
+
*/
|
|
38
|
+
async function runInOrder(executor, steps) {
|
|
39
|
+
for (const { sql, params } of steps) {
|
|
40
|
+
// oxlint-disable-next-line no-await-in-loop -- each statement needs the previous one
|
|
41
|
+
await executor.query(sql, params);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* The statements of the missing migrations, each followed by its record.
|
|
47
|
+
* @param {import('./types.js').Migration[]} missing
|
|
48
|
+
* @param {string} recordSql
|
|
49
|
+
* @returns {{ sql: string, params: unknown[] }[]}
|
|
50
|
+
*/
|
|
51
|
+
function toSteps(missing, recordSql) {
|
|
52
|
+
return missing.flatMap(({ id, statements }) => [
|
|
53
|
+
...statements.map(sql => ({ sql, params: [] })),
|
|
54
|
+
{ sql: recordSql, params: [id] },
|
|
55
|
+
]);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* @param {Executor} executor
|
|
60
|
+
* @param {string} table
|
|
61
|
+
* @returns {Promise<Set<string>>}
|
|
62
|
+
*/
|
|
63
|
+
async function readApplied(executor, table) {
|
|
64
|
+
const rows = await executor.query(`SELECT id FROM ${table}`, []);
|
|
65
|
+
|
|
66
|
+
return new Set(rows.map(row => String(row.id)));
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* @param {Executor} executor
|
|
71
|
+
* @param {string} p
|
|
72
|
+
* @param {number} lockTimeoutMs
|
|
73
|
+
* @returns {Promise<string[]>}
|
|
74
|
+
*/
|
|
75
|
+
async function migrateMysql(executor, p, lockTimeoutMs) {
|
|
76
|
+
const lockName = `${p}migrations`;
|
|
77
|
+
// GET_LOCK waits whole seconds; -1 is forever, so at least 1
|
|
78
|
+
const [{ locked }] = await executor.query('SELECT GET_LOCK(?, ?) AS locked', [
|
|
79
|
+
lockName,
|
|
80
|
+
Math.max(1, Math.ceil(lockTimeoutMs / 1000)),
|
|
81
|
+
]);
|
|
82
|
+
|
|
83
|
+
if (Number(locked) !== 1) {
|
|
84
|
+
throw new MigrationLockTimeoutError(`migrate: the lock "${lockName}" was not free in ${lockTimeoutMs} ms`);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
try {
|
|
88
|
+
await executor.query(mysqlMigrationsTable(p), []);
|
|
89
|
+
|
|
90
|
+
const applied = await readApplied(executor, `${p}migrations`);
|
|
91
|
+
const missing = mysqlMigrations(p).filter(({ id }) => !applied.has(id));
|
|
92
|
+
|
|
93
|
+
await runInOrder(executor, toSteps(missing, `INSERT INTO ${p}migrations (id, applied_at) VALUES (?, UTC_TIMESTAMP(3))`));
|
|
94
|
+
|
|
95
|
+
return missing.map(({ id }) => id);
|
|
96
|
+
} finally {
|
|
97
|
+
await executor.query('SELECT RELEASE_LOCK(?)', [lockName]);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* @param {Executor} executor
|
|
103
|
+
* @param {string} p
|
|
104
|
+
* @param {number} lockTimeoutMs
|
|
105
|
+
* @returns {Promise<string[]>}
|
|
106
|
+
*/
|
|
107
|
+
async function migratePostgres(executor, p, lockTimeoutMs) {
|
|
108
|
+
await executor.query('BEGIN', []);
|
|
109
|
+
|
|
110
|
+
try {
|
|
111
|
+
// SET LOCAL: until the end of the transaction, then the pooled connection gets its settings back
|
|
112
|
+
await executor.query('SET LOCAL statement_timeout = 0', []);
|
|
113
|
+
await executor.query(`SET LOCAL lock_timeout = ${Math.max(1, lockTimeoutMs)}`, []);
|
|
114
|
+
|
|
115
|
+
try {
|
|
116
|
+
await executor.query('SELECT pg_advisory_xact_lock(hashtext($1))', [`${p}migrations`]);
|
|
117
|
+
} catch (error) {
|
|
118
|
+
if (/** @type {{ code?: string }} */ (error).code === '55P03') {
|
|
119
|
+
throw new MigrationLockTimeoutError(
|
|
120
|
+
`migrate: the lock of "${p}migrations" was not free in ${lockTimeoutMs} ms`,
|
|
121
|
+
{ cause: error },
|
|
122
|
+
);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
throw error;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
await executor.query('SET LOCAL lock_timeout = 0', []);
|
|
129
|
+
await executor.query(postgresMigrationsTable(p), []);
|
|
130
|
+
|
|
131
|
+
const applied = await readApplied(executor, `${p}migrations`);
|
|
132
|
+
const missing = postgresMigrations(p).filter(({ id }) => !applied.has(id));
|
|
133
|
+
|
|
134
|
+
await runInOrder(executor, toSteps(missing, `INSERT INTO ${p}migrations (id, applied_at) VALUES ($1, clock_timestamp())`));
|
|
135
|
+
|
|
136
|
+
await executor.query('COMMIT', []);
|
|
137
|
+
|
|
138
|
+
return missing.map(({ id }) => id);
|
|
139
|
+
} catch (error) {
|
|
140
|
+
await executor.query('ROLLBACK', []);
|
|
141
|
+
throw error;
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Applies the missing migrations under a DB lock, on a connection of its own, without a statement timeout.
|
|
147
|
+
* @template C
|
|
148
|
+
* @param {import('./types.js').Adapter<C>} adapter
|
|
149
|
+
* @param {string} tablePrefix Checked.
|
|
150
|
+
* @param {import('./types.js').MigrateOptions} [options]
|
|
151
|
+
* @returns {Promise<string[]>} The ids of the applied migrations.
|
|
152
|
+
*/
|
|
153
|
+
export function runMigrations(adapter, tablePrefix, options = {}) {
|
|
154
|
+
const lockTimeoutMs = parseDuration(options.lockTimeout ?? '10m');
|
|
155
|
+
|
|
156
|
+
if (lockTimeoutMs === null) {
|
|
157
|
+
return Promise.reject(new InvalidOptionsError('migrate: "lockTimeout" must be a duration'));
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
return adapter.withConnection(executor => (adapter.dialect === 'mysql'
|
|
161
|
+
? migrateMysql(executor, tablePrefix, lockTimeoutMs)
|
|
162
|
+
: migratePostgres(executor, tablePrefix, lockTimeoutMs)));
|
|
10
163
|
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { InvalidMutexKeyError } from './errors.js';
|
|
2
|
+
import { PRINTABLE_ID_REGEXP } from './validate.js';
|
|
3
|
+
|
|
4
|
+
export const MAX_MUTEX_KEYS = 10;
|
|
5
|
+
export const MAX_MUTEX_KEY_LENGTH = 255;
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Checks the mutex keys of a job.
|
|
9
|
+
* @param {unknown} keys
|
|
10
|
+
* @param {string} where For the error message.
|
|
11
|
+
* @returns {string[]} The keys without duplicates, sorted (the lock order).
|
|
12
|
+
*/
|
|
13
|
+
export function normalizeMutexKeys(keys, where) {
|
|
14
|
+
if (!Array.isArray(keys)) {
|
|
15
|
+
throw new InvalidMutexKeyError(`${where}: the mutex keys must be an array`);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
for (const key of keys) {
|
|
19
|
+
if (typeof key !== 'string' || key.length > MAX_MUTEX_KEY_LENGTH || !PRINTABLE_ID_REGEXP.test(key)) {
|
|
20
|
+
throw new InvalidMutexKeyError(
|
|
21
|
+
`${where}: a mutex key has 1 to ${MAX_MUTEX_KEY_LENGTH} printable ASCII characters, without spaces at `
|
|
22
|
+
+ `the ends: ${JSON.stringify(key)}`,
|
|
23
|
+
);
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const unique = [...new Set(/** @type {string[]} */ (keys))].toSorted();
|
|
28
|
+
|
|
29
|
+
if (unique.length > MAX_MUTEX_KEYS) {
|
|
30
|
+
throw new InvalidMutexKeyError(`${where}: at most ${MAX_MUTEX_KEYS} mutex keys, got ${unique.length}`);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
return unique;
|
|
34
|
+
}
|
package/src/new_job.js
ADDED
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import { monotonicFactory } from 'ulid';
|
|
2
|
+
import { parseDuration } from './duration.js';
|
|
3
|
+
import { InvalidOptionsError } from './errors.js';
|
|
4
|
+
import { normalizeMutexKeys } from './mutex_keys.js';
|
|
5
|
+
import { serializePayload } from './payload.js';
|
|
6
|
+
import {
|
|
7
|
+
checkFailureJob,
|
|
8
|
+
checkJobFields,
|
|
9
|
+
checkObject,
|
|
10
|
+
JOB_NAME_REGEXP,
|
|
11
|
+
PRINTABLE_ID_REGEXP,
|
|
12
|
+
} from './validate.js';
|
|
13
|
+
|
|
14
|
+
const ENQUEUE_OPTION_NAMES = [
|
|
15
|
+
'jobId', 'delay', 'runAt', 'maxAttempts', 'maxStalls', 'retention', 'failureJob', 'mutexKeys',
|
|
16
|
+
];
|
|
17
|
+
const MAX_JOB_ID_LENGTH = 120;
|
|
18
|
+
/** Sorted by creation order in this process, also within one millisecond */
|
|
19
|
+
const ulid = monotonicFactory();
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* A job to insert, with every value frozen at enqueue.
|
|
23
|
+
* @typedef {object} NewJob
|
|
24
|
+
* @property {string} id `<name>:<jobId>`, or a ULID.
|
|
25
|
+
* @property {boolean} hasJobId
|
|
26
|
+
* @property {string} name
|
|
27
|
+
* @property {string} payloadJson
|
|
28
|
+
* @property {readonly string[]} mutexKeys Sorted, without duplicates.
|
|
29
|
+
* @property {number} maxAttempts
|
|
30
|
+
* @property {number} maxStalls
|
|
31
|
+
* @property {number} completedRetentionMs
|
|
32
|
+
* @property {number} failedRetentionMs
|
|
33
|
+
* @property {string | null} failureJob
|
|
34
|
+
* @property {number} delayMs From the DB clock at insert. 0 with `runAt`.
|
|
35
|
+
* @property {Date | null} runAt
|
|
36
|
+
*/
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* @typedef {object} NewJobParams
|
|
40
|
+
* @property {string} name
|
|
41
|
+
* @property {import('./types.js').JobDefinition | null} definition Null: an unknown name, the defaults only.
|
|
42
|
+
* @property {import('./defaults.js').ResolvedDefaults} defaults
|
|
43
|
+
* @property {unknown} payload
|
|
44
|
+
* @property {import('./types.js').EnqueueOptions | undefined} options
|
|
45
|
+
* @property {number} maxPayloadBytes
|
|
46
|
+
*/
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* @param {unknown} jobId
|
|
50
|
+
* @param {string} where
|
|
51
|
+
*/
|
|
52
|
+
function checkJobId(jobId, where) {
|
|
53
|
+
if (typeof jobId !== 'string' || jobId.length > MAX_JOB_ID_LENGTH || !PRINTABLE_ID_REGEXP.test(jobId)) {
|
|
54
|
+
throw new InvalidOptionsError(
|
|
55
|
+
`${where}: "jobId" must have 1 to ${MAX_JOB_ID_LENGTH} printable ASCII characters, without spaces at the `
|
|
56
|
+
+ `ends: ${JSON.stringify(jobId)}`,
|
|
57
|
+
);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* @param {Record<string, unknown>} options
|
|
63
|
+
* @param {string} where
|
|
64
|
+
* @returns {{ delayMs: number, runAt: Date | null }}
|
|
65
|
+
*/
|
|
66
|
+
function getSchedule(options, where) {
|
|
67
|
+
if (options.delay !== undefined && options.runAt !== undefined) {
|
|
68
|
+
throw new InvalidOptionsError(`${where}: "delay" or "runAt", not both`);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
if (options.runAt !== undefined) {
|
|
72
|
+
if (!(options.runAt instanceof Date) || Number.isNaN(options.runAt.getTime())) {
|
|
73
|
+
throw new InvalidOptionsError(`${where}: "runAt" must be a valid Date`);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
return { delayMs: 0, runAt: options.runAt };
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const delayMs = options.delay === undefined ? 0 : parseDuration(options.delay);
|
|
80
|
+
|
|
81
|
+
if (delayMs === null) {
|
|
82
|
+
throw new InvalidOptionsError(`${where}: "delay" must be a duration, got ${JSON.stringify(options.delay)}`);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
return { delayMs, runAt: null };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Checks an enqueue and computes the job to insert: options, then the definition, then the defaults.
|
|
90
|
+
* @param {NewJobParams} params
|
|
91
|
+
* @returns {NewJob}
|
|
92
|
+
*/
|
|
93
|
+
export function buildNewJob({ name, definition, defaults, payload, options = {}, maxPayloadBytes }) {
|
|
94
|
+
const where = `enqueue(${JSON.stringify(name)})`;
|
|
95
|
+
|
|
96
|
+
if (typeof name !== 'string' || !JOB_NAME_REGEXP.test(name)) {
|
|
97
|
+
throw new InvalidOptionsError(`${where}: the job name must match ${JOB_NAME_REGEXP}`);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const scope = { where, ErrorClass: InvalidOptionsError };
|
|
101
|
+
|
|
102
|
+
checkObject(options, ENQUEUE_OPTION_NAMES, 'options', scope);
|
|
103
|
+
checkJobFields(options, scope);
|
|
104
|
+
|
|
105
|
+
if (options.jobId !== undefined) {
|
|
106
|
+
checkJobId(options.jobId, where);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const failureJob = options.failureJob ?? definition?.failureJob;
|
|
110
|
+
const payloadJson = serializePayload(payload, maxPayloadBytes, where);
|
|
111
|
+
// The keys come from the payload as the handler gets it: as JSON gives it back.
|
|
112
|
+
const rawKeys = options.mutexKeys ?? definition?.mutexKeys?.(JSON.parse(payloadJson)) ?? [];
|
|
113
|
+
const retention = { ...definition?.retention, ...options.retention };
|
|
114
|
+
|
|
115
|
+
return {
|
|
116
|
+
id: options.jobId === undefined ? ulid() : `${name}:${options.jobId}`,
|
|
117
|
+
hasJobId: options.jobId !== undefined,
|
|
118
|
+
name,
|
|
119
|
+
payloadJson,
|
|
120
|
+
mutexKeys: normalizeMutexKeys(rawKeys, where),
|
|
121
|
+
maxAttempts: options.maxAttempts ?? definition?.maxAttempts ?? defaults.maxAttempts,
|
|
122
|
+
maxStalls: options.maxStalls ?? definition?.maxStalls ?? defaults.maxStalls,
|
|
123
|
+
completedRetentionMs: parseDuration(retention.completed) ?? defaults.retention.completedMs,
|
|
124
|
+
failedRetentionMs: parseDuration(retention.failed) ?? defaults.retention.failedMs,
|
|
125
|
+
failureJob: failureJob === undefined ? null : checkFailureJob(failureJob, name, scope),
|
|
126
|
+
...getSchedule(options, where),
|
|
127
|
+
};
|
|
128
|
+
}
|
package/src/payload.js
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { InvalidOptionsError, PayloadTooLargeError } from './errors.js';
|
|
2
|
+
|
|
3
|
+
export const DEFAULT_MAX_PAYLOAD_BYTES = 256 * 1024;
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Serializes a payload: JSON, well-formed strings (MySQL refuses a lone surrogate), no NUL character (PostgreSQL
|
|
7
|
+
* refuses it), at most maxBytes.
|
|
8
|
+
* @param {unknown} payload
|
|
9
|
+
* @param {number} maxBytes
|
|
10
|
+
* @param {string} where For the error message.
|
|
11
|
+
* @returns {string} The JSON.
|
|
12
|
+
*/
|
|
13
|
+
export function serializePayload(payload, maxBytes, where) {
|
|
14
|
+
let wellFormed = true;
|
|
15
|
+
let hasNul = false;
|
|
16
|
+
let json;
|
|
17
|
+
|
|
18
|
+
try {
|
|
19
|
+
json = JSON.stringify(payload, (key, value) => {
|
|
20
|
+
if (!key.isWellFormed() || (typeof value === 'string' && !value.isWellFormed())) {
|
|
21
|
+
wellFormed = false;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
if (key.includes('\0') || (typeof value === 'string' && value.includes('\0'))) {
|
|
25
|
+
hasNul = true;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
return value;
|
|
29
|
+
});
|
|
30
|
+
} catch (error) {
|
|
31
|
+
throw new InvalidOptionsError(`${where}: the payload is not JSON`, { cause: error });
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
if (json === undefined) {
|
|
35
|
+
throw new InvalidOptionsError(`${where}: the payload is not JSON`);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
if (!wellFormed) {
|
|
39
|
+
throw new InvalidOptionsError(`${where}: the payload has a string that is not well-formed UTF-16`);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
if (hasNul) {
|
|
43
|
+
// PostgreSQL jsonb refuses it: refused on both databases, for the same behavior
|
|
44
|
+
throw new InvalidOptionsError(`${where}: the payload has a NUL character (\\u0000)`);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const bytes = Buffer.byteLength(json);
|
|
48
|
+
|
|
49
|
+
if (bytes > maxBytes) {
|
|
50
|
+
throw new PayloadTooLargeError(`${where}: the payload has ${bytes} bytes of JSON, at most ${maxBytes}`);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
return json;
|
|
54
|
+
}
|