@monque/core 1.5.2 → 1.7.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/dist/CHANGELOG.md +44 -0
- package/dist/index.cjs +522 -169
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +83 -4
- package/dist/index.d.cts.map +1 -1
- package/dist/index.d.mts +83 -4
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs +520 -170
- package/dist/index.mjs.map +1 -1
- package/package.json +6 -6
- package/src/index.ts +3 -0
- package/src/scheduler/monque.ts +112 -27
- package/src/scheduler/services/change-stream-handler.ts +183 -31
- package/src/scheduler/services/index.ts +1 -1
- package/src/scheduler/services/job-manager.ts +151 -114
- package/src/scheduler/services/job-processor.ts +109 -54
- package/src/scheduler/services/job-scheduler.ts +42 -9
- package/src/scheduler/services/lifecycle-manager.ts +77 -17
- package/src/scheduler/services/types.ts +7 -0
- package/src/scheduler/types.ts +14 -0
- package/src/shared/errors.ts +29 -0
- package/src/shared/index.ts +3 -0
- package/src/shared/utils/index.ts +1 -0
- package/src/shared/utils/job-identifiers.ts +71 -0
|
@@ -7,7 +7,14 @@ import {
|
|
|
7
7
|
type PersistedJob,
|
|
8
8
|
type ScheduleOptions,
|
|
9
9
|
} from '@/jobs';
|
|
10
|
-
import {
|
|
10
|
+
import {
|
|
11
|
+
ConnectionError,
|
|
12
|
+
getNextCronDate,
|
|
13
|
+
MonqueError,
|
|
14
|
+
PayloadTooLargeError,
|
|
15
|
+
validateJobName,
|
|
16
|
+
validateUniqueKey,
|
|
17
|
+
} from '@/shared';
|
|
11
18
|
|
|
12
19
|
import type { SchedulerContext } from './types.js';
|
|
13
20
|
|
|
@@ -22,6 +29,14 @@ import type { SchedulerContext } from './types.js';
|
|
|
22
29
|
export class JobScheduler {
|
|
23
30
|
constructor(private readonly ctx: SchedulerContext) {}
|
|
24
31
|
|
|
32
|
+
private validateJobIdentifiers(name: string, uniqueKey?: string): void {
|
|
33
|
+
validateJobName(name);
|
|
34
|
+
|
|
35
|
+
if (uniqueKey !== undefined) {
|
|
36
|
+
validateUniqueKey(uniqueKey);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
25
40
|
/**
|
|
26
41
|
* Validate that the job data payload does not exceed the configured maximum BSON byte size.
|
|
27
42
|
*
|
|
@@ -74,6 +89,7 @@ export class JobScheduler {
|
|
|
74
89
|
* @param data - Job payload, will be passed to the worker handler
|
|
75
90
|
* @param options - Scheduling and deduplication options
|
|
76
91
|
* @returns Promise resolving to the created or existing job document
|
|
92
|
+
* @throws {InvalidJobIdentifierError} If `name` or `uniqueKey` fails public identifier validation
|
|
77
93
|
* @throws {ConnectionError} If database operation fails or scheduler not initialized
|
|
78
94
|
* @throws {PayloadTooLargeError} If payload exceeds configured `maxPayloadSize`
|
|
79
95
|
*
|
|
@@ -103,6 +119,7 @@ export class JobScheduler {
|
|
|
103
119
|
* ```
|
|
104
120
|
*/
|
|
105
121
|
async enqueue<T>(name: string, data: T, options: EnqueueOptions = {}): Promise<PersistedJob<T>> {
|
|
122
|
+
this.validateJobIdentifiers(name, options.uniqueKey);
|
|
106
123
|
this.validatePayloadSize(data);
|
|
107
124
|
const now = new Date();
|
|
108
125
|
const job: Omit<Job<T>, '_id'> = {
|
|
@@ -115,12 +132,12 @@ export class JobScheduler {
|
|
|
115
132
|
updatedAt: now,
|
|
116
133
|
};
|
|
117
134
|
|
|
118
|
-
if (options.uniqueKey) {
|
|
135
|
+
if (options.uniqueKey !== undefined) {
|
|
119
136
|
job.uniqueKey = options.uniqueKey;
|
|
120
137
|
}
|
|
121
138
|
|
|
122
139
|
try {
|
|
123
|
-
if (options.uniqueKey) {
|
|
140
|
+
if (options.uniqueKey !== undefined) {
|
|
124
141
|
// Use upsert with $setOnInsert for deduplication (scoped by name + uniqueKey)
|
|
125
142
|
const result = await this.ctx.collection.findOneAndUpdate(
|
|
126
143
|
{
|
|
@@ -141,12 +158,19 @@ export class JobScheduler {
|
|
|
141
158
|
throw new ConnectionError('Failed to enqueue job: findOneAndUpdate returned no document');
|
|
142
159
|
}
|
|
143
160
|
|
|
144
|
-
|
|
161
|
+
const persistedJob = this.ctx.documentToPersistedJob<T>(result);
|
|
162
|
+
if (persistedJob.status === JobStatus.PENDING) {
|
|
163
|
+
this.ctx.notifyPendingJob(persistedJob.name, persistedJob.nextRunAt);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
return persistedJob;
|
|
145
167
|
}
|
|
146
168
|
|
|
147
169
|
const result = await this.ctx.collection.insertOne(job as Document);
|
|
170
|
+
const persistedJob = { ...job, _id: result.insertedId } as PersistedJob<T>;
|
|
171
|
+
this.ctx.notifyPendingJob(persistedJob.name, persistedJob.nextRunAt);
|
|
148
172
|
|
|
149
|
-
return
|
|
173
|
+
return persistedJob;
|
|
150
174
|
} catch (error) {
|
|
151
175
|
if (error instanceof ConnectionError) {
|
|
152
176
|
throw error;
|
|
@@ -209,6 +233,7 @@ export class JobScheduler {
|
|
|
209
233
|
* @param data - Job payload, will be passed to the worker handler on each run
|
|
210
234
|
* @param options - Scheduling options (uniqueKey for deduplication)
|
|
211
235
|
* @returns Promise resolving to the created job document with `repeatInterval` set
|
|
236
|
+
* @throws {InvalidJobIdentifierError} If `name` or `uniqueKey` fails public identifier validation
|
|
212
237
|
* @throws {InvalidCronError} If cron expression is invalid
|
|
213
238
|
* @throws {ConnectionError} If database operation fails or scheduler not initialized
|
|
214
239
|
* @throws {PayloadTooLargeError} If payload exceeds configured `maxPayloadSize`
|
|
@@ -242,6 +267,7 @@ export class JobScheduler {
|
|
|
242
267
|
data: T,
|
|
243
268
|
options: ScheduleOptions = {},
|
|
244
269
|
): Promise<PersistedJob<T>> {
|
|
270
|
+
this.validateJobIdentifiers(name, options.uniqueKey);
|
|
245
271
|
this.validatePayloadSize(data);
|
|
246
272
|
|
|
247
273
|
// Validate cron and get next run date (throws InvalidCronError if invalid)
|
|
@@ -259,12 +285,12 @@ export class JobScheduler {
|
|
|
259
285
|
updatedAt: now,
|
|
260
286
|
};
|
|
261
287
|
|
|
262
|
-
if (options.uniqueKey) {
|
|
288
|
+
if (options.uniqueKey !== undefined) {
|
|
263
289
|
job.uniqueKey = options.uniqueKey;
|
|
264
290
|
}
|
|
265
291
|
|
|
266
292
|
try {
|
|
267
|
-
if (options.uniqueKey) {
|
|
293
|
+
if (options.uniqueKey !== undefined) {
|
|
268
294
|
// Use upsert with $setOnInsert for deduplication (scoped by name + uniqueKey)
|
|
269
295
|
const result = await this.ctx.collection.findOneAndUpdate(
|
|
270
296
|
{
|
|
@@ -287,12 +313,19 @@ export class JobScheduler {
|
|
|
287
313
|
);
|
|
288
314
|
}
|
|
289
315
|
|
|
290
|
-
|
|
316
|
+
const persistedJob = this.ctx.documentToPersistedJob<T>(result);
|
|
317
|
+
if (persistedJob.status === JobStatus.PENDING) {
|
|
318
|
+
this.ctx.notifyPendingJob(persistedJob.name, persistedJob.nextRunAt);
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
return persistedJob;
|
|
291
322
|
}
|
|
292
323
|
|
|
293
324
|
const result = await this.ctx.collection.insertOne(job as Document);
|
|
325
|
+
const persistedJob = { ...job, _id: result.insertedId } as PersistedJob<T>;
|
|
326
|
+
this.ctx.notifyPendingJob(persistedJob.name, persistedJob.nextRunAt);
|
|
294
327
|
|
|
295
|
-
return
|
|
328
|
+
return persistedJob;
|
|
296
329
|
} catch (error) {
|
|
297
330
|
if (error instanceof MonqueError) {
|
|
298
331
|
throw error;
|
|
@@ -10,6 +10,11 @@ import type { SchedulerContext } from './types.js';
|
|
|
10
10
|
*/
|
|
11
11
|
const DEFAULT_RETENTION_INTERVAL = 3600_000;
|
|
12
12
|
|
|
13
|
+
/**
|
|
14
|
+
* Statuses that are eligible for cleanup by the retention policy.
|
|
15
|
+
*/
|
|
16
|
+
export const CLEANUP_STATUSES = [JobStatus.COMPLETED, JobStatus.FAILED] as const;
|
|
17
|
+
|
|
13
18
|
/**
|
|
14
19
|
* Callbacks for timer-driven operations.
|
|
15
20
|
*
|
|
@@ -21,19 +26,26 @@ interface TimerCallbacks {
|
|
|
21
26
|
poll: () => Promise<void>;
|
|
22
27
|
/** Update heartbeats for claimed jobs */
|
|
23
28
|
updateHeartbeats: () => Promise<void>;
|
|
29
|
+
/** Whether change streams are currently active */
|
|
30
|
+
isChangeStreamActive: () => boolean;
|
|
24
31
|
}
|
|
25
32
|
|
|
26
33
|
/**
|
|
27
34
|
* Manages scheduler lifecycle timers and job cleanup.
|
|
28
35
|
*
|
|
29
|
-
* Owns poll
|
|
36
|
+
* Owns poll scheduling, heartbeat interval, cleanup interval, and the
|
|
30
37
|
* cleanupJobs logic. Extracted from Monque to keep the facade thin.
|
|
31
38
|
*
|
|
39
|
+
* Uses adaptive poll scheduling: when change streams are active, polls at
|
|
40
|
+
* `safetyPollInterval` (safety net only). When change streams are inactive,
|
|
41
|
+
* polls at `pollInterval` (primary discovery mechanism).
|
|
42
|
+
*
|
|
32
43
|
* @internal Not part of public API.
|
|
33
44
|
*/
|
|
34
45
|
export class LifecycleManager {
|
|
35
46
|
private readonly ctx: SchedulerContext;
|
|
36
|
-
private
|
|
47
|
+
private callbacks: TimerCallbacks | null = null;
|
|
48
|
+
private pollTimeoutId: ReturnType<typeof setTimeout> | null = null;
|
|
37
49
|
private heartbeatIntervalId: ReturnType<typeof setInterval> | null = null;
|
|
38
50
|
private cleanupIntervalId: ReturnType<typeof setInterval> | null = null;
|
|
39
51
|
|
|
@@ -44,18 +56,13 @@ export class LifecycleManager {
|
|
|
44
56
|
/**
|
|
45
57
|
* Start all lifecycle timers.
|
|
46
58
|
*
|
|
47
|
-
* Sets up poll
|
|
59
|
+
* Sets up adaptive poll scheduling, heartbeat interval, and (if configured)
|
|
48
60
|
* cleanup interval. Runs an initial poll immediately.
|
|
49
61
|
*
|
|
50
62
|
* @param callbacks - Functions to invoke on each timer tick
|
|
51
63
|
*/
|
|
52
64
|
startTimers(callbacks: TimerCallbacks): void {
|
|
53
|
-
|
|
54
|
-
this.pollIntervalId = setInterval(() => {
|
|
55
|
-
callbacks.poll().catch((error: unknown) => {
|
|
56
|
-
this.ctx.emit('job:error', { error: toError(error) });
|
|
57
|
-
});
|
|
58
|
-
}, this.ctx.options.pollInterval);
|
|
65
|
+
this.callbacks = callbacks;
|
|
59
66
|
|
|
60
67
|
// Start heartbeat interval for claimed jobs
|
|
61
68
|
this.heartbeatIntervalId = setInterval(() => {
|
|
@@ -80,26 +87,26 @@ export class LifecycleManager {
|
|
|
80
87
|
}, interval);
|
|
81
88
|
}
|
|
82
89
|
|
|
83
|
-
// Run initial poll immediately
|
|
84
|
-
|
|
85
|
-
this.ctx.emit('job:error', { error: toError(error) });
|
|
86
|
-
});
|
|
90
|
+
// Run initial poll immediately, then schedule the next one adaptively
|
|
91
|
+
this.executePollAndScheduleNext();
|
|
87
92
|
}
|
|
88
93
|
|
|
89
94
|
/**
|
|
90
95
|
* Stop all lifecycle timers.
|
|
91
96
|
*
|
|
92
|
-
* Clears poll, heartbeat, and cleanup
|
|
97
|
+
* Clears poll timeout, heartbeat interval, and cleanup interval.
|
|
93
98
|
*/
|
|
94
99
|
stopTimers(): void {
|
|
100
|
+
this.callbacks = null;
|
|
101
|
+
|
|
95
102
|
if (this.cleanupIntervalId) {
|
|
96
103
|
clearInterval(this.cleanupIntervalId);
|
|
97
104
|
this.cleanupIntervalId = null;
|
|
98
105
|
}
|
|
99
106
|
|
|
100
|
-
if (this.
|
|
101
|
-
|
|
102
|
-
this.
|
|
107
|
+
if (this.pollTimeoutId) {
|
|
108
|
+
clearTimeout(this.pollTimeoutId);
|
|
109
|
+
this.pollTimeoutId = null;
|
|
103
110
|
}
|
|
104
111
|
|
|
105
112
|
if (this.heartbeatIntervalId) {
|
|
@@ -108,6 +115,59 @@ export class LifecycleManager {
|
|
|
108
115
|
}
|
|
109
116
|
}
|
|
110
117
|
|
|
118
|
+
/**
|
|
119
|
+
* Reset the poll timer to reschedule the next poll.
|
|
120
|
+
*
|
|
121
|
+
* Called after change-stream-triggered polls to ensure the safety poll timer
|
|
122
|
+
* is recalculated (not fired redundantly from an old schedule).
|
|
123
|
+
*/
|
|
124
|
+
resetPollTimer(): void {
|
|
125
|
+
this.scheduleNextPoll();
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Execute a poll and schedule the next one adaptively.
|
|
130
|
+
*/
|
|
131
|
+
private executePollAndScheduleNext(): void {
|
|
132
|
+
if (!this.callbacks) {
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
this.callbacks
|
|
137
|
+
.poll()
|
|
138
|
+
.catch((error: unknown) => {
|
|
139
|
+
this.ctx.emit('job:error', { error: toError(error) });
|
|
140
|
+
})
|
|
141
|
+
.finally(() => {
|
|
142
|
+
this.scheduleNextPoll();
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Schedule the next poll using adaptive timing.
|
|
148
|
+
*
|
|
149
|
+
* When change streams are active, uses `safetyPollInterval` (longer, safety net only).
|
|
150
|
+
* When change streams are inactive, uses `pollInterval` (shorter, primary discovery).
|
|
151
|
+
*/
|
|
152
|
+
private scheduleNextPoll(): void {
|
|
153
|
+
if (this.pollTimeoutId) {
|
|
154
|
+
clearTimeout(this.pollTimeoutId);
|
|
155
|
+
this.pollTimeoutId = null;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
if (!this.ctx.isRunning() || !this.callbacks) {
|
|
159
|
+
return;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
const delay = this.callbacks.isChangeStreamActive()
|
|
163
|
+
? this.ctx.options.safetyPollInterval
|
|
164
|
+
: this.ctx.options.pollInterval;
|
|
165
|
+
|
|
166
|
+
this.pollTimeoutId = setTimeout(() => {
|
|
167
|
+
this.executePollAndScheduleNext();
|
|
168
|
+
}, delay);
|
|
169
|
+
}
|
|
170
|
+
|
|
111
171
|
/**
|
|
112
172
|
* Clean up old completed and failed jobs based on retention policy.
|
|
113
173
|
*
|
|
@@ -30,6 +30,7 @@ export interface ResolvedMonqueOptions
|
|
|
30
30
|
> {
|
|
31
31
|
// Ensure resolved options use the new naming convention
|
|
32
32
|
workerConcurrency: number;
|
|
33
|
+
safetyPollInterval: number;
|
|
33
34
|
}
|
|
34
35
|
/**
|
|
35
36
|
* Shared context provided to all internal Monque services.
|
|
@@ -59,6 +60,12 @@ export interface SchedulerContext {
|
|
|
59
60
|
/** Type-safe event emitter */
|
|
60
61
|
emit: <K extends keyof MonqueEventMap>(event: K, payload: MonqueEventMap[K]) => boolean;
|
|
61
62
|
|
|
63
|
+
/** Notify the local scheduler about a pending job transition */
|
|
64
|
+
notifyPendingJob: (name: string, nextRunAt: Date) => void;
|
|
65
|
+
|
|
66
|
+
/** Notify that a job has finished processing (for reactive shutdown drain) */
|
|
67
|
+
notifyJobFinished: () => void;
|
|
68
|
+
|
|
62
69
|
/** Convert MongoDB document to typed PersistedJob */
|
|
63
70
|
documentToPersistedJob: <T>(doc: WithId<Document>) => PersistedJob<T>;
|
|
64
71
|
}
|
package/src/scheduler/types.ts
CHANGED
|
@@ -196,4 +196,18 @@ export interface MonqueOptions {
|
|
|
196
196
|
* @default 5000
|
|
197
197
|
*/
|
|
198
198
|
statsCacheTtlMs?: number;
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Interval in milliseconds between safety polls when change streams are active.
|
|
202
|
+
*
|
|
203
|
+
* When change streams are connected, the scheduler uses them as the primary
|
|
204
|
+
* notification mechanism and only polls at this longer interval as a safety net
|
|
205
|
+
* to catch any missed events. When change streams are unavailable, the scheduler
|
|
206
|
+
* falls back to the standard `pollInterval`.
|
|
207
|
+
*
|
|
208
|
+
* This is separate from `heartbeatInterval`, which updates job liveness signals.
|
|
209
|
+
*
|
|
210
|
+
* @default 30000 (30 seconds)
|
|
211
|
+
*/
|
|
212
|
+
safetyPollInterval?: number;
|
|
199
213
|
}
|
package/src/shared/errors.ts
CHANGED
|
@@ -199,6 +199,35 @@ export class InvalidCursorError extends MonqueError {
|
|
|
199
199
|
}
|
|
200
200
|
}
|
|
201
201
|
|
|
202
|
+
/**
|
|
203
|
+
* Error thrown when a public job identifier fails validation.
|
|
204
|
+
*
|
|
205
|
+
* @example
|
|
206
|
+
* ```typescript
|
|
207
|
+
* try {
|
|
208
|
+
* await monque.enqueue('invalid job name', {});
|
|
209
|
+
* } catch (error) {
|
|
210
|
+
* if (error instanceof InvalidJobIdentifierError) {
|
|
211
|
+
* console.error(`Invalid ${error.field}: ${error.message}`);
|
|
212
|
+
* }
|
|
213
|
+
* }
|
|
214
|
+
* ```
|
|
215
|
+
*/
|
|
216
|
+
export class InvalidJobIdentifierError extends MonqueError {
|
|
217
|
+
constructor(
|
|
218
|
+
public readonly field: 'name' | 'uniqueKey',
|
|
219
|
+
public readonly value: string,
|
|
220
|
+
message: string,
|
|
221
|
+
) {
|
|
222
|
+
super(message);
|
|
223
|
+
this.name = 'InvalidJobIdentifierError';
|
|
224
|
+
/* istanbul ignore next -- @preserve captureStackTrace is always available in Node.js */
|
|
225
|
+
if (Error.captureStackTrace) {
|
|
226
|
+
Error.captureStackTrace(this, InvalidJobIdentifierError);
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
202
231
|
/**
|
|
203
232
|
* Error thrown when a statistics aggregation times out.
|
|
204
233
|
*
|
package/src/shared/index.ts
CHANGED
|
@@ -3,6 +3,7 @@ export {
|
|
|
3
3
|
ConnectionError,
|
|
4
4
|
InvalidCronError,
|
|
5
5
|
InvalidCursorError,
|
|
6
|
+
InvalidJobIdentifierError,
|
|
6
7
|
JobStateError,
|
|
7
8
|
MonqueError,
|
|
8
9
|
PayloadTooLargeError,
|
|
@@ -17,4 +18,6 @@ export {
|
|
|
17
18
|
getNextCronDate,
|
|
18
19
|
toError,
|
|
19
20
|
validateCronExpression,
|
|
21
|
+
validateJobName,
|
|
22
|
+
validateUniqueKey,
|
|
20
23
|
} from './utils/index.js';
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { InvalidJobIdentifierError } from '../errors.js';
|
|
2
|
+
|
|
3
|
+
const JOB_NAME_PATTERN = /^[^\s\p{Cc}]+$/u;
|
|
4
|
+
const CONTROL_CHARACTER_PATTERN = /\p{Cc}/u;
|
|
5
|
+
|
|
6
|
+
const MAX_JOB_NAME_LENGTH = 255;
|
|
7
|
+
const MAX_UNIQUE_KEY_LENGTH = 1024;
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Validate a public job name before it is registered or scheduled.
|
|
11
|
+
*
|
|
12
|
+
* @param name - The job name to validate
|
|
13
|
+
* @throws {InvalidJobIdentifierError} If the job name is empty, too long, or contains unsupported characters
|
|
14
|
+
*/
|
|
15
|
+
export function validateJobName(name: string): void {
|
|
16
|
+
if (name.length === 0 || name.trim().length === 0) {
|
|
17
|
+
throw new InvalidJobIdentifierError(
|
|
18
|
+
'name',
|
|
19
|
+
name,
|
|
20
|
+
'Job name cannot be empty or whitespace only.',
|
|
21
|
+
);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
if (name.length > MAX_JOB_NAME_LENGTH) {
|
|
25
|
+
throw new InvalidJobIdentifierError(
|
|
26
|
+
'name',
|
|
27
|
+
name,
|
|
28
|
+
`Job name cannot exceed ${MAX_JOB_NAME_LENGTH} characters.`,
|
|
29
|
+
);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
if (!JOB_NAME_PATTERN.test(name)) {
|
|
33
|
+
throw new InvalidJobIdentifierError(
|
|
34
|
+
'name',
|
|
35
|
+
name,
|
|
36
|
+
'Job name cannot contain whitespace or control characters.',
|
|
37
|
+
);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Validate a deduplication key before it is stored or used in a unique query.
|
|
43
|
+
*
|
|
44
|
+
* @param uniqueKey - The unique key to validate
|
|
45
|
+
* @throws {InvalidJobIdentifierError} If the key is empty, too long, or contains control characters
|
|
46
|
+
*/
|
|
47
|
+
export function validateUniqueKey(uniqueKey: string): void {
|
|
48
|
+
if (uniqueKey.length === 0 || uniqueKey.trim().length === 0) {
|
|
49
|
+
throw new InvalidJobIdentifierError(
|
|
50
|
+
'uniqueKey',
|
|
51
|
+
uniqueKey,
|
|
52
|
+
'Unique key cannot be empty or whitespace only.',
|
|
53
|
+
);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
if (uniqueKey.length > MAX_UNIQUE_KEY_LENGTH) {
|
|
57
|
+
throw new InvalidJobIdentifierError(
|
|
58
|
+
'uniqueKey',
|
|
59
|
+
uniqueKey,
|
|
60
|
+
`Unique key cannot exceed ${MAX_UNIQUE_KEY_LENGTH} characters.`,
|
|
61
|
+
);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
if (CONTROL_CHARACTER_PATTERN.test(uniqueKey)) {
|
|
65
|
+
throw new InvalidJobIdentifierError(
|
|
66
|
+
'uniqueKey',
|
|
67
|
+
uniqueKey,
|
|
68
|
+
'Unique key cannot contain control characters.',
|
|
69
|
+
);
|
|
70
|
+
}
|
|
71
|
+
}
|