enqiu 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/dist/api.js ADDED
@@ -0,0 +1,519 @@
1
+ import { MemoryQueue, JobCancelledError, JobExpiredError, JobFailedError, JobTimeoutError, QueueClosedError, } from "./memory.js";
2
+ import { RedisQueue, } from "./redis.js";
3
+ import { JobSerializationError, cloneJobValue, } from "./codec.js";
4
+ import { MemoryScheduler } from "./memory-scheduler.js";
5
+ const definitionMarker = Symbol("enqiu.job");
6
+ const reservedNames = new Set(["queue", "worker"]);
7
+ export function job(definition) {
8
+ if (!definition || typeof definition !== "object") {
9
+ throw new TypeError("job() requires a definition object");
10
+ }
11
+ if (!isStandardSchema(definition.input)) {
12
+ throw new TypeError("job.input must implement Standard Schema");
13
+ }
14
+ if (typeof definition.run !== "function") {
15
+ throw new TypeError("job.run must be a function");
16
+ }
17
+ return Object.freeze({
18
+ ...definition,
19
+ [definitionMarker]: true,
20
+ });
21
+ }
22
+ export class JobValidationError extends TypeError {
23
+ issues;
24
+ constructor(name, issues) {
25
+ super(`Invalid input for job "${name}": ${issues[0]?.message ?? "validation failed"}`);
26
+ this.name = "JobValidationError";
27
+ this.issues = issues;
28
+ }
29
+ }
30
+ class PublicJobHandle {
31
+ legacy;
32
+ resultPromise;
33
+ constructor(legacy) {
34
+ this.legacy = legacy;
35
+ }
36
+ get id() {
37
+ return this.legacy.id;
38
+ }
39
+ get name() {
40
+ return this.legacy.name;
41
+ }
42
+ get input() {
43
+ return this.legacy.input;
44
+ }
45
+ get status() {
46
+ return this.legacy.status;
47
+ }
48
+ get deduplicated() {
49
+ return this.legacy.deduplicated;
50
+ }
51
+ get result() {
52
+ this.resultPromise ??= this.legacy.result;
53
+ return this.resultPromise;
54
+ }
55
+ async cancel(reason) {
56
+ return this.legacy.cancel(reason);
57
+ }
58
+ async refresh() {
59
+ if ("refresh" in this.legacy) {
60
+ return this.legacy.refresh();
61
+ }
62
+ return this.legacy.snapshot();
63
+ }
64
+ }
65
+ class EnqiuFacade {
66
+ options;
67
+ api;
68
+ definitions = new Map();
69
+ memory;
70
+ redis;
71
+ memoryScheduler;
72
+ workerRunning;
73
+ constructor(definitions, options) {
74
+ this.options = options;
75
+ const handlers = {};
76
+ for (const [name, definition] of Object.entries(definitions)) {
77
+ if (reservedNames.has(name)) {
78
+ throw new TypeError(`"${name}" is reserved by enqiu`);
79
+ }
80
+ const normalized = normalizeDefinition(definition);
81
+ this.definitions.set(name, normalized);
82
+ handlers[name] = async (input, context) => {
83
+ const output = await normalized.run(input, createContext(context, options.name ?? "default", options.telemetry));
84
+ return cloneJobValue(output);
85
+ };
86
+ }
87
+ if (this.definitions.size === 0) {
88
+ throw new TypeError("At least one job definition is required");
89
+ }
90
+ const concurrency = options.worker === false ? 1 : options.worker?.concurrency;
91
+ const autoStart = options.worker === false
92
+ ? false
93
+ : options.worker?.autoStart ?? true;
94
+ const retry = normalizeLegacyRetry(options.retry);
95
+ if (options.driver) {
96
+ this.redis = new RedisQueue(handlers, compact({
97
+ driver: options.driver,
98
+ name: options.name,
99
+ worker: options.worker !== false,
100
+ concurrency,
101
+ autoStart,
102
+ retry: retry,
103
+ timeout: options.timeout,
104
+ historyLimit: options.historyLimit,
105
+ logLimit: options.logLimit,
106
+ }));
107
+ this.workerRunning = autoStart && options.worker !== false;
108
+ }
109
+ else {
110
+ this.memory = new MemoryQueue(handlers, compact({
111
+ name: options.name,
112
+ concurrency,
113
+ autoStart,
114
+ retry,
115
+ timeout: options.timeout,
116
+ historyLimit: options.historyLimit,
117
+ logLimit: options.logLimit,
118
+ }));
119
+ this.memoryScheduler = new MemoryScheduler();
120
+ this.workerRunning = autoStart;
121
+ }
122
+ const target = {};
123
+ for (const name of this.definitions.keys()) {
124
+ target[name] = this.createCallable(name);
125
+ }
126
+ target.queue = this.createQueueApi();
127
+ target.worker = this.createWorkerApi();
128
+ this.api = Object.freeze(target);
129
+ this.connectTelemetry();
130
+ }
131
+ get queue() {
132
+ return this.redis ?? this.memory;
133
+ }
134
+ createCallable(name) {
135
+ const definition = this.definitions.get(name);
136
+ if (!definition) {
137
+ throw new TypeError(`Unknown job "${name}"`);
138
+ }
139
+ const callable = async (input, options = {}) => {
140
+ const value = cloneJobValue(await validateInput(name, definition.schema, input));
141
+ const legacy = this.addLegacy(name, value, toLegacyOptions(options, definition.policy, name, value));
142
+ if ("accepted" in legacy) {
143
+ await legacy.accepted;
144
+ }
145
+ return new PublicJobHandle(legacy);
146
+ };
147
+ const bulk = async (inputs, options = {}) => {
148
+ if (options.ids && options.ids.length !== inputs.length) {
149
+ throw new RangeError("bulk ids must match the number of inputs");
150
+ }
151
+ const values = await Promise.all(inputs.map(async (input) => cloneJobValue(await validateInput(name, definition.schema, input))));
152
+ const handles = values.map((value, index) => {
153
+ const id = options.ids?.[index];
154
+ const submitOptions = compact({
155
+ ...options,
156
+ ids: undefined,
157
+ id,
158
+ });
159
+ return this.addLegacy(name, value, toLegacyOptions(submitOptions, definition.policy, name, value));
160
+ });
161
+ await Promise.all(handles.map((handle) => "accepted" in handle ? handle.accepted : Promise.resolve()));
162
+ return handles.map((handle) => new PublicJobHandle(handle));
163
+ };
164
+ const schedule = async (options) => {
165
+ const value = cloneJobValue(await validateInput(name, definition.schema, options.input));
166
+ if (this.redis) {
167
+ return this.redis.upsertSchedule({
168
+ ...options,
169
+ input: value,
170
+ jobName: name,
171
+ submit: toLegacyOptions({}, definition.policy, name, value),
172
+ });
173
+ }
174
+ const scheduleId = options.id?.trim() || name;
175
+ return this.memoryScheduler.upsert({
176
+ ...options,
177
+ input: value,
178
+ jobName: name,
179
+ enqueue: async (scheduledInput, occurrence) => {
180
+ const handle = await callable(scheduledInput, {
181
+ id: `${this.options.name ?? "default"}:schedule:${scheduleId}:${occurrence}`,
182
+ });
183
+ void handle;
184
+ },
185
+ });
186
+ };
187
+ Object.defineProperties(callable, {
188
+ bulk: { value: bulk, enumerable: true },
189
+ schedule: { value: schedule, enumerable: true },
190
+ input: { value: definition.schema, enumerable: true },
191
+ });
192
+ return callable;
193
+ }
194
+ createQueueApi() {
195
+ return Object.freeze({
196
+ get: async (id) => (await this.queue.get(id)),
197
+ list: async (query = {}) => {
198
+ if (this.redis) {
199
+ return this.redis.list(query);
200
+ }
201
+ let jobs = this.memory?.list(query.status) ?? [];
202
+ if (query.name) {
203
+ jobs = jobs.filter((item) => item.name === query.name);
204
+ }
205
+ if (query.after !== undefined) {
206
+ jobs = jobs.filter((item) => item.createdAt > query.after);
207
+ }
208
+ if (query.before !== undefined) {
209
+ jobs = jobs.filter((item) => item.createdAt < query.before);
210
+ }
211
+ const limit = query.limit ?? 100;
212
+ return {
213
+ jobs: jobs.slice(0, limit),
214
+ };
215
+ },
216
+ stats: async () => this.redis ? this.redis.stats() : this.memory.stats,
217
+ pause: async () => {
218
+ if (this.redis) {
219
+ await this.redis.pauseQueue();
220
+ }
221
+ else {
222
+ this.memory?.pause();
223
+ }
224
+ },
225
+ resume: async () => {
226
+ if (this.redis) {
227
+ await this.redis.resumeQueue();
228
+ }
229
+ else {
230
+ this.memory?.start();
231
+ }
232
+ },
233
+ setConcurrency: async (limit) => {
234
+ if (this.redis) {
235
+ await this.redis.setGlobalConcurrency(limit);
236
+ return;
237
+ }
238
+ this.memory.concurrency = limit;
239
+ },
240
+ redrive: async (id) => {
241
+ if (this.redis) {
242
+ return new PublicJobHandle((await this.redis.redrive(id)));
243
+ }
244
+ const handle = this.memory?.retry(id);
245
+ if (!handle) {
246
+ throw new Error(`Job "${id}" cannot be redriven`);
247
+ }
248
+ return new PublicJobHandle(handle);
249
+ },
250
+ cleanup: async (query = {}) => {
251
+ if (this.redis) {
252
+ return this.redis.cleanup(query);
253
+ }
254
+ return (this.memory?.cleanup(compact({
255
+ olderThan: query.olderThan,
256
+ limit: query.limit,
257
+ })) ?? []);
258
+ },
259
+ on: (event, listener) => {
260
+ const events = this.queue;
261
+ return events.on(event, listener);
262
+ },
263
+ });
264
+ }
265
+ createWorkerApi() {
266
+ const facade = this;
267
+ return Object.freeze({
268
+ get running() {
269
+ return facade.workerRunning;
270
+ },
271
+ start: async (options = {}) => {
272
+ if (options.concurrency !== undefined) {
273
+ if (facade.redis) {
274
+ facade.redis.setWorkerConcurrency(options.concurrency);
275
+ }
276
+ else {
277
+ facade.memory.concurrency =
278
+ options.concurrency;
279
+ }
280
+ }
281
+ facade.queue.start();
282
+ facade.workerRunning = true;
283
+ },
284
+ pause: async () => {
285
+ facade.queue.pause();
286
+ facade.workerRunning = false;
287
+ },
288
+ resume: async () => {
289
+ facade.queue.start();
290
+ facade.workerRunning = true;
291
+ },
292
+ onIdle: async () => facade.queue.onIdle(),
293
+ close: async (options) => {
294
+ await facade.queue.close(options);
295
+ facade.memoryScheduler?.close();
296
+ facade.workerRunning = false;
297
+ },
298
+ });
299
+ }
300
+ connectTelemetry() {
301
+ const telemetry = this.options.telemetry;
302
+ if (!telemetry) {
303
+ return;
304
+ }
305
+ const events = [
306
+ "added",
307
+ "started",
308
+ "retry",
309
+ "succeeded",
310
+ "failed",
311
+ "cancelled",
312
+ "expired",
313
+ ];
314
+ const source = this.queue;
315
+ for (const event of events) {
316
+ source.on(event, (payload) => {
317
+ const snapshot = "job" in Object(payload)
318
+ ? payload.job
319
+ : payload;
320
+ telemetry.emit({
321
+ type: `job.${event}`,
322
+ queue: this.options.name ?? "default",
323
+ timestamp: Date.now(),
324
+ job: snapshot,
325
+ });
326
+ });
327
+ }
328
+ }
329
+ addLegacy(name, value, options) {
330
+ if (this.redis) {
331
+ return this.redis.add(name, value, options);
332
+ }
333
+ return this.memory.add(name, value, options);
334
+ }
335
+ }
336
+ export function enqiu(definitions, options = {}) {
337
+ return new EnqiuFacade(definitions, options).api;
338
+ }
339
+ function normalizeDefinition(definition) {
340
+ if (typeof definition === "function") {
341
+ return {
342
+ schema: undefined,
343
+ run: definition,
344
+ policy: {},
345
+ };
346
+ }
347
+ if (!definition ||
348
+ typeof definition !== "object" ||
349
+ definition[definitionMarker] !== true) {
350
+ throw new TypeError("Every job must be a handler or a definition created with job()");
351
+ }
352
+ const { input, run, retry, timeout, expiresIn, concurrency, throttle, debounce, } = definition;
353
+ return {
354
+ schema: input,
355
+ run: run,
356
+ policy: compact({
357
+ retry,
358
+ timeout,
359
+ expiresIn,
360
+ concurrency,
361
+ throttle,
362
+ debounce,
363
+ }),
364
+ };
365
+ }
366
+ async function validateInput(name, schema, input) {
367
+ if (!schema) {
368
+ return input;
369
+ }
370
+ const result = await schema["~standard"].validate(input);
371
+ if (result.issues) {
372
+ throw new JobValidationError(name, result.issues);
373
+ }
374
+ return result.value;
375
+ }
376
+ function isStandardSchema(value) {
377
+ if (!value || typeof value !== "object") {
378
+ return false;
379
+ }
380
+ const standard = value["~standard"];
381
+ return (standard?.version === 1 &&
382
+ typeof standard.vendor === "string" &&
383
+ typeof standard.validate === "function");
384
+ }
385
+ function normalizeLegacyRetry(value) {
386
+ if (typeof value === "number" || value === undefined) {
387
+ return value;
388
+ }
389
+ if (!Number.isInteger(value.attempts) || value.attempts < 1) {
390
+ throw new RangeError("retry.attempts must be a positive integer");
391
+ }
392
+ return compact({
393
+ retries: value.attempts - 1,
394
+ backoff: value.backoff,
395
+ when: value.when,
396
+ });
397
+ }
398
+ function toLegacyOptions(options, policy, name, input) {
399
+ const priority = typeof options.priority === "string"
400
+ ? { low: -10, normal: 0, high: 10 }[options.priority]
401
+ : options.priority;
402
+ const concurrency = policy.concurrency === undefined
403
+ ? undefined
404
+ : typeof policy.concurrency === "number"
405
+ ? {
406
+ limit: policy.concurrency,
407
+ key: `${name}:*`,
408
+ }
409
+ : {
410
+ limit: policy.concurrency.limit,
411
+ key: `${name}:${resolvePolicyKey("concurrency.by", policy.concurrency.by?.(input) ?? "*")}`,
412
+ };
413
+ const throttle = policy.throttle
414
+ ? {
415
+ limit: policy.throttle.limit,
416
+ interval: policy.throttle.per,
417
+ burst: policy.throttle.burst ?? policy.throttle.limit,
418
+ key: `${name}:${resolvePolicyKey("throttle.by", policy.throttle.by?.(input) ?? "*")}`,
419
+ }
420
+ : undefined;
421
+ const debounce = policy.debounce
422
+ ? {
423
+ wait: policy.debounce.wait,
424
+ mode: policy.debounce.mode,
425
+ key: resolvePolicyKey("debounce.by", policy.debounce.by(input)),
426
+ }
427
+ : undefined;
428
+ return compact({
429
+ id: options.id,
430
+ key: options.idempotencyKey,
431
+ keyRetention: options.idempotencyKey
432
+ ? options.idempotencyTtl ?? 24 * 60 * 60 * 1000
433
+ : undefined,
434
+ delay: options.delay,
435
+ priority,
436
+ retry: normalizeLegacyRetry(options.retry ?? policy.retry),
437
+ timeout: options.timeout ?? policy.timeout,
438
+ expiresIn: options.expiresIn ?? policy.expiresIn,
439
+ concurrency,
440
+ throttle,
441
+ debounce,
442
+ signal: options.signal,
443
+ });
444
+ }
445
+ function resolvePolicyKey(name, value) {
446
+ if (typeof value !== "string" || !value.trim()) {
447
+ throw new TypeError(`${name} must return a non-empty string`);
448
+ }
449
+ return value;
450
+ }
451
+ function createContext(legacy, queue, telemetry) {
452
+ const log = createLogger(legacy, queue, telemetry);
453
+ return {
454
+ id: legacy.id,
455
+ name: legacy.name,
456
+ attempt: legacy.attempt,
457
+ signal: legacy.signal,
458
+ reportProgress: async (progress) => {
459
+ validateProgress(progress);
460
+ const safeProgress = cloneJobValue(progress);
461
+ legacy.progress(safeProgress);
462
+ telemetry?.emit({
463
+ type: "job.progress",
464
+ queue,
465
+ timestamp: Date.now(),
466
+ fields: {
467
+ jobId: legacy.id,
468
+ jobName: legacy.name,
469
+ progress: safeProgress,
470
+ },
471
+ });
472
+ },
473
+ log,
474
+ };
475
+ }
476
+ function createLogger(context, queue, telemetry) {
477
+ const write = (level, message, fields) => {
478
+ if (!message) {
479
+ throw new TypeError("Job log messages must not be empty");
480
+ }
481
+ const entry = cloneJobValue({
482
+ timestamp: Date.now(),
483
+ level,
484
+ message,
485
+ ...(fields === undefined ? {} : { fields }),
486
+ });
487
+ context.log(entry);
488
+ telemetry?.emit({
489
+ type: `job.log.${level}`,
490
+ queue,
491
+ timestamp: Date.now(),
492
+ fields: {
493
+ jobId: context.id,
494
+ jobName: context.name,
495
+ message: entry.message,
496
+ ...(entry.fields ?? {}),
497
+ },
498
+ });
499
+ };
500
+ return Object.freeze({
501
+ debug: (message, fields) => write("debug", message, fields),
502
+ info: (message, fields) => write("info", message, fields),
503
+ warn: (message, fields) => write("warn", message, fields),
504
+ error: (message, fields) => write("error", message, fields),
505
+ });
506
+ }
507
+ function validateProgress(progress) {
508
+ if (!Number.isFinite(progress.completed) ||
509
+ !Number.isFinite(progress.total) ||
510
+ progress.completed < 0 ||
511
+ progress.total <= 0 ||
512
+ progress.completed > progress.total) {
513
+ throw new RangeError("Progress requires 0 <= completed <= total and total > 0");
514
+ }
515
+ }
516
+ function compact(value) {
517
+ return Object.fromEntries(Object.entries(value).filter(([, entry]) => entry !== undefined));
518
+ }
519
+ export { JobCancelledError, JobExpiredError, JobFailedError, JobSerializationError, JobTimeoutError, QueueClosedError, };
@@ -0,0 +1,8 @@
1
+ export declare class JobSerializationError extends TypeError {
2
+ readonly path: string;
3
+ constructor(path: string, reason: string);
4
+ }
5
+ /** Serialize the exact data contract shared by the memory and Redis drivers. */
6
+ export declare function encodeJobValue(value: unknown): string;
7
+ export declare function decodeJobValue(value: string): unknown;
8
+ export declare function cloneJobValue<T>(value: T): T;
package/dist/codec.js ADDED
@@ -0,0 +1,74 @@
1
+ export class JobSerializationError extends TypeError {
2
+ path;
3
+ constructor(path, reason) {
4
+ super(`Job data at ${path} is not JSON-safe: ${reason}`);
5
+ this.name = "JobSerializationError";
6
+ this.path = path;
7
+ }
8
+ }
9
+ /** Serialize the exact data contract shared by the memory and Redis drivers. */
10
+ export function encodeJobValue(value) {
11
+ assertJsonSafe(value, "$", new Set());
12
+ return JSON.stringify({ value });
13
+ }
14
+ export function decodeJobValue(value) {
15
+ const envelope = JSON.parse(value);
16
+ return Object.prototype.hasOwnProperty.call(envelope, "value")
17
+ ? envelope.value
18
+ : undefined;
19
+ }
20
+ export function cloneJobValue(value) {
21
+ return decodeJobValue(encodeJobValue(value));
22
+ }
23
+ function assertJsonSafe(value, path, ancestors) {
24
+ if (value === null ||
25
+ typeof value === "string" ||
26
+ typeof value === "boolean") {
27
+ return;
28
+ }
29
+ if (value === undefined) {
30
+ if (path !== "$") {
31
+ throw new JobSerializationError(path, "undefined is only valid as a root value");
32
+ }
33
+ return;
34
+ }
35
+ if (typeof value === "number") {
36
+ if (!Number.isFinite(value)) {
37
+ throw new JobSerializationError(path, "numbers must be finite");
38
+ }
39
+ return;
40
+ }
41
+ if (typeof value === "bigint" ||
42
+ typeof value === "function" ||
43
+ typeof value === "symbol") {
44
+ throw new JobSerializationError(path, `${typeof value} is unsupported`);
45
+ }
46
+ if (typeof value !== "object") {
47
+ throw new JobSerializationError(path, "unsupported value");
48
+ }
49
+ if (ancestors.has(value)) {
50
+ throw new JobSerializationError(path, "circular reference");
51
+ }
52
+ ancestors.add(value);
53
+ if (Array.isArray(value)) {
54
+ for (let index = 0; index < value.length; index += 1) {
55
+ if (!(index in value)) {
56
+ throw new JobSerializationError(`${path}[${index}]`, "sparse array entries are unsupported");
57
+ }
58
+ assertJsonSafe(value[index], `${path}[${index}]`, ancestors);
59
+ }
60
+ ancestors.delete(value);
61
+ return;
62
+ }
63
+ const prototype = Object.getPrototypeOf(value);
64
+ if (prototype !== Object.prototype && prototype !== null) {
65
+ throw new JobSerializationError(path, "only plain objects and arrays are supported");
66
+ }
67
+ for (const [key, entry] of Object.entries(value)) {
68
+ if (entry === undefined) {
69
+ throw new JobSerializationError(`${path}.${key}`, "undefined object fields are unsupported");
70
+ }
71
+ assertJsonSafe(entry, `${path}.${key}`, ancestors);
72
+ }
73
+ ancestors.delete(value);
74
+ }
package/dist/cron.d.ts ADDED
@@ -0,0 +1,19 @@
1
+ interface CronField {
2
+ readonly wildcard: boolean;
3
+ readonly values: ReadonlySet<number>;
4
+ }
5
+ export interface ParsedCron {
6
+ readonly expression: string;
7
+ readonly minute: CronField;
8
+ readonly hour: CronField;
9
+ readonly dayOfMonth: CronField;
10
+ readonly month: CronField;
11
+ readonly dayOfWeek: CronField;
12
+ }
13
+ export declare class CronExpressionError extends TypeError {
14
+ constructor(message: string);
15
+ }
16
+ export declare function parseCron(expression: string): ParsedCron;
17
+ export declare function validateTimeZone(timeZone: string): string;
18
+ export declare function nextCronOccurrence(cron: string | ParsedCron, timeZone: string, after: number): number;
19
+ export {};