bullmq-dash 0.3.0 → 0.3.2

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.
Files changed (3) hide show
  1. package/README.md +40 -21
  2. package/dist/index.js +1548 -1430
  3. package/package.json +4 -1
package/dist/index.js CHANGED
@@ -3,7 +3,64 @@
3
3
  // src/app.ts
4
4
  import { createCliRenderer } from "@opentui/core";
5
5
 
6
+ // src/data/queue-sort.ts
7
+ var QUEUE_SORT_FIELDS = [
8
+ "name",
9
+ "task-size",
10
+ "waiting",
11
+ "active",
12
+ "completed",
13
+ "failed",
14
+ "delayed"
15
+ ];
16
+ function queueSortLabel(sortBy, order) {
17
+ const label = sortBy === "task-size" ? "size" : sortBy;
18
+ return `${label} ${order}`;
19
+ }
20
+ function metric(queue, sortBy) {
21
+ switch (sortBy) {
22
+ case "name":
23
+ return queue.name;
24
+ case "task-size":
25
+ return queue.total;
26
+ case "waiting":
27
+ return queue.counts.wait;
28
+ case "active":
29
+ return queue.counts.active;
30
+ case "completed":
31
+ return queue.counts.completed;
32
+ case "failed":
33
+ return queue.counts.failed;
34
+ case "delayed":
35
+ return queue.counts.delayed;
36
+ }
37
+ }
38
+ function defaultSortOrder(sortBy) {
39
+ return sortBy === "name" ? "asc" : "desc";
40
+ }
41
+ function sortQueues(queues, sortBy = "name", order = defaultSortOrder(sortBy)) {
42
+ const direction = order === "asc" ? 1 : -1;
43
+ return queues.toSorted((a, b) => {
44
+ const aMetric = metric(a, sortBy);
45
+ const bMetric = metric(b, sortBy);
46
+ if (typeof aMetric === "number" && typeof bMetric === "number") {
47
+ const diff = aMetric - bMetric;
48
+ if (diff !== 0)
49
+ return diff * direction;
50
+ return a.name.localeCompare(b.name);
51
+ }
52
+ return String(aMetric).localeCompare(String(bMetric)) * direction;
53
+ });
54
+ }
55
+
6
56
  // src/state.ts
57
+ var QUEUE_SORT_SEQUENCE = [
58
+ { sortBy: "name", sortOrder: "asc" },
59
+ { sortBy: "task-size", sortOrder: "desc" },
60
+ { sortBy: "failed", sortOrder: "desc" },
61
+ { sortBy: "waiting", sortOrder: "desc" }
62
+ ];
63
+
7
64
  class StateManager {
8
65
  state;
9
66
  listeners = new Set;
@@ -14,6 +71,8 @@ class StateManager {
14
71
  globalMetrics: null,
15
72
  queues: [],
16
73
  selectedQueueIndex: 0,
74
+ queueSortBy: "name",
75
+ queueSortOrder: "asc",
17
76
  jobs: [],
18
77
  jobsTotal: 0,
19
78
  jobsPage: 1,
@@ -84,6 +143,20 @@ class StateManager {
84
143
  });
85
144
  }
86
145
  }
146
+ cycleQueueSort() {
147
+ const { queueSortBy, queueSortOrder } = this.state;
148
+ const currentIndex = QUEUE_SORT_SEQUENCE.findIndex((option) => option.sortBy === queueSortBy && option.sortOrder === queueSortOrder);
149
+ const next = QUEUE_SORT_SEQUENCE[(currentIndex + 1) % QUEUE_SORT_SEQUENCE.length];
150
+ const sortedQueues = sortQueues(this.state.queues, next.sortBy, next.sortOrder);
151
+ this.setState({
152
+ queues: sortedQueues,
153
+ queueSortBy: next.sortBy,
154
+ queueSortOrder: next.sortOrder,
155
+ selectedQueueIndex: 0,
156
+ selectedJobIndex: 0,
157
+ jobsPage: 1
158
+ });
159
+ }
87
160
  selectNextJob() {
88
161
  const { jobs, selectedJobIndex } = this.state;
89
162
  if (selectedJobIndex < jobs.length - 1) {
@@ -230,1049 +303,170 @@ class StateManager {
230
303
  }
231
304
  var stateManager = new StateManager;
232
305
 
233
- // src/errors.ts
234
- function writeError(message, code, details) {
235
- process.stderr.write(JSON.stringify({ error: message, code, details }) + `
236
- `);
237
- }
306
+ // src/data/queues.ts
307
+ import { Queue } from "bullmq";
238
308
 
239
- // src/profiles.ts
240
- import { existsSync, readFileSync } from "fs";
241
- import { homedir } from "os";
242
- import { join } from "path";
243
- function isRecord(value) {
244
- return !!value && typeof value === "object" && !Array.isArray(value);
245
- }
246
- function coerceOptionalPositiveInt(value) {
247
- if (value === undefined)
248
- return;
249
- const numberValue = Number(value);
250
- if (!Number.isInteger(numberValue) || numberValue <= 0)
251
- return null;
252
- return numberValue;
309
+ // src/redis-options.ts
310
+ function redisConnectionOptions(config) {
311
+ return {
312
+ host: config.redis.host,
313
+ port: config.redis.port,
314
+ username: config.redis.username,
315
+ password: config.redis.password,
316
+ db: config.redis.db,
317
+ ...config.redis.tls ? { tls: {} } : {}
318
+ };
253
319
  }
254
- var PROFILE_ALLOWED_KEYS = ["redis", "pollInterval", "prefix", "queues", "retentionMs"];
255
- var PROFILES_FILE_ALLOWED_KEYS = ["defaultProfile", "profiles"];
256
- function unknownKeyMessage(path, key, allowed) {
257
- return `unknown key '${key}' in ${path} (allowed: ${allowed.join(", ")})`;
320
+
321
+ // src/data/queues.ts
322
+ var QUEUE_NAMES_CACHE_TTL = 5000;
323
+ var SCAN_COUNT = 1000;
324
+ var DEL_BATCH_SIZE = 500;
325
+ function getQueue(ctx, queueName) {
326
+ const cached = ctx.queueCache.get(queueName);
327
+ if (cached)
328
+ return cached;
329
+ const queue = new Queue(queueName, {
330
+ prefix: ctx.config.prefix,
331
+ connection: redisConnectionOptions(ctx.config)
332
+ });
333
+ ctx.queueCache.set(queueName, queue);
334
+ return queue;
258
335
  }
259
- function validateProfile(value, path, errors) {
260
- if (!isRecord(value)) {
261
- errors.push(`${path} must be an object`);
262
- return null;
336
+ async function discoverQueueNames(ctx) {
337
+ if (ctx.config.queueNames && ctx.config.queueNames.length > 0) {
338
+ return ctx.config.queueNames;
263
339
  }
264
- for (const key of Object.keys(value)) {
265
- if (!PROFILE_ALLOWED_KEYS.includes(key)) {
266
- errors.push(unknownKeyMessage(path, key, PROFILE_ALLOWED_KEYS));
267
- }
340
+ const now = Date.now();
341
+ if (ctx.queueNamesCache && now - ctx.queueNamesCache.timestamp < QUEUE_NAMES_CACHE_TTL) {
342
+ return ctx.queueNamesCache.names;
268
343
  }
269
- const profile = {};
270
- if (value.redis !== undefined) {
271
- if (!isRecord(value.redis)) {
272
- errors.push(`${path}.redis must be an object`);
273
- } else {
274
- for (const key of Object.keys(value.redis)) {
275
- if (key !== "url")
276
- errors.push(unknownKeyMessage(`${path}.redis`, key, ["url"]));
277
- }
278
- if (typeof value.redis.url !== "string") {
279
- errors.push(`${path}.redis.url must be a string`);
280
- } else {
281
- profile.redis = { url: value.redis.url };
344
+ const queueNames = new Set;
345
+ const prefix = ctx.config.prefix + ":";
346
+ let cursor = "0";
347
+ do {
348
+ const [nextCursor, keys] = await ctx.redis.scan(cursor, "MATCH", `${ctx.config.prefix}:*`, "COUNT", SCAN_COUNT);
349
+ cursor = nextCursor;
350
+ for (const key of keys) {
351
+ if (key.startsWith(prefix)) {
352
+ const rest = key.slice(prefix.length);
353
+ const colonIdx = rest.indexOf(":");
354
+ const queueName = colonIdx === -1 ? rest : rest.slice(0, colonIdx);
355
+ if (queueName) {
356
+ queueNames.add(queueName);
357
+ }
282
358
  }
283
359
  }
284
- }
285
- const pollInterval = coerceOptionalPositiveInt(value.pollInterval);
286
- if (pollInterval === null)
287
- errors.push(`${path}.pollInterval must be a positive integer`);
288
- else if (pollInterval !== undefined)
289
- profile.pollInterval = pollInterval;
290
- if (value.prefix !== undefined) {
291
- if (typeof value.prefix !== "string")
292
- errors.push(`${path}.prefix must be a string`);
293
- else
294
- profile.prefix = value.prefix;
295
- }
296
- if (value.queues !== undefined) {
297
- if (!Array.isArray(value.queues) || value.queues.some((queue) => typeof queue !== "string")) {
298
- errors.push(`${path}.queues must be an array of strings`);
299
- } else {
300
- profile.queues = value.queues;
301
- }
302
- }
303
- const retentionMs = coerceOptionalPositiveInt(value.retentionMs);
304
- if (retentionMs === null)
305
- errors.push(`${path}.retentionMs must be a positive integer`);
306
- else if (retentionMs !== undefined)
307
- profile.retentionMs = retentionMs;
308
- return profile;
360
+ } while (cursor !== "0");
361
+ const sortedNames = Array.from(queueNames).toSorted();
362
+ ctx.queueNamesCache = {
363
+ names: sortedNames,
364
+ timestamp: now
365
+ };
366
+ return sortedNames;
309
367
  }
310
- function validateProfilesFile(value) {
311
- const errors = [];
312
- if (!isRecord(value))
313
- return ["config file must be an object"];
314
- for (const key of Object.keys(value)) {
315
- if (!PROFILES_FILE_ALLOWED_KEYS.includes(key)) {
316
- errors.push(unknownKeyMessage("config file", key, PROFILES_FILE_ALLOWED_KEYS));
317
- }
318
- }
319
- const file = { profiles: {} };
320
- if (value.defaultProfile !== undefined) {
321
- if (typeof value.defaultProfile !== "string")
322
- errors.push("defaultProfile must be a string");
323
- else
324
- file.defaultProfile = value.defaultProfile;
325
- }
326
- if (value.profiles !== undefined) {
327
- if (!isRecord(value.profiles)) {
328
- errors.push("profiles must be an object");
329
- } else {
330
- for (const [name, profileValue] of Object.entries(value.profiles)) {
331
- const profile = validateProfile(profileValue, `profiles.${name}`, errors);
332
- if (profile)
333
- file.profiles[name] = profile;
334
- }
335
- }
336
- }
337
- return errors.length > 0 ? errors : file;
368
+ async function getQueueStats(ctx, queueName) {
369
+ const queue = getQueue(ctx, queueName);
370
+ const [counts, isPaused, schedulersCount] = await Promise.all([
371
+ queue.getJobCounts(),
372
+ queue.isPaused(),
373
+ queue.getJobSchedulersCount()
374
+ ]);
375
+ const waitCount = (counts.waiting || 0) + (counts.prioritized || 0);
376
+ return {
377
+ name: queueName,
378
+ counts: {
379
+ wait: waitCount,
380
+ active: counts.active || 0,
381
+ completed: counts.completed || 0,
382
+ failed: counts.failed || 0,
383
+ delayed: counts.delayed || 0,
384
+ schedulers: schedulersCount || 0
385
+ },
386
+ isPaused,
387
+ total: waitCount + (counts.active || 0) + (counts.completed || 0) + (counts.failed || 0) + (counts.delayed || 0)
388
+ };
338
389
  }
339
- function resolveConfigPath(explicitPath, env = process.env) {
340
- if (explicitPath)
341
- return explicitPath;
342
- if (env.BULLMQ_DASH_CONFIG)
343
- return env.BULLMQ_DASH_CONFIG;
344
- const xdg = env.XDG_CONFIG_HOME;
345
- if (xdg)
346
- return join(xdg, "bullmq-dash", "config.json");
347
- return join(homedir(), ".config", "bullmq-dash", "config.json");
390
+ async function getAllQueueStats(ctx) {
391
+ const queueNames = await discoverQueueNames(ctx);
392
+ const stats = await Promise.all(queueNames.map((name) => getQueueStats(ctx, name)));
393
+ return stats;
348
394
  }
349
- var ENV_REF = /^\$\{([A-Z_][A-Z0-9_]*)\}$/;
350
- function expandEnvRefs(value, env = process.env) {
351
- if (typeof value === "string") {
352
- const match = value.match(ENV_REF);
353
- if (!match)
354
- return value;
355
- const key = match[1];
356
- const resolved = env[key];
357
- if (resolved === undefined) {
358
- throw new Error(`Environment variable '${key}' referenced in profile is not set`);
395
+ async function closeAllQueues(ctx) {
396
+ const closePromises = Array.from(ctx.queueCache.values()).map((queue) => queue.close());
397
+ await Promise.all(closePromises);
398
+ ctx.queueCache.clear();
399
+ ctx.queueNamesCache = null;
400
+ }
401
+ async function deleteQueue(ctx, queueName, dryRun = false) {
402
+ const queue = getQueue(ctx, queueName);
403
+ const counts = await queue.getJobCounts();
404
+ const result = {
405
+ name: queueName,
406
+ counts: {
407
+ wait: (counts.waiting || 0) + (counts.prioritized || 0),
408
+ active: counts.active || 0,
409
+ completed: counts.completed || 0,
410
+ failed: counts.failed || 0,
411
+ delayed: counts.delayed || 0
359
412
  }
360
- return resolved;
361
- }
362
- if (Array.isArray(value)) {
363
- return value.map((v) => expandEnvRefs(v, env));
364
- }
365
- if (value && typeof value === "object") {
366
- const out = {};
367
- for (const [k, v] of Object.entries(value)) {
368
- out[k] = expandEnvRefs(v, env);
413
+ };
414
+ if (!dryRun) {
415
+ await queue.obliterate({ force: true });
416
+ const escapedQueueName = queueName.replace(/[[*?]/g, "\\$&");
417
+ const repeatKeyPattern = `${ctx.config.prefix}:${escapedQueueName}:*`;
418
+ const allKeys = [];
419
+ let cursor = "0";
420
+ do {
421
+ const [nextCursor, keys] = await ctx.redis.scan(cursor, "MATCH", repeatKeyPattern, "COUNT", SCAN_COUNT);
422
+ cursor = nextCursor;
423
+ allKeys.push(...keys);
424
+ } while (cursor !== "0");
425
+ if (allKeys.length > 0) {
426
+ for (let i = 0;i < allKeys.length; i += DEL_BATCH_SIZE) {
427
+ const batch = allKeys.slice(i, i + DEL_BATCH_SIZE);
428
+ await ctx.redis.del(...batch);
429
+ }
430
+ }
431
+ await queue.close();
432
+ ctx.queueCache.delete(queueName);
433
+ if (ctx.queueNamesCache) {
434
+ ctx.queueNamesCache.names = ctx.queueNamesCache.names.filter((n) => n !== queueName);
369
435
  }
370
- return out;
371
436
  }
372
- return value;
437
+ return result;
373
438
  }
374
- function loadProfile(opts = {}) {
375
- const env = opts.env ?? process.env;
376
- const explicitConfig = !!opts.configPath || !!env.BULLMQ_DASH_CONFIG;
377
- const explicitProfile = !!opts.profileName;
378
- const path = resolveConfigPath(opts.configPath, env);
379
- if (!existsSync(path)) {
380
- if (explicitConfig || explicitProfile) {
381
- writeError(`Config file not found: ${path}`, "CONFIG_ERROR", explicitConfig ? "Check the path passed to --config or $BULLMQ_DASH_CONFIG." : `--profile requires a config file. Create one at ${path} or pass --config <path>.`);
382
- process.exit(2);
383
- }
439
+
440
+ // src/data/duration.ts
441
+ var DURATION_PATTERN = /^(\d+)([smhd])$/;
442
+ var MS_PER_UNIT = {
443
+ s: 1000,
444
+ m: 60 * 1000,
445
+ h: 60 * 60 * 1000,
446
+ d: 24 * 60 * 60 * 1000
447
+ };
448
+ var DEFAULT_RETRY_PAGE_SIZE = 1000;
449
+ var MAX_RETRY_PAGE_SIZE = 1e4;
450
+ function parseDuration(raw) {
451
+ const match = DURATION_PATTERN.exec(raw);
452
+ if (!match)
384
453
  return null;
385
- }
386
- let raw;
387
- try {
388
- raw = JSON.parse(readFileSync(path, "utf-8"));
389
- } catch (error) {
390
- writeError(`Failed to parse config file: ${path}`, "CONFIG_ERROR", error instanceof Error ? error.message : String(error));
391
- process.exit(2);
392
- }
393
- const parsed = validateProfilesFile(raw);
394
- if (Array.isArray(parsed)) {
395
- writeError(`Invalid config file: ${path}`, "CONFIG_ERROR", parsed.join("; "));
396
- process.exit(2);
397
- }
398
- const file = parsed;
399
- const name = opts.profileName ?? file.defaultProfile;
400
- if (!name)
401
- return null;
402
- const profile = file.profiles[name];
403
- if (!profile) {
404
- const available = Object.keys(file.profiles);
405
- writeError(`Profile '${name}' not found in ${path}`, "CONFIG_ERROR", available.length > 0 ? `Available profiles: ${available.join(", ")}` : "No profiles are defined in the config file.");
406
- process.exit(2);
407
- }
408
- let expanded;
409
- try {
410
- expanded = expandEnvRefs(profile, env);
411
- } catch (error) {
412
- writeError(`Failed to resolve env vars in profile '${name}'`, "CONFIG_ERROR", error instanceof Error ? error.message : String(error));
413
- process.exit(2);
414
- }
415
- return { sourcePath: path, name, profile: expanded };
416
- }
417
- function decodeUrlAuthField(raw, field) {
418
- try {
419
- return decodeURIComponent(raw);
420
- } catch {
421
- throw new Error(`Invalid percent-encoding in URL ${field}`);
422
- }
423
- }
424
- function redactUrl(input) {
425
- return input.replace(/(:\/\/)[^/]*@/, "$1[REDACTED]@");
426
- }
427
- function parseRedisUrl(input) {
428
- let parsed;
429
- try {
430
- parsed = new URL(input);
431
- } catch {
432
- throw new Error(`Not a valid URL: ${redactUrl(input)}`);
433
- }
434
- if (parsed.protocol !== "redis:" && parsed.protocol !== "rediss:") {
435
- throw new Error(`Unsupported scheme '${parsed.protocol}' (expected redis:// or rediss://)`);
436
- }
437
- if (!parsed.hostname) {
438
- throw new Error(`Missing host in URL: ${redactUrl(input)}`);
439
- }
440
- if (parsed.search || parsed.hash) {
441
- throw new Error("Query parameters and fragments are not supported in Redis URLs. " + "For TLS, use rediss:// (not ?ssl=true).");
442
- }
443
- const username = parsed.username ? decodeUrlAuthField(parsed.username, "username") : undefined;
444
- const password = parsed.password ? decodeUrlAuthField(parsed.password, "password") : undefined;
445
- let db;
446
- if (parsed.pathname && parsed.pathname !== "/") {
447
- const trimmed = parsed.pathname.replace(/^\//, "");
448
- if (!/^\d+$/.test(trimmed)) {
449
- throw new Error(`Database in URL must be a non-negative integer, got '/${trimmed}'`);
450
- }
451
- db = Number(trimmed);
452
- }
453
- const port = parsed.port ? Number(parsed.port) : 6379;
454
- return {
455
- host: parsed.hostname,
456
- port,
457
- username,
458
- password,
459
- db,
460
- tls: parsed.protocol === "rediss:"
461
- };
462
- }
463
-
464
- // src/config.ts
465
- var DEFAULT_RETENTION_MS = 7 * 24 * 60 * 60 * 1000;
466
- function coercePositiveInt(value, fallback) {
467
- if (value === undefined)
468
- return fallback;
469
- const numberValue = Number(value);
470
- if (!Number.isInteger(numberValue) || numberValue <= 0)
471
- return null;
472
- return numberValue;
473
- }
474
- function coerceNonNegativeInt(value, fallback) {
475
- if (value === undefined)
476
- return fallback;
477
- const numberValue = Number(value);
478
- if (!Number.isInteger(numberValue) || numberValue < 0)
479
- return null;
480
- return numberValue;
481
- }
482
- function coerceString(value, fallback) {
483
- if (value === undefined)
484
- return fallback;
485
- return typeof value === "string" ? value : null;
486
- }
487
- function coerceOptionalString(value) {
488
- if (value === undefined)
489
- return;
490
- return typeof value === "string" ? value : null;
491
- }
492
- var CONFIG_ALLOWED_KEYS = ["redis", "pollInterval", "prefix", "queueNames", "retentionMs"];
493
- var CONFIG_REDIS_ALLOWED_KEYS = ["host", "port", "username", "password", "db", "tls"];
494
- function validateConfig(raw) {
495
- const errors = [];
496
- for (const key of Object.keys(raw)) {
497
- if (!CONFIG_ALLOWED_KEYS.includes(key)) {
498
- errors.push(`unknown key '${key}' in config (allowed: ${CONFIG_ALLOWED_KEYS.join(", ")})`);
499
- }
500
- }
501
- for (const key of Object.keys(raw.redis)) {
502
- if (!CONFIG_REDIS_ALLOWED_KEYS.includes(key)) {
503
- errors.push(`unknown key '${key}' in config.redis (allowed: ${CONFIG_REDIS_ALLOWED_KEYS.join(", ")})`);
504
- }
505
- }
506
- const redis = raw.redis;
507
- const host = coerceString(redis.host, "localhost");
508
- if (host === null)
509
- errors.push("redis.host must be a string");
510
- const port = coercePositiveInt(redis.port, 6379);
511
- if (port === null)
512
- errors.push("redis.port must be a positive integer");
513
- const username = coerceOptionalString(redis.username);
514
- if (username === null)
515
- errors.push("redis.username must be a string");
516
- const password = coerceOptionalString(redis.password);
517
- if (password === null)
518
- errors.push("redis.password must be a string");
519
- const db = coerceNonNegativeInt(redis.db, 0);
520
- if (db === null)
521
- errors.push("redis.db must be a non-negative integer");
522
- const tls = redis.tls;
523
- if (tls !== undefined && typeof tls !== "boolean") {
524
- errors.push("redis.tls must be a boolean");
525
- }
526
- const pollInterval = coercePositiveInt(raw.pollInterval, 3000);
527
- if (pollInterval === null)
528
- errors.push("pollInterval must be a positive integer");
529
- const prefix = coerceString(raw.prefix, "bull");
530
- if (prefix === null)
531
- errors.push("prefix must be a string");
532
- const queueNames = raw.queueNames;
533
- if (queueNames !== undefined && (!Array.isArray(queueNames) || queueNames.some((name) => typeof name !== "string"))) {
534
- errors.push("queueNames must be an array of strings");
535
- }
536
- const retentionMs = coercePositiveInt(raw.retentionMs, DEFAULT_RETENTION_MS);
537
- if (retentionMs === null)
538
- errors.push("retentionMs must be a positive integer");
539
- if (errors.length > 0) {
540
- return { success: false, errors };
541
- }
542
- const config = {
543
- redis: {
544
- host,
545
- port,
546
- db
547
- },
548
- pollInterval,
549
- prefix,
550
- retentionMs
551
- };
552
- if (username !== undefined && username !== null)
553
- config.redis.username = username;
554
- if (password !== undefined && password !== null)
555
- config.redis.password = password;
556
- if (tls !== undefined)
557
- config.redis.tls = tls;
558
- if (queueNames !== undefined)
559
- config.queueNames = queueNames;
560
- return { success: true, data: config };
561
- }
562
- function loadConfig(cliArgs, profile) {
563
- const p = profile?.profile;
564
- const url = cliArgs.redisUrl ?? p?.redis?.url;
565
- let parts;
566
- if (url) {
567
- try {
568
- parts = parseRedisUrl(url);
569
- } catch (error) {
570
- writeError(cliArgs.redisUrl ? "Invalid --redis-url" : `Invalid redis.url in profile '${profile?.name}'`, "CONFIG_ERROR", error instanceof Error ? error.message : String(error));
571
- process.exit(2);
572
- }
573
- }
574
- const raw = {
575
- redis: {
576
- host: parts?.host,
577
- port: parts?.port,
578
- username: parts?.username,
579
- password: parts?.password,
580
- db: parts?.db,
581
- tls: parts?.tls
582
- },
583
- pollInterval: cliArgs.pollInterval ?? p?.pollInterval,
584
- prefix: cliArgs.prefix ?? p?.prefix,
585
- queueNames: cliArgs.queues ?? p?.queues,
586
- retentionMs: p?.retentionMs
587
- };
588
- const result = validateConfig(raw);
589
- if (!result.success) {
590
- writeError("Configuration error", "CONFIG_ERROR", result.errors.join("; "));
591
- process.exit(2);
592
- }
593
- return result.data;
594
- }
595
- function createConfigFromPrompt(redisUrl, cliArgs) {
596
- return loadConfig({ ...cliArgs, redisUrl });
597
- }
598
- var configInstance = null;
599
- function setConfig(config) {
600
- configInstance = config;
601
- }
602
- function getConfig() {
603
- if (!configInstance) {
604
- throw new Error("Config not initialized. Call setConfig() first.");
605
- }
606
- return configInstance;
607
- }
608
-
609
- // src/data/queues.ts
610
- import { Queue } from "bullmq";
611
-
612
- // src/data/redis.ts
613
- import { RedisConnection } from "bullmq";
614
- var redisConnection = null;
615
- var redisClientPromise = null;
616
- async function getRedisClient() {
617
- if (!redisClientPromise) {
618
- const config = getConfig();
619
- redisConnection = new RedisConnection({
620
- host: config.redis.host,
621
- port: config.redis.port,
622
- username: config.redis.username,
623
- password: config.redis.password,
624
- db: config.redis.db,
625
- ...config.redis.tls ? { tls: {} } : {},
626
- lazyConnect: true,
627
- retryStrategy: (times) => {
628
- if (times > 3) {
629
- return null;
630
- }
631
- return Math.min(times * 200, 2000);
632
- }
633
- }, { blocking: false });
634
- redisClientPromise = redisConnection.client.catch((err) => {
635
- redisClientPromise = null;
636
- redisConnection = null;
637
- throw err;
638
- });
639
- }
640
- return redisClientPromise;
641
- }
642
- async function connectRedis() {
643
- await getRedisClient();
644
- }
645
- async function disconnectRedis() {
646
- if (redisConnection) {
647
- await redisConnection.close();
648
- redisConnection = null;
649
- redisClientPromise = null;
650
- }
651
- }
652
-
653
- // src/data/queues.ts
654
- var queueCache = new Map;
655
- var queueNamesCache = null;
656
- var QUEUE_NAMES_CACHE_TTL = 5000;
657
- var SCAN_COUNT = 1000;
658
- var DEL_BATCH_SIZE = 500;
659
- function getQueue(queueName) {
660
- if (!queueCache.has(queueName)) {
661
- const config = getConfig();
662
- const queue = new Queue(queueName, {
663
- prefix: config.prefix,
664
- connection: {
665
- host: config.redis.host,
666
- port: config.redis.port,
667
- username: config.redis.username,
668
- password: config.redis.password,
669
- db: config.redis.db,
670
- ...config.redis.tls ? { tls: {} } : {}
671
- }
672
- });
673
- queueCache.set(queueName, queue);
674
- }
675
- return queueCache.get(queueName);
676
- }
677
- async function discoverQueueNames() {
678
- const config = getConfig();
679
- if (config.queueNames && config.queueNames.length > 0) {
680
- return config.queueNames;
681
- }
682
- const now = Date.now();
683
- if (queueNamesCache && now - queueNamesCache.timestamp < QUEUE_NAMES_CACHE_TTL) {
684
- return queueNamesCache.names;
685
- }
686
- const redis = await getRedisClient();
687
- const queueNames = new Set;
688
- const prefix = config.prefix + ":";
689
- let cursor = "0";
690
- do {
691
- const [nextCursor, keys] = await redis.scan(cursor, "MATCH", `${config.prefix}:*`, "COUNT", SCAN_COUNT);
692
- cursor = nextCursor;
693
- for (const key of keys) {
694
- if (key.startsWith(prefix)) {
695
- const rest = key.slice(prefix.length);
696
- const colonIdx = rest.indexOf(":");
697
- const queueName = colonIdx === -1 ? rest : rest.slice(0, colonIdx);
698
- if (queueName) {
699
- queueNames.add(queueName);
700
- }
701
- }
702
- }
703
- } while (cursor !== "0");
704
- const sortedNames = Array.from(queueNames).toSorted();
705
- queueNamesCache = {
706
- names: sortedNames,
707
- timestamp: now
708
- };
709
- return sortedNames;
710
- }
711
- async function getQueueStats(queueName) {
712
- const queue = getQueue(queueName);
713
- const [counts, isPaused, schedulersCount] = await Promise.all([
714
- queue.getJobCounts(),
715
- queue.isPaused(),
716
- queue.getJobSchedulersCount()
717
- ]);
718
- const waitCount = (counts.waiting || 0) + (counts.prioritized || 0);
719
- return {
720
- name: queueName,
721
- counts: {
722
- wait: waitCount,
723
- active: counts.active || 0,
724
- completed: counts.completed || 0,
725
- failed: counts.failed || 0,
726
- delayed: counts.delayed || 0,
727
- schedulers: schedulersCount || 0
728
- },
729
- isPaused,
730
- total: waitCount + (counts.active || 0) + (counts.completed || 0) + (counts.failed || 0) + (counts.delayed || 0)
731
- };
732
- }
733
- async function getAllQueueStats() {
734
- const queueNames = await discoverQueueNames();
735
- const stats = await Promise.all(queueNames.map((name) => getQueueStats(name)));
736
- return stats;
737
- }
738
- async function closeAllQueues() {
739
- const closePromises = Array.from(queueCache.values()).map((queue) => queue.close());
740
- await Promise.all(closePromises);
741
- queueCache.clear();
742
- queueNamesCache = null;
743
- }
744
- async function deleteQueue(queueName, dryRun = false) {
745
- const queue = getQueue(queueName);
746
- const counts = await queue.getJobCounts();
747
- const result = {
748
- name: queueName,
749
- counts: {
750
- wait: (counts.waiting || 0) + (counts.prioritized || 0),
751
- active: counts.active || 0,
752
- completed: counts.completed || 0,
753
- failed: counts.failed || 0,
754
- delayed: counts.delayed || 0
755
- }
756
- };
757
- if (!dryRun) {
758
- await queue.obliterate({ force: true });
759
- const config = getConfig();
760
- const redis = await getRedisClient();
761
- const escapedQueueName = queueName.replace(/[[*?]/g, "\\$&");
762
- const repeatKeyPattern = `${config.prefix}:${escapedQueueName}:*`;
763
- const allKeys = [];
764
- let cursor = "0";
765
- do {
766
- const [nextCursor, keys] = await redis.scan(cursor, "MATCH", repeatKeyPattern, "COUNT", SCAN_COUNT);
767
- cursor = nextCursor;
768
- allKeys.push(...keys);
769
- } while (cursor !== "0");
770
- if (allKeys.length > 0) {
771
- for (let i = 0;i < allKeys.length; i += DEL_BATCH_SIZE) {
772
- const batch = allKeys.slice(i, i + DEL_BATCH_SIZE);
773
- await redis.del(...batch);
774
- }
775
- }
776
- await queue.close();
777
- queueCache.delete(queueName);
778
- if (queueNamesCache) {
779
- queueNamesCache.names = queueNamesCache.names.filter((n) => n !== queueName);
780
- }
781
- }
782
- return result;
783
- }
784
-
785
- // src/data/duration.ts
786
- var DURATION_PATTERN = /^(\d+)([smhd])$/;
787
- var MS_PER_UNIT = {
788
- s: 1000,
789
- m: 60 * 1000,
790
- h: 60 * 60 * 1000,
791
- d: 24 * 60 * 60 * 1000
792
- };
793
- var DEFAULT_RETRY_PAGE_SIZE = 1000;
794
- var MAX_RETRY_PAGE_SIZE = 1e4;
795
- function parseDuration(raw) {
796
- const match = DURATION_PATTERN.exec(raw);
797
- if (!match)
798
- return null;
799
- const n = parseInt(match[1], 10);
800
- const unit = match[2];
801
- if (!Number.isFinite(n) || n <= 0)
802
- return null;
803
- const unitMs = MS_PER_UNIT[unit];
804
- if (!unitMs)
805
- return null;
806
- return n * unitMs;
807
- }
808
-
809
- // src/data/sqlite.ts
810
- import { Database } from "bun:sqlite";
811
- var db = null;
812
- var SCHEMA = `
813
- CREATE TABLE IF NOT EXISTS jobs (
814
- id TEXT NOT NULL,
815
- queue TEXT NOT NULL,
816
- name TEXT,
817
- state TEXT NOT NULL,
818
- timestamp INTEGER,
819
- data_preview TEXT,
820
- removed_at INTEGER,
821
- PRIMARY KEY (queue, id)
822
- );
823
-
824
- CREATE INDEX IF NOT EXISTS idx_jobs_queue_state ON jobs(queue, state);
825
- CREATE INDEX IF NOT EXISTS idx_jobs_name ON jobs(name);
826
- CREATE INDEX IF NOT EXISTS idx_jobs_timestamp ON jobs(timestamp);
827
- CREATE INDEX IF NOT EXISTS idx_jobs_active
828
- ON jobs(queue, state, timestamp) WHERE removed_at IS NULL;
829
- -- Covers the disconnected-fallback "latest" view: queue + removed_at IS NULL,
830
- -- ordered by timestamp DESC. Without this, the planner falls back to
831
- -- idx_jobs_queue_state + a TEMP B-TREE sort, which is O(n log n) over the
832
- -- queue's live rows and becomes the bottleneck at multi-million row scale.
833
- CREATE INDEX IF NOT EXISTS idx_jobs_queue_timestamp_live
834
- ON jobs(queue, timestamp DESC) WHERE removed_at IS NULL;
835
- `;
836
- var QUEUES_SCHEMA = `
837
- CREATE TABLE IF NOT EXISTS queues (
838
- name TEXT PRIMARY KEY,
839
- wait_count INTEGER NOT NULL DEFAULT 0,
840
- active_count INTEGER NOT NULL DEFAULT 0,
841
- completed_count INTEGER NOT NULL DEFAULT 0,
842
- failed_count INTEGER NOT NULL DEFAULT 0,
843
- delayed_count INTEGER NOT NULL DEFAULT 0,
844
- schedulers_count INTEGER NOT NULL DEFAULT 0,
845
- is_paused INTEGER NOT NULL DEFAULT 0
846
- );
847
- `;
848
- var SCHEDULERS_SCHEMA = `
849
- CREATE TABLE IF NOT EXISTS schedulers (
850
- queue TEXT NOT NULL,
851
- key TEXT NOT NULL,
852
- name TEXT NOT NULL,
853
- pattern TEXT,
854
- every INTEGER,
855
- next INTEGER,
856
- iteration_count INTEGER,
857
- tz TEXT,
858
- PRIMARY KEY (queue, key)
859
- );
860
- `;
861
- function migrateRemovedAtColumn(database) {
862
- const cols = database.prepare("PRAGMA table_info(jobs)").all();
863
- if (cols.length === 0)
864
- return;
865
- if (cols.some((c) => c.name === "removed_at"))
866
- return;
867
- database.exec("ALTER TABLE jobs ADD COLUMN removed_at INTEGER");
868
- }
869
- var FTS_SCHEMA = `
870
- CREATE VIRTUAL TABLE IF NOT EXISTS jobs_fts USING fts5(
871
- id,
872
- queue,
873
- name,
874
- data_preview,
875
- content='jobs',
876
- content_rowid='rowid'
877
- );
878
-
879
- -- Keep FTS in sync via triggers
880
- CREATE TRIGGER IF NOT EXISTS jobs_ai AFTER INSERT ON jobs BEGIN
881
- INSERT INTO jobs_fts(rowid, id, queue, name, data_preview)
882
- VALUES (new.rowid, new.id, new.queue, new.name, new.data_preview);
883
- END;
884
-
885
- CREATE TRIGGER IF NOT EXISTS jobs_ad AFTER DELETE ON jobs BEGIN
886
- INSERT INTO jobs_fts(jobs_fts, rowid, id, queue, name, data_preview)
887
- VALUES ('delete', old.rowid, old.id, old.queue, old.name, old.data_preview);
888
- END;
889
-
890
- CREATE TRIGGER IF NOT EXISTS jobs_au AFTER UPDATE ON jobs BEGIN
891
- INSERT INTO jobs_fts(jobs_fts, rowid, id, queue, name, data_preview)
892
- VALUES ('delete', old.rowid, old.id, old.queue, old.name, old.data_preview);
893
- INSERT INTO jobs_fts(rowid, id, queue, name, data_preview)
894
- VALUES (new.rowid, new.id, new.queue, new.name, new.data_preview);
895
- END;
896
- `;
897
- var SYNC_STATE_SCHEMA = `
898
- CREATE TABLE IF NOT EXISTS sync_state (
899
- queue TEXT PRIMARY KEY,
900
- job_count INTEGER NOT NULL DEFAULT 0,
901
- synced_at INTEGER NOT NULL DEFAULT 0
902
- );
903
- `;
904
- function createSqliteDb(dbPath) {
905
- const config = getConfig();
906
- const path = dbPath ?? `/tmp/bullmq-dash-${config.redis.host}-${config.redis.port}.db`;
907
- db = new Database(path);
908
- db.exec("PRAGMA journal_mode=WAL");
909
- db.exec("PRAGMA synchronous=NORMAL");
910
- migrateRemovedAtColumn(db);
911
- db.exec(SCHEMA);
912
- db.exec(QUEUES_SCHEMA);
913
- db.exec(SCHEDULERS_SCHEMA);
914
- db.exec(FTS_SCHEMA);
915
- db.exec(SYNC_STATE_SCHEMA);
916
- return db;
917
- }
918
- function getSqliteDb() {
919
- if (!db) {
920
- return createSqliteDb();
921
- }
922
- return db;
923
- }
924
- function closeSqliteDb() {
925
- if (db) {
926
- db.close();
927
- db = null;
928
- }
929
- }
930
- function viewClause(view, alias) {
931
- const col = `${alias}removed_at`;
932
- switch (view) {
933
- case "live":
934
- return `${col} IS NULL`;
935
- case "history":
936
- return `${col} IS NOT NULL`;
937
- case "all":
938
- return "";
939
- }
940
- }
941
- function appendStateClause(conditions, values, state, prefix) {
942
- if (Array.isArray(state)) {
943
- conditions.push(`${prefix}state IN (${state.map(() => "?").join(",")})`);
944
- values.push(...state);
945
- } else if (state && state !== "all") {
946
- conditions.push(`${prefix}state = ?`);
947
- values.push(state);
948
- }
949
- }
950
- function upsertQueueStats(queues) {
951
- const database = getSqliteDb();
952
- const stmt = database.prepare(`
953
- INSERT INTO queues (
954
- name,
955
- wait_count,
956
- active_count,
957
- completed_count,
958
- failed_count,
959
- delayed_count,
960
- schedulers_count,
961
- is_paused
962
- ) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
963
- ON CONFLICT(name) DO UPDATE SET
964
- wait_count = excluded.wait_count,
965
- active_count = excluded.active_count,
966
- completed_count = excluded.completed_count,
967
- failed_count = excluded.failed_count,
968
- delayed_count = excluded.delayed_count,
969
- schedulers_count = excluded.schedulers_count,
970
- is_paused = excluded.is_paused
971
- `);
972
- const replaceObservedQueues = database.transaction((items) => {
973
- if (items.length === 0) {
974
- database.prepare("DELETE FROM queues").run();
975
- database.prepare("DELETE FROM schedulers").run();
976
- return;
977
- }
978
- for (const queue of items) {
979
- stmt.run(queue.name, queue.counts.wait, queue.counts.active, queue.counts.completed, queue.counts.failed, queue.counts.delayed, queue.counts.schedulers, queue.isPaused ? 1 : 0);
980
- }
981
- const placeholders = items.map(() => "?").join(",");
982
- const names = items.map((queue) => queue.name);
983
- database.prepare(`DELETE FROM queues WHERE name NOT IN (${placeholders})`).run(...names);
984
- database.prepare(`DELETE FROM schedulers WHERE queue NOT IN (${placeholders})`).run(...names);
985
- });
986
- replaceObservedQueues(queues);
987
- }
988
- function upsertSchedulers(queue, schedulers) {
989
- const database = getSqliteDb();
990
- const stmt = database.prepare(`
991
- INSERT INTO schedulers (queue, key, name, pattern, every, next, iteration_count, tz)
992
- VALUES (?, ?, ?, ?, ?, ?, ?, ?)
993
- ON CONFLICT(queue, key) DO UPDATE SET
994
- name = excluded.name,
995
- pattern = excluded.pattern,
996
- every = excluded.every,
997
- next = excluded.next,
998
- iteration_count = excluded.iteration_count,
999
- tz = excluded.tz
1000
- `);
1001
- const replaceForQueue = database.transaction((items) => {
1002
- if (items.length === 0) {
1003
- database.prepare("DELETE FROM schedulers WHERE queue = ?").run(queue);
1004
- return;
1005
- }
1006
- for (const s of items) {
1007
- stmt.run(queue, s.key, s.name, s.pattern ?? null, s.every ?? null, s.next ?? null, s.iterationCount ?? null, s.tz ?? null);
1008
- }
1009
- const placeholders = items.map(() => "?").join(",");
1010
- database.prepare(`DELETE FROM schedulers WHERE queue = ? AND key NOT IN (${placeholders})`).run(queue, ...items.map((s) => s.key));
1011
- });
1012
- replaceForQueue(schedulers);
1013
- }
1014
- function querySchedulers(queue, page = 1, pageSize = 25) {
1015
- const database = getSqliteDb();
1016
- const offset = (page - 1) * pageSize;
1017
- const { total } = database.prepare("SELECT COUNT(*) as total FROM schedulers WHERE queue = ?").get(queue);
1018
- const rows = database.prepare(`
1019
- SELECT key, name, pattern, every, next, iteration_count, tz
1020
- FROM schedulers
1021
- WHERE queue = ?
1022
- ORDER BY key ASC
1023
- LIMIT ? OFFSET ?
1024
- `).all(queue, pageSize, offset);
1025
- const schedulers = rows.map((row) => ({
1026
- key: row.key,
1027
- name: row.name,
1028
- pattern: row.pattern ?? undefined,
1029
- every: row.every ?? undefined,
1030
- next: row.next ?? undefined,
1031
- iterationCount: row.iteration_count ?? undefined,
1032
- tz: row.tz ?? undefined
1033
- }));
1034
- return { schedulers, total };
1035
- }
1036
- function queryQueueStats() {
1037
- const database = getSqliteDb();
1038
- const rows = database.prepare(`
1039
- SELECT
1040
- name,
1041
- wait_count,
1042
- active_count,
1043
- completed_count,
1044
- failed_count,
1045
- delayed_count,
1046
- schedulers_count,
1047
- is_paused
1048
- FROM queues
1049
- ORDER BY name ASC
1050
- `).all();
1051
- return rows.map((row) => {
1052
- const counts = {
1053
- wait: row.wait_count,
1054
- active: row.active_count,
1055
- completed: row.completed_count,
1056
- failed: row.failed_count,
1057
- delayed: row.delayed_count,
1058
- schedulers: row.schedulers_count
1059
- };
1060
- return {
1061
- name: row.name,
1062
- counts,
1063
- isPaused: row.is_paused === 1,
1064
- total: counts.wait + counts.active + counts.completed + counts.failed + counts.delayed
1065
- };
1066
- });
1067
- }
1068
- function queryJobs(params) {
1069
- const database = getSqliteDb();
1070
- const {
1071
- queue,
1072
- search,
1073
- state,
1074
- sort = "timestamp",
1075
- order = "desc",
1076
- page = 1,
1077
- pageSize = 25,
1078
- view = "live"
1079
- } = params;
1080
- const validSorts = ["id", "name", "state", "timestamp"];
1081
- const sortCol = validSorts.includes(sort) ? sort : "timestamp";
1082
- const sortOrder = order === "asc" ? "ASC" : "DESC";
1083
- const offset = (page - 1) * pageSize;
1084
- if (Array.isArray(state) && state.length === 0) {
1085
- return { jobs: [], total: 0 };
1086
- }
1087
- if (search) {
1088
- const conditions2 = ["j.queue = ?"];
1089
- const values2 = [queue];
1090
- appendStateClause(conditions2, values2, state, "j.");
1091
- const viewSql2 = viewClause(view, "j.");
1092
- if (viewSql2)
1093
- conditions2.push(viewSql2);
1094
- const where2 = conditions2.join(" AND ");
1095
- const ftsMatch = `${search}*`;
1096
- const countSql2 = `SELECT COUNT(*) as total FROM jobs j JOIN jobs_fts fts ON j.rowid = fts.rowid WHERE ${where2} AND jobs_fts MATCH ?`;
1097
- const total2 = database.prepare(countSql2).get(...values2, ftsMatch).total;
1098
- const qualifiedSort = `j.${sortCol}`;
1099
- const sql2 = `SELECT j.* FROM jobs j JOIN jobs_fts fts ON j.rowid = fts.rowid WHERE ${where2} AND jobs_fts MATCH ? ORDER BY ${qualifiedSort} ${sortOrder} LIMIT ? OFFSET ?`;
1100
- const jobs2 = database.prepare(sql2).all(...values2, ftsMatch, pageSize, offset);
1101
- return { jobs: jobs2, total: total2 };
1102
- }
1103
- const conditions = ["queue = ?"];
1104
- const values = [queue];
1105
- appendStateClause(conditions, values, state, "");
1106
- const viewSql = viewClause(view, "");
1107
- if (viewSql)
1108
- conditions.push(viewSql);
1109
- const where = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
1110
- const countSql = `SELECT COUNT(*) as total FROM jobs ${where}`;
1111
- const total = database.prepare(countSql).get(...values).total;
1112
- const sql = `SELECT * FROM jobs ${where} ORDER BY ${sortCol} ${sortOrder} LIMIT ? OFFSET ?`;
1113
- const jobs = database.prepare(sql).all(...values, pageSize, offset);
1114
- return { jobs, total };
1115
- }
1116
- function safeDataPreview(data) {
1117
- if (data === undefined || data === null)
454
+ const n = parseInt(match[1], 10);
455
+ const unit = match[2];
456
+ if (!Number.isFinite(n) || n <= 0)
1118
457
  return null;
1119
- try {
1120
- return JSON.stringify(data).slice(0, 500);
1121
- } catch {
458
+ const unitMs = MS_PER_UNIT[unit];
459
+ if (!unitMs)
1122
460
  return null;
1123
- }
1124
- }
1125
- function upsertJobs(queue, jobs) {
1126
- const database = getSqliteDb();
1127
- const stmt = database.prepare(`
1128
- INSERT INTO jobs (id, queue, name, state, timestamp, data_preview)
1129
- VALUES (?, ?, ?, ?, ?, ?)
1130
- ON CONFLICT(queue, id) DO UPDATE SET
1131
- name = excluded.name,
1132
- state = excluded.state,
1133
- timestamp = excluded.timestamp,
1134
- data_preview = excluded.data_preview
1135
- `);
1136
- const upsert = database.transaction((items) => {
1137
- for (const job of items) {
1138
- const dataPreview = safeDataPreview(job.data);
1139
- stmt.run(job.id, queue, job.name, job.state, job.timestamp, dataPreview);
1140
- }
1141
- });
1142
- upsert(jobs);
1143
- }
1144
- function upsertJobStubs(queue, jobs) {
1145
- const database = getSqliteDb();
1146
- const stmt = database.prepare(`
1147
- INSERT INTO jobs (id, queue, name, state, timestamp, data_preview)
1148
- VALUES (?, ?, NULL, ?, NULL, NULL)
1149
- ON CONFLICT(queue, id) DO UPDATE SET
1150
- state = excluded.state
1151
- `);
1152
- const upsert = database.transaction((items) => {
1153
- for (const job of items) {
1154
- stmt.run(job.id, queue, job.state);
1155
- }
1156
- });
1157
- upsert(jobs);
1158
- }
1159
- function upsertSyncState(queue, input) {
1160
- const database = getSqliteDb();
1161
- database.prepare(`
1162
- INSERT INTO sync_state (queue, job_count, synced_at)
1163
- VALUES (?, ?, ?)
1164
- ON CONFLICT(queue) DO UPDATE SET
1165
- job_count = excluded.job_count,
1166
- synced_at = excluded.synced_at
1167
- `).run(queue, input.jobCount, input.syncedAt);
1168
- }
1169
- function createSyncStaging() {
1170
- const database = getSqliteDb();
1171
- database.exec(`
1172
- DROP TABLE IF EXISTS main.sync_staging;
1173
- DROP TABLE IF EXISTS temp.sync_staging;
1174
- CREATE TEMP TABLE sync_staging (
1175
- id TEXT NOT NULL,
1176
- queue TEXT NOT NULL,
1177
- state TEXT NOT NULL,
1178
- PRIMARY KEY (queue, id)
1179
- );
1180
- `);
1181
- }
1182
- function insertStagingBatch(queue, jobs) {
1183
- if (jobs.length === 0)
1184
- return;
1185
- const database = getSqliteDb();
1186
- const stmt = database.prepare("INSERT OR IGNORE INTO sync_staging (id, queue, state) VALUES (?, ?, ?)");
1187
- const insert = database.transaction((items) => {
1188
- for (const job of items) {
1189
- stmt.run(job.id, queue, job.state);
1190
- }
1191
- });
1192
- insert(jobs);
1193
- }
1194
- function findResurrectedIdsByStagingDiff(queue) {
1195
- const database = getSqliteDb();
1196
- const rows = database.prepare(`
1197
- SELECT s.id FROM sync_staging s
1198
- JOIN jobs j ON s.queue = j.queue AND s.id = j.id
1199
- WHERE s.queue = ? AND j.removed_at IS NOT NULL
1200
- `).all(queue);
1201
- return rows.map((r) => r.id);
1202
- }
1203
- function findNewIdsByStagingDiff(queue) {
1204
- const database = getSqliteDb();
1205
- return database.prepare(`
1206
- SELECT s.id, s.state FROM sync_staging s
1207
- LEFT JOIN jobs j ON s.queue = j.queue AND s.id = j.id
1208
- WHERE s.queue = ? AND j.id IS NULL
1209
- `).all(queue);
1210
- }
1211
- function findChangedIdsByStagingDiff(queue) {
1212
- const database = getSqliteDb();
1213
- return database.prepare(`
1214
- SELECT s.id, s.state FROM sync_staging s
1215
- JOIN jobs j ON s.queue = j.queue AND s.id = j.id
1216
- WHERE s.queue = ? AND j.removed_at IS NULL AND s.state != j.state
1217
- `).all(queue);
1218
- }
1219
- function findStaleIdsByStagingDiff(queue) {
1220
- const database = getSqliteDb();
1221
- const rows = database.prepare(`
1222
- SELECT j.id FROM jobs j
1223
- LEFT JOIN sync_staging s ON j.queue = s.queue AND j.id = s.id
1224
- WHERE j.queue = ? AND j.removed_at IS NULL AND s.id IS NULL
1225
- `).all(queue);
1226
- return rows.map((r) => r.id);
1227
- }
1228
- function softDeleteJobsByIds(queue, ids, now) {
1229
- if (ids.length === 0)
1230
- return 0;
1231
- const database = getSqliteDb();
1232
- const BATCH_SIZE = 900;
1233
- const run = database.transaction(() => {
1234
- for (let i = 0;i < ids.length; i += BATCH_SIZE) {
1235
- const batch = ids.slice(i, i + BATCH_SIZE);
1236
- const placeholders = batch.map(() => "?").join(",");
1237
- database.prepare(`UPDATE jobs SET removed_at = ? WHERE queue = ? AND id IN (${placeholders})`).run(now, queue, ...batch);
1238
- }
1239
- });
1240
- run();
1241
- return ids.length;
1242
- }
1243
- function compactRemovedJobs(now, retentionMs) {
1244
- const database = getSqliteDb();
1245
- const cutoff = now - retentionMs;
1246
- const filter = "removed_at IS NOT NULL AND removed_at < ?";
1247
- const run = database.transaction(() => {
1248
- const { n } = database.prepare(`SELECT COUNT(*) as n FROM jobs WHERE ${filter}`).get(cutoff);
1249
- if (n > 0) {
1250
- database.prepare(`DELETE FROM jobs WHERE ${filter}`).run(cutoff);
1251
- }
1252
- return n;
1253
- });
1254
- return run();
1255
- }
1256
- function dropSyncStaging() {
1257
- const database = getSqliteDb();
1258
- database.exec("DROP TABLE IF EXISTS sync_staging");
461
+ return n * unitMs;
1259
462
  }
1260
463
 
1261
464
  // src/data/jobs.ts
1262
465
  var PAGE_SIZE = 25;
1263
466
  var DEFAULT_MAX_RESULTS = 1000;
1264
- var SYNC_JOB_TYPES = [
1265
- "active",
1266
- "waiting",
1267
- "completed",
1268
- "failed",
1269
- "delayed",
1270
- "prioritized"
1271
- ];
1272
- var SYNC_PAGE_SIZE = 5000;
1273
467
  var VALID_JOB_STATUSES = ["wait", "active", "completed", "failed", "delayed"];
1274
- async function getAllJobs(queueName, status, maxResults = DEFAULT_MAX_RESULTS, includeData = false) {
1275
- const queue = getQueue(queueName);
468
+ async function getAllJobs(ctx, queueName, status, maxResults = DEFAULT_MAX_RESULTS, includeData = false) {
469
+ const queue = getQueue(ctx, queueName);
1276
470
  const end = maxResults - 1;
1277
471
  let tagged;
1278
472
  let total;
@@ -1346,27 +540,8 @@ async function getAllJobs(queueName, status, maxResults = DEFAULT_MAX_RESULTS, i
1346
540
  });
1347
541
  return { jobs: jobSummaries, total };
1348
542
  }
1349
- async function* getAllJobIds(queueName) {
1350
- const queue = getQueue(queueName);
1351
- for (const type of SYNC_JOB_TYPES) {
1352
- let offset = 0;
1353
- while (true) {
1354
- const end = offset + SYNC_PAGE_SIZE - 1;
1355
- const ids = await queue.getRanges([type], offset, end);
1356
- if (ids.length === 0)
1357
- break;
1358
- const batch = ids.filter((id) => typeof id === "string" && id !== "").map((id) => ({ id, state: type }));
1359
- if (batch.length > 0) {
1360
- yield batch;
1361
- }
1362
- if (ids.length < SYNC_PAGE_SIZE)
1363
- break;
1364
- offset += SYNC_PAGE_SIZE;
1365
- }
1366
- }
1367
- }
1368
- async function getJobs(queueName, status, page = 1, pageSize = PAGE_SIZE, includeData = false) {
1369
- const queue = getQueue(queueName);
543
+ async function getJobs(ctx, queueName, status, page = 1, pageSize = PAGE_SIZE, includeData = false) {
544
+ const queue = getQueue(ctx, queueName);
1370
545
  const start = (page - 1) * pageSize;
1371
546
  const end = start + pageSize - 1;
1372
547
  let tagged;
@@ -1431,7 +606,7 @@ async function getJobs(queueName, status, page = 1, pageSize = PAGE_SIZE, includ
1431
606
  total = delayedCounts.delayed || 0;
1432
607
  break;
1433
608
  case "schedulers":
1434
- throw new Error("Cannot fetch schedulers via getJobs(). Use getJobSchedulers() instead.");
609
+ throw new Error("Cannot fetch schedulers via getJobs(). Use getAllJobSchedulers() instead.");
1435
610
  default:
1436
611
  tagged = [];
1437
612
  total = 0;
@@ -1457,45 +632,8 @@ async function getJobs(queueName, status, page = 1, pageSize = PAGE_SIZE, includ
1457
632
  totalPages: Math.ceil(total / pageSize)
1458
633
  };
1459
634
  }
1460
- function rowToJobSummary(row) {
1461
- return {
1462
- id: row.id,
1463
- name: row.name ?? "unknown",
1464
- state: row.state,
1465
- timestamp: row.timestamp ?? 0
1466
- };
1467
- }
1468
- function storeStateFilter(status) {
1469
- switch (status) {
1470
- case "latest":
1471
- return;
1472
- case "wait":
1473
- return ["waiting", "prioritized"];
1474
- case "schedulers":
1475
- throw new Error("Cannot fetch schedulers via getJobsFromStore(). Use getJobSchedulers() instead.");
1476
- default:
1477
- return status;
1478
- }
1479
- }
1480
- async function getJobsFromStore(queueName, status, page = 1, pageSize = PAGE_SIZE) {
1481
- const result = queryJobs({
1482
- queue: queueName,
1483
- state: storeStateFilter(status),
1484
- sort: "timestamp",
1485
- order: "desc",
1486
- page,
1487
- pageSize
1488
- });
1489
- return {
1490
- jobs: result.jobs.map(rowToJobSummary),
1491
- total: result.total,
1492
- page,
1493
- pageSize,
1494
- totalPages: Math.ceil(result.total / pageSize)
1495
- };
1496
- }
1497
- async function getJobDetail(queueName, jobId) {
1498
- const queue = getQueue(queueName);
635
+ async function getJobDetail(ctx, queueName, jobId) {
636
+ const queue = getQueue(ctx, queueName);
1499
637
  const job = await queue.getJob(jobId);
1500
638
  if (!job) {
1501
639
  return null;
@@ -1519,8 +657,8 @@ async function getJobDetail(queueName, jobId) {
1519
657
  delay: job.delay
1520
658
  };
1521
659
  }
1522
- async function deleteJob(queueName, jobId) {
1523
- const queue = getQueue(queueName);
660
+ async function deleteJob(ctx, queueName, jobId) {
661
+ const queue = getQueue(ctx, queueName);
1524
662
  const job = await queue.getJob(jobId);
1525
663
  if (!job) {
1526
664
  return false;
@@ -1529,7 +667,7 @@ async function deleteJob(queueName, jobId) {
1529
667
  return true;
1530
668
  }
1531
669
  var SAMPLE_ID_COUNT = 5;
1532
- async function retryFailedJobs(queueName, options) {
670
+ async function retryFailedJobs(ctx, queueName, options) {
1533
671
  const pageSize = Math.min(options.pageSize ?? DEFAULT_RETRY_PAGE_SIZE, MAX_RETRY_PAGE_SIZE);
1534
672
  const dryRun = options.dryRun ?? false;
1535
673
  let cutoffMs;
@@ -1540,22 +678,51 @@ async function retryFailedJobs(queueName, options) {
1540
678
  }
1541
679
  cutoffMs = Date.now() - durationMs;
1542
680
  }
1543
- const queue = getQueue(queueName);
1544
- const [failedJobs, counts] = await Promise.all([
681
+ const queue = getQueue(ctx, queueName);
682
+ let failedJobs;
683
+ let truncated = false;
684
+ const countsPromise = queue.getJobCounts("failed");
685
+ if (options.jobId !== undefined) {
686
+ const [job, counts2] = await Promise.all([queue.getJob(options.jobId), countsPromise]);
687
+ const totalFailed2 = counts2.failed || 0;
688
+ if (!job) {
689
+ return {
690
+ matched: 0,
691
+ retried: 0,
692
+ errors: [],
693
+ sampleJobIds: [],
694
+ totalFailed: totalFailed2,
695
+ truncated: false
696
+ };
697
+ }
698
+ const state = await job.getState();
699
+ failedJobs = state === "failed" ? [job] : [];
700
+ const matched2 = applyRetryFilters(failedJobs, cutoffMs, options.name);
701
+ return retryMatchedJobs(matched2, totalFailed2, false, dryRun);
702
+ }
703
+ const [fetchedFailedJobs, counts] = await Promise.all([
1545
704
  queue.getFailed(0, pageSize - 1),
1546
- queue.getJobCounts("failed")
705
+ countsPromise
1547
706
  ]);
1548
707
  const totalFailed = counts.failed || 0;
1549
- const matched = failedJobs.filter((job) => {
708
+ failedJobs = fetchedFailedJobs;
709
+ truncated = totalFailed > failedJobs.length;
710
+ const matched = applyRetryFilters(failedJobs, cutoffMs, options.name);
711
+ return retryMatchedJobs(matched, totalFailed, truncated, dryRun);
712
+ }
713
+ function applyRetryFilters(jobs, cutoffMs, name) {
714
+ return jobs.filter((job) => {
1550
715
  if (cutoffMs !== undefined) {
1551
716
  const whenFailed = job.finishedOn ?? job.timestamp ?? 0;
1552
717
  if (whenFailed < cutoffMs)
1553
718
  return false;
1554
719
  }
1555
- if (options.name !== undefined && job.name !== options.name)
720
+ if (name !== undefined && job.name !== name)
1556
721
  return false;
1557
722
  return true;
1558
723
  });
724
+ }
725
+ async function retryMatchedJobs(matched, totalFailed, truncated, dryRun) {
1559
726
  const matchedIds = matched.map((job) => job.id).filter((id) => typeof id === "string" && id.length > 0);
1560
727
  const sampleIds = matchedIds.slice(0, SAMPLE_ID_COUNT);
1561
728
  if (dryRun) {
@@ -1565,7 +732,7 @@ async function retryFailedJobs(queueName, options) {
1565
732
  errors: [],
1566
733
  sampleJobIds: sampleIds,
1567
734
  totalFailed,
1568
- truncated: totalFailed > failedJobs.length
735
+ truncated
1569
736
  };
1570
737
  }
1571
738
  let retried = 0;
@@ -1587,7 +754,7 @@ async function retryFailedJobs(queueName, options) {
1587
754
  errors,
1588
755
  sampleJobIds: sampleIds,
1589
756
  totalFailed,
1590
- truncated: totalFailed > failedJobs.length
757
+ truncated
1591
758
  };
1592
759
  }
1593
760
  function formatRelativeTime(timestamp) {
@@ -1615,8 +782,8 @@ function formatTimestamp(timestamp) {
1615
782
 
1616
783
  // src/data/schedulers.ts
1617
784
  var DEFAULT_MAX_RESULTS2 = 1000;
1618
- async function getAllJobSchedulers(queueName, maxResults = DEFAULT_MAX_RESULTS2) {
1619
- const queue = getQueue(queueName);
785
+ async function getAllJobSchedulers(ctx, queueName, maxResults = DEFAULT_MAX_RESULTS2) {
786
+ const queue = getQueue(ctx, queueName);
1620
787
  const end = maxResults - 1;
1621
788
  const [schedulers, total] = await Promise.all([
1622
789
  queue.getJobSchedulers(0, end, false),
@@ -1633,8 +800,8 @@ async function getAllJobSchedulers(queueName, maxResults = DEFAULT_MAX_RESULTS2)
1633
800
  }));
1634
801
  return { schedulers: summaries, total };
1635
802
  }
1636
- async function getJobSchedulerDetail(queueName, schedulerKey) {
1637
- const queue = getQueue(queueName);
803
+ async function getJobSchedulerDetail(ctx, queueName, schedulerKey) {
804
+ const queue = getQueue(ctx, queueName);
1638
805
  const schedulers = await queue.getJobSchedulers(0, -1, false);
1639
806
  const scheduler = schedulers.find((s) => s.key === schedulerKey);
1640
807
  if (!scheduler) {
@@ -1801,219 +968,522 @@ var metricsTracker = new MetricsTracker;
1801
968
  function resetMetricsTracker() {
1802
969
  metricsTracker.reset();
1803
970
  }
1804
- function aggregateJobCounts(queues) {
1805
- const jobCounts = {
1806
- wait: 0,
1807
- active: 0,
1808
- completed: 0,
1809
- failed: 0,
1810
- delayed: 0,
1811
- total: 0
1812
- };
1813
- for (const { counts } of queues) {
1814
- jobCounts.wait += counts.wait;
1815
- jobCounts.active += counts.active;
1816
- jobCounts.completed += counts.completed;
1817
- jobCounts.failed += counts.failed;
1818
- jobCounts.delayed += counts.delayed;
971
+ function aggregateJobCounts(queues) {
972
+ const jobCounts = {
973
+ wait: 0,
974
+ active: 0,
975
+ completed: 0,
976
+ failed: 0,
977
+ delayed: 0,
978
+ total: 0
979
+ };
980
+ for (const { counts } of queues) {
981
+ jobCounts.wait += counts.wait;
982
+ jobCounts.active += counts.active;
983
+ jobCounts.completed += counts.completed;
984
+ jobCounts.failed += counts.failed;
985
+ jobCounts.delayed += counts.delayed;
986
+ }
987
+ jobCounts.total = jobCounts.wait + jobCounts.active + jobCounts.completed + jobCounts.failed + jobCounts.delayed;
988
+ return jobCounts;
989
+ }
990
+ function calculateGlobalMetricsFromQueueStats(queues, rates) {
991
+ return {
992
+ queueCount: queues.length,
993
+ jobCounts: aggregateJobCounts(queues),
994
+ rates
995
+ };
996
+ }
997
+ function updateMetricsTracker(queues) {
998
+ return metricsTracker.update(aggregateJobCounts(queues));
999
+ }
1000
+
1001
+ // src/data/sqlite.ts
1002
+ import { Database } from "bun:sqlite";
1003
+ var SCHEMA = `
1004
+ CREATE TABLE IF NOT EXISTS jobs (
1005
+ id TEXT NOT NULL,
1006
+ queue TEXT NOT NULL,
1007
+ name TEXT,
1008
+ state TEXT NOT NULL,
1009
+ timestamp INTEGER,
1010
+ data_preview TEXT,
1011
+ data_json TEXT,
1012
+ opts_json TEXT,
1013
+ attempts_made INTEGER,
1014
+ failed_reason TEXT,
1015
+ stacktrace_json TEXT,
1016
+ returnvalue_json TEXT,
1017
+ processed_on INTEGER,
1018
+ finished_on INTEGER,
1019
+ progress_json TEXT,
1020
+ repeat_job_key TEXT,
1021
+ delay INTEGER,
1022
+ last_observed_at INTEGER NOT NULL DEFAULT 0,
1023
+ PRIMARY KEY (queue, id)
1024
+ );
1025
+
1026
+ CREATE INDEX IF NOT EXISTS idx_jobs_queue_state ON jobs(queue, state);
1027
+ CREATE INDEX IF NOT EXISTS idx_jobs_name ON jobs(name);
1028
+ CREATE INDEX IF NOT EXISTS idx_jobs_timestamp ON jobs(timestamp);
1029
+ CREATE INDEX IF NOT EXISTS idx_jobs_last_observed ON jobs(last_observed_at);
1030
+ CREATE INDEX IF NOT EXISTS idx_jobs_queue_timestamp
1031
+ ON jobs(queue, timestamp DESC);
1032
+ `;
1033
+ var QUEUES_SCHEMA = `
1034
+ CREATE TABLE IF NOT EXISTS queues (
1035
+ name TEXT PRIMARY KEY,
1036
+ wait_count INTEGER NOT NULL DEFAULT 0,
1037
+ active_count INTEGER NOT NULL DEFAULT 0,
1038
+ completed_count INTEGER NOT NULL DEFAULT 0,
1039
+ failed_count INTEGER NOT NULL DEFAULT 0,
1040
+ delayed_count INTEGER NOT NULL DEFAULT 0,
1041
+ schedulers_count INTEGER NOT NULL DEFAULT 0,
1042
+ is_paused INTEGER NOT NULL DEFAULT 0,
1043
+ last_observed_at INTEGER NOT NULL DEFAULT 0
1044
+ );
1045
+ `;
1046
+ var SCHEDULERS_SCHEMA = `
1047
+ CREATE TABLE IF NOT EXISTS schedulers (
1048
+ queue TEXT NOT NULL,
1049
+ key TEXT NOT NULL,
1050
+ name TEXT NOT NULL,
1051
+ pattern TEXT,
1052
+ every INTEGER,
1053
+ next INTEGER,
1054
+ iteration_count INTEGER,
1055
+ tz TEXT,
1056
+ last_observed_at INTEGER NOT NULL DEFAULT 0,
1057
+ PRIMARY KEY (queue, key)
1058
+ );
1059
+ `;
1060
+ function tableColumns(database, table) {
1061
+ const cols = database.prepare(`PRAGMA table_info(${table})`).all();
1062
+ return new Set(cols.map((c) => c.name));
1063
+ }
1064
+ function addColumnIfMissing(database, table, columns, sql) {
1065
+ const match = sql.match(/ADD COLUMN\s+([a-z_]+)/i);
1066
+ const name = match?.[1];
1067
+ if (!name || columns.has(name))
1068
+ return false;
1069
+ database.exec(`ALTER TABLE ${table} ${sql}`);
1070
+ columns.add(name);
1071
+ return true;
1072
+ }
1073
+ function migrateCacheColumns(database, now) {
1074
+ const jobCols = tableColumns(database, "jobs");
1075
+ if (jobCols.size > 0) {
1076
+ addColumnIfMissing(database, "jobs", jobCols, "ADD COLUMN data_json TEXT");
1077
+ addColumnIfMissing(database, "jobs", jobCols, "ADD COLUMN opts_json TEXT");
1078
+ addColumnIfMissing(database, "jobs", jobCols, "ADD COLUMN attempts_made INTEGER");
1079
+ addColumnIfMissing(database, "jobs", jobCols, "ADD COLUMN failed_reason TEXT");
1080
+ addColumnIfMissing(database, "jobs", jobCols, "ADD COLUMN stacktrace_json TEXT");
1081
+ addColumnIfMissing(database, "jobs", jobCols, "ADD COLUMN returnvalue_json TEXT");
1082
+ addColumnIfMissing(database, "jobs", jobCols, "ADD COLUMN processed_on INTEGER");
1083
+ addColumnIfMissing(database, "jobs", jobCols, "ADD COLUMN finished_on INTEGER");
1084
+ addColumnIfMissing(database, "jobs", jobCols, "ADD COLUMN progress_json TEXT");
1085
+ addColumnIfMissing(database, "jobs", jobCols, "ADD COLUMN repeat_job_key TEXT");
1086
+ addColumnIfMissing(database, "jobs", jobCols, "ADD COLUMN delay INTEGER");
1087
+ if (addColumnIfMissing(database, "jobs", jobCols, "ADD COLUMN last_observed_at INTEGER NOT NULL DEFAULT 0")) {
1088
+ database.prepare("UPDATE jobs SET last_observed_at = ?").run(now);
1089
+ }
1090
+ }
1091
+ const queueCols = tableColumns(database, "queues");
1092
+ if (queueCols.size > 0 && addColumnIfMissing(database, "queues", queueCols, "ADD COLUMN last_observed_at INTEGER NOT NULL DEFAULT 0")) {
1093
+ database.prepare("UPDATE queues SET last_observed_at = ?").run(now);
1094
+ }
1095
+ const schedulerCols = tableColumns(database, "schedulers");
1096
+ if (schedulerCols.size > 0 && addColumnIfMissing(database, "schedulers", schedulerCols, "ADD COLUMN last_observed_at INTEGER NOT NULL DEFAULT 0")) {
1097
+ database.prepare("UPDATE schedulers SET last_observed_at = ?").run(now);
1819
1098
  }
1820
- jobCounts.total = jobCounts.wait + jobCounts.active + jobCounts.completed + jobCounts.failed + jobCounts.delayed;
1821
- return jobCounts;
1822
- }
1823
- function calculateGlobalMetricsFromQueueStats(queues, rates) {
1824
- return {
1825
- queueCount: queues.length,
1826
- jobCounts: aggregateJobCounts(queues),
1827
- rates
1828
- };
1829
- }
1830
- function updateMetricsTracker(queues) {
1831
- return metricsTracker.update(aggregateJobCounts(queues));
1832
1099
  }
1100
+ var FTS_SCHEMA = `
1101
+ CREATE VIRTUAL TABLE IF NOT EXISTS jobs_fts USING fts5(
1102
+ id,
1103
+ queue,
1104
+ name,
1105
+ data_preview,
1106
+ content='jobs',
1107
+ content_rowid='rowid'
1108
+ );
1833
1109
 
1834
- // src/data/sync.ts
1835
- var syncInProgress = false;
1836
- var syncLockAcquiredAt = null;
1837
- var SYNC_LOCK_TIMEOUT_MS = 10 * 60 * 1000;
1110
+ -- Keep FTS in sync via triggers
1111
+ CREATE TRIGGER IF NOT EXISTS jobs_ai AFTER INSERT ON jobs BEGIN
1112
+ INSERT INTO jobs_fts(rowid, id, queue, name, data_preview)
1113
+ VALUES (new.rowid, new.id, new.queue, new.name, new.data_preview);
1114
+ END;
1838
1115
 
1839
- class JobResurrectionError extends Error {
1840
- constructor(queueName, ids) {
1841
- super(`Resurrected job IDs detected in queue "${queueName}": ` + `${ids.join(", ")}. ` + `Soft-deleted IDs are not allowed to reappear in Redis.`);
1842
- this.name = "JobResurrectionError";
1843
- }
1844
- }
1116
+ CREATE TRIGGER IF NOT EXISTS jobs_ad AFTER DELETE ON jobs BEGIN
1117
+ INSERT INTO jobs_fts(jobs_fts, rowid, id, queue, name, data_preview)
1118
+ VALUES ('delete', old.rowid, old.id, old.queue, old.name, old.data_preview);
1119
+ END;
1845
1120
 
1846
- class FullSyncInvariantError extends Error {
1847
- errors;
1848
- constructor(errors) {
1849
- super(`SQLite full sync invariant violation in ${errors.length} queue(s): ` + errors.map((e) => `${e.queue}: ${e.error}`).join("; "));
1850
- this.errors = errors;
1851
- this.name = "FullSyncInvariantError";
1121
+ CREATE TRIGGER IF NOT EXISTS jobs_au AFTER UPDATE ON jobs BEGIN
1122
+ INSERT INTO jobs_fts(jobs_fts, rowid, id, queue, name, data_preview)
1123
+ VALUES ('delete', old.rowid, old.id, old.queue, old.name, old.data_preview);
1124
+ INSERT INTO jobs_fts(rowid, id, queue, name, data_preview)
1125
+ VALUES (new.rowid, new.id, new.queue, new.name, new.data_preview);
1126
+ END;
1127
+ `;
1128
+ function createSqliteDb(config, dbPath) {
1129
+ const path = dbPath ?? `/tmp/bullmq-dash-${config.redis.host}-${config.redis.port}.db`;
1130
+ const handle = new Database(path);
1131
+ handle.exec("PRAGMA journal_mode=WAL");
1132
+ handle.exec("PRAGMA synchronous=NORMAL");
1133
+ migrateCacheColumns(handle, Date.now());
1134
+ handle.exec(SCHEMA);
1135
+ handle.exec(QUEUES_SCHEMA);
1136
+ handle.exec(SCHEDULERS_SCHEMA);
1137
+ handle.exec(FTS_SCHEMA);
1138
+ return handle;
1139
+ }
1140
+ function appendStateClause(conditions, values, state, prefix) {
1141
+ if (Array.isArray(state)) {
1142
+ conditions.push(`${prefix}state IN (${state.map(() => "?").join(",")})`);
1143
+ values.push(...state);
1144
+ } else if (state && state !== "all") {
1145
+ conditions.push(`${prefix}state = ?`);
1146
+ values.push(state);
1852
1147
  }
1853
1148
  }
1854
- function tryAcquireSyncLock() {
1855
- if (syncInProgress && syncLockAcquiredAt !== null) {
1856
- const heldForMs = Date.now() - syncLockAcquiredAt;
1857
- if (heldForMs <= SYNC_LOCK_TIMEOUT_MS) {
1858
- return { ok: false, heldForMs };
1149
+ function upsertQueueStats(ctx, queues, observedAt = Date.now()) {
1150
+ const database = ctx.db;
1151
+ const stmt = database.prepare(`
1152
+ INSERT INTO queues (
1153
+ name,
1154
+ wait_count,
1155
+ active_count,
1156
+ completed_count,
1157
+ failed_count,
1158
+ delayed_count,
1159
+ schedulers_count,
1160
+ is_paused,
1161
+ last_observed_at
1162
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
1163
+ ON CONFLICT(name) DO UPDATE SET
1164
+ wait_count = excluded.wait_count,
1165
+ active_count = excluded.active_count,
1166
+ completed_count = excluded.completed_count,
1167
+ failed_count = excluded.failed_count,
1168
+ delayed_count = excluded.delayed_count,
1169
+ schedulers_count = excluded.schedulers_count,
1170
+ is_paused = excluded.is_paused,
1171
+ last_observed_at = excluded.last_observed_at
1172
+ `);
1173
+ const runUpsert = database.transaction((items) => {
1174
+ for (const queue of items) {
1175
+ stmt.run(queue.name, queue.counts.wait, queue.counts.active, queue.counts.completed, queue.counts.failed, queue.counts.delayed, queue.counts.schedulers, queue.isPaused ? 1 : 0, observedAt);
1859
1176
  }
1860
- console.error(`Sync lock held for ${heldForMs}ms (> ${SYNC_LOCK_TIMEOUT_MS}ms timeout) \u2014 stealing.`);
1861
- }
1862
- syncInProgress = true;
1863
- syncLockAcquiredAt = Date.now();
1864
- return { ok: true };
1177
+ });
1178
+ runUpsert(queues);
1865
1179
  }
1866
- function releaseSyncLock() {
1867
- syncInProgress = false;
1868
- syncLockAcquiredAt = null;
1180
+ function upsertSchedulers(ctx, queue, schedulers, observedAt = Date.now()) {
1181
+ const database = ctx.db;
1182
+ const stmt = database.prepare(`
1183
+ INSERT INTO schedulers (
1184
+ queue,
1185
+ key,
1186
+ name,
1187
+ pattern,
1188
+ every,
1189
+ next,
1190
+ iteration_count,
1191
+ tz,
1192
+ last_observed_at
1193
+ )
1194
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
1195
+ ON CONFLICT(queue, key) DO UPDATE SET
1196
+ name = excluded.name,
1197
+ pattern = excluded.pattern,
1198
+ every = excluded.every,
1199
+ next = excluded.next,
1200
+ iteration_count = excluded.iteration_count,
1201
+ tz = excluded.tz,
1202
+ last_observed_at = excluded.last_observed_at
1203
+ `);
1204
+ const runUpsert = database.transaction((items) => {
1205
+ for (const s of items) {
1206
+ stmt.run(queue, s.key, s.name, s.pattern ?? null, s.every ?? null, s.next ?? null, s.iterationCount ?? null, s.tz ?? null, observedAt);
1207
+ }
1208
+ });
1209
+ runUpsert(schedulers);
1869
1210
  }
1870
- var recentlyPolledWrites = new Map;
1871
- var RECENT_POLL_WINDOW_MS = 120000;
1872
- var RECENT_POLL_MAX_ENTRIES = 50000;
1873
- function pruneRecentlyPolled() {
1874
- const cutoff = Date.now() - RECENT_POLL_WINDOW_MS;
1875
- for (const [key, ts] of recentlyPolledWrites) {
1876
- if (ts < cutoff)
1877
- recentlyPolledWrites.delete(key);
1878
- }
1211
+ function querySchedulers(ctx, queue, page = 1, pageSize = 25) {
1212
+ const database = ctx.db;
1213
+ const offset = (page - 1) * pageSize;
1214
+ const { total } = database.prepare("SELECT COUNT(*) as total FROM schedulers WHERE queue = ?").get(queue);
1215
+ const rows = database.prepare(`
1216
+ SELECT key, name, pattern, every, next, iteration_count, tz, last_observed_at
1217
+ FROM schedulers
1218
+ WHERE queue = ?
1219
+ ORDER BY key ASC
1220
+ LIMIT ? OFFSET ?
1221
+ `).all(queue, pageSize, offset);
1222
+ const schedulers = rows.map((row) => ({
1223
+ key: row.key,
1224
+ name: row.name,
1225
+ pattern: row.pattern ?? undefined,
1226
+ every: row.every ?? undefined,
1227
+ next: row.next ?? undefined,
1228
+ iterationCount: row.iteration_count ?? undefined,
1229
+ tz: row.tz ?? undefined,
1230
+ lastObservedAt: row.last_observed_at
1231
+ }));
1232
+ return { schedulers, total };
1879
1233
  }
1880
- function markPolledWrites(queue, jobIds) {
1881
- const now = Date.now();
1882
- for (const id of jobIds) {
1883
- recentlyPolledWrites.set(`${queue}:${id}`, now);
1234
+ function queryQueueStats(ctx) {
1235
+ const database = ctx.db;
1236
+ const rows = database.prepare(`
1237
+ SELECT
1238
+ name,
1239
+ wait_count,
1240
+ active_count,
1241
+ completed_count,
1242
+ failed_count,
1243
+ delayed_count,
1244
+ schedulers_count,
1245
+ is_paused,
1246
+ last_observed_at
1247
+ FROM queues
1248
+ ORDER BY name ASC
1249
+ `).all();
1250
+ return rows.map((row) => {
1251
+ const counts = {
1252
+ wait: row.wait_count,
1253
+ active: row.active_count,
1254
+ completed: row.completed_count,
1255
+ failed: row.failed_count,
1256
+ delayed: row.delayed_count,
1257
+ schedulers: row.schedulers_count
1258
+ };
1259
+ return {
1260
+ name: row.name,
1261
+ counts,
1262
+ isPaused: row.is_paused === 1,
1263
+ total: counts.wait + counts.active + counts.completed + counts.failed + counts.delayed,
1264
+ lastObservedAt: row.last_observed_at
1265
+ };
1266
+ });
1267
+ }
1268
+ function queryJobs(ctx, params) {
1269
+ const database = ctx.db;
1270
+ const {
1271
+ queue,
1272
+ search,
1273
+ state,
1274
+ sort = "timestamp",
1275
+ order = "desc",
1276
+ page = 1,
1277
+ pageSize = 25
1278
+ } = params;
1279
+ const validSorts = ["id", "name", "state", "timestamp"];
1280
+ const sortCol = validSorts.includes(sort) ? sort : "timestamp";
1281
+ const sortOrder = order === "asc" ? "ASC" : "DESC";
1282
+ const offset = (page - 1) * pageSize;
1283
+ if (Array.isArray(state) && state.length === 0) {
1284
+ return { jobs: [], total: 0 };
1884
1285
  }
1885
- if (recentlyPolledWrites.size > RECENT_POLL_MAX_ENTRIES) {
1886
- pruneRecentlyPolled();
1286
+ if (search) {
1287
+ const conditions2 = ["j.queue = ?"];
1288
+ const values2 = [queue];
1289
+ appendStateClause(conditions2, values2, state, "j.");
1290
+ const where2 = conditions2.join(" AND ");
1291
+ const ftsMatch = `${search}*`;
1292
+ const countSql2 = `SELECT COUNT(*) as total FROM jobs j JOIN jobs_fts fts ON j.rowid = fts.rowid WHERE ${where2} AND jobs_fts MATCH ?`;
1293
+ const total2 = database.prepare(countSql2).get(...values2, ftsMatch).total;
1294
+ const qualifiedSort = `j.${sortCol}`;
1295
+ const sql2 = `SELECT j.* FROM jobs j JOIN jobs_fts fts ON j.rowid = fts.rowid WHERE ${where2} AND jobs_fts MATCH ? ORDER BY ${qualifiedSort} ${sortOrder} LIMIT ? OFFSET ?`;
1296
+ const jobs2 = database.prepare(sql2).all(...values2, ftsMatch, pageSize, offset);
1297
+ return { jobs: jobs2, total: total2 };
1887
1298
  }
1299
+ const conditions = ["queue = ?"];
1300
+ const values = [queue];
1301
+ appendStateClause(conditions, values, state, "");
1302
+ const where = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
1303
+ const countSql = `SELECT COUNT(*) as total FROM jobs ${where}`;
1304
+ const total = database.prepare(countSql).get(...values).total;
1305
+ const sql = `SELECT * FROM jobs ${where} ORDER BY ${sortCol} ${sortOrder} LIMIT ? OFFSET ?`;
1306
+ const jobs = database.prepare(sql).all(...values, pageSize, offset);
1307
+ return { jobs, total };
1888
1308
  }
1889
- function wasPolledSince(queue, jobId, threshold) {
1890
- const ts = recentlyPolledWrites.get(`${queue}:${jobId}`);
1891
- return ts !== undefined && ts >= threshold;
1309
+ function safeDataPreview(data) {
1310
+ if (data === undefined || data === null)
1311
+ return null;
1312
+ try {
1313
+ return JSON.stringify(data).slice(0, 500);
1314
+ } catch {
1315
+ return null;
1316
+ }
1892
1317
  }
1893
- async function syncQueue(queueName) {
1894
- const lock = tryAcquireSyncLock();
1895
- if (!lock.ok) {
1896
- const message = `Refusing to sync queue "${queueName}": another sync is in progress ` + `(held ${lock.heldForMs}ms). syncQueue/fullSync share a single staging ` + `table and cannot run concurrently.`;
1897
- console.error(message);
1898
- return {
1899
- inserted: 0,
1900
- stateUpdated: 0,
1901
- softDeleted: 0,
1902
- total: 0,
1903
- error: message
1904
- };
1318
+ function safeJson(data) {
1319
+ if (data === undefined || data === null)
1320
+ return null;
1321
+ try {
1322
+ return JSON.stringify(data);
1323
+ } catch {
1324
+ return null;
1905
1325
  }
1906
- const syncStart = Date.now();
1907
- const syncDb = getSqliteDb();
1908
- const assertSameConnection = () => {
1909
- if (getSqliteDb() !== syncDb) {
1910
- throw new Error(`syncQueue("${queueName}") aborted: SQLite connection was closed mid-sync`);
1326
+ }
1327
+ function upsertJobs(ctx, queue, jobs, observedAt = Date.now()) {
1328
+ const database = ctx.db;
1329
+ const stmt = database.prepare(`
1330
+ INSERT INTO jobs (
1331
+ id,
1332
+ queue,
1333
+ name,
1334
+ state,
1335
+ timestamp,
1336
+ data_preview,
1337
+ data_json,
1338
+ opts_json,
1339
+ attempts_made,
1340
+ failed_reason,
1341
+ stacktrace_json,
1342
+ returnvalue_json,
1343
+ processed_on,
1344
+ finished_on,
1345
+ progress_json,
1346
+ repeat_job_key,
1347
+ delay,
1348
+ last_observed_at
1349
+ )
1350
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
1351
+ ON CONFLICT(queue, id) DO UPDATE SET
1352
+ name = excluded.name,
1353
+ state = excluded.state,
1354
+ timestamp = excluded.timestamp,
1355
+ data_preview = excluded.data_preview,
1356
+ data_json = excluded.data_json,
1357
+ opts_json = excluded.opts_json,
1358
+ attempts_made = excluded.attempts_made,
1359
+ failed_reason = excluded.failed_reason,
1360
+ stacktrace_json = excluded.stacktrace_json,
1361
+ returnvalue_json = excluded.returnvalue_json,
1362
+ processed_on = excluded.processed_on,
1363
+ finished_on = excluded.finished_on,
1364
+ progress_json = excluded.progress_json,
1365
+ repeat_job_key = excluded.repeat_job_key,
1366
+ delay = excluded.delay,
1367
+ last_observed_at = excluded.last_observed_at
1368
+ `);
1369
+ const upsert = database.transaction((items) => {
1370
+ for (const job of items) {
1371
+ const dataPreview = safeDataPreview(job.data);
1372
+ stmt.run(job.id, queue, job.name, job.state, job.timestamp, dataPreview, safeJson(job.data), safeJson(job.opts), job.attemptsMade ?? null, job.failedReason ?? null, safeJson(job.stacktrace), safeJson(job.returnvalue), job.processedOn ?? null, job.finishedOn ?? null, safeJson(job.progress), job.repeatJobKey ?? null, job.delay ?? null, observedAt);
1911
1373
  }
1374
+ });
1375
+ upsert(jobs);
1376
+ }
1377
+
1378
+ // src/data/queue-store.ts
1379
+ var DEFAULT_PAGE_SIZE = 25;
1380
+ function stateFilter(state) {
1381
+ switch (state) {
1382
+ case undefined:
1383
+ case "latest":
1384
+ case "all":
1385
+ return;
1386
+ case "wait":
1387
+ return ["waiting", "prioritized"];
1388
+ case "schedulers":
1389
+ throw new Error("Cannot list schedulers via listJobs(). Use listSchedulers() instead.");
1390
+ default:
1391
+ return state;
1392
+ }
1393
+ }
1394
+ function rowToSummary(row) {
1395
+ return {
1396
+ id: row.id,
1397
+ name: row.name ?? "unknown",
1398
+ state: row.state,
1399
+ timestamp: row.timestamp ?? 0,
1400
+ lastObservedAt: row.last_observed_at
1912
1401
  };
1913
- try {
1914
- createSyncStaging();
1915
- let total = 0;
1916
- for await (const batch of getAllJobIds(queueName)) {
1917
- assertSameConnection();
1918
- insertStagingBatch(queueName, batch);
1919
- total += batch.length;
1920
- }
1921
- assertSameConnection();
1922
- const resurrected = findResurrectedIdsByStagingDiff(queueName);
1923
- if (resurrected.length > 0) {
1924
- throw new JobResurrectionError(queueName, resurrected);
1925
- }
1926
- const newStubs = findNewIdsByStagingDiff(queueName);
1927
- if (newStubs.length > 0) {
1928
- upsertJobStubs(queueName, newStubs);
1929
- }
1930
- const changed = findChangedIdsByStagingDiff(queueName).filter((row) => !wasPolledSince(queueName, row.id, syncStart));
1931
- if (changed.length > 0) {
1932
- upsertJobStubs(queueName, changed);
1933
- }
1934
- const staleIds = findStaleIdsByStagingDiff(queueName).filter((id) => !wasPolledSince(queueName, id, syncStart));
1935
- const now = Date.now();
1936
- const softDeleted = softDeleteJobsByIds(queueName, staleIds, now);
1937
- upsertSyncState(queueName, {
1938
- jobCount: total,
1939
- syncedAt: now
1940
- });
1941
- return {
1942
- inserted: newStubs.length,
1943
- stateUpdated: changed.length,
1944
- softDeleted,
1945
- total
1946
- };
1947
- } catch (error) {
1948
- if (error instanceof JobResurrectionError) {
1949
- throw error;
1950
- }
1951
- const message = error instanceof Error ? error.message : String(error);
1952
- console.error(`SQLite sync failed for queue "${queueName}":`, error);
1402
+ }
1403
+ function listQueues(ctx) {
1404
+ return queryQueueStats(ctx);
1405
+ }
1406
+ function listJobs(ctx, queue, options = {}) {
1407
+ const page = options.page ?? 1;
1408
+ const pageSize = options.pageSize ?? DEFAULT_PAGE_SIZE;
1409
+ const result = queryJobs(ctx, {
1410
+ queue,
1411
+ state: stateFilter(options.state),
1412
+ sort: "timestamp",
1413
+ order: "desc",
1414
+ page,
1415
+ pageSize
1416
+ });
1417
+ return {
1418
+ jobs: result.jobs.map(rowToSummary),
1419
+ total: result.total,
1420
+ page,
1421
+ pageSize,
1422
+ totalPages: Math.ceil(result.total / pageSize)
1423
+ };
1424
+ }
1425
+ function listSchedulers(ctx, queue, options = {}) {
1426
+ const page = options.page ?? 1;
1427
+ const pageSize = options.pageSize ?? DEFAULT_PAGE_SIZE;
1428
+ const result = querySchedulers(ctx, queue, page, pageSize);
1429
+ return {
1430
+ schedulers: result.schedulers,
1431
+ total: result.total,
1432
+ page,
1433
+ pageSize,
1434
+ totalPages: Math.ceil(result.total / pageSize)
1435
+ };
1436
+ }
1437
+ function recordObservedQueues(ctx, queues, options = {}) {
1438
+ upsertQueueStats(ctx, queues, options.observedAt ?? Date.now());
1439
+ return { observed: queues.length };
1440
+ }
1441
+ function recordObservedJobs(ctx, queue, jobs, options = {}) {
1442
+ upsertJobs(ctx, queue, jobs, options.observedAt ?? Date.now());
1443
+ return { observed: jobs.length };
1444
+ }
1445
+ function recordObservedSchedulers(ctx, queue, schedulers, options = {}) {
1446
+ upsertSchedulers(ctx, queue, schedulers, options.observedAt ?? Date.now());
1447
+ return { observed: schedulers.length };
1448
+ }
1449
+ function staleCutoff(ctx, now) {
1450
+ return now - ctx.config.cacheTtlMs;
1451
+ }
1452
+ function expireStaleRecords(ctx, options = {}) {
1453
+ const database = ctx.db;
1454
+ const cutoff = staleCutoff(ctx, options.now ?? Date.now());
1455
+ return database.transaction(() => {
1456
+ const expiredQueues = database.prepare("SELECT name FROM queues WHERE last_observed_at < ?").all(cutoff);
1457
+ const queueNames = expiredQueues.map((row) => row.name);
1458
+ let cascadedSchedulers = 0;
1459
+ if (queueNames.length > 0) {
1460
+ const placeholders = queueNames.map(() => "?").join(",");
1461
+ cascadedSchedulers = database.prepare(`DELETE FROM schedulers WHERE queue IN (${placeholders})`).run(...queueNames).changes;
1462
+ database.prepare(`DELETE FROM queues WHERE name IN (${placeholders})`).run(...queueNames);
1463
+ }
1464
+ const staleSchedulers = database.prepare("DELETE FROM schedulers WHERE last_observed_at < ?").run(cutoff).changes;
1465
+ const staleJobs = database.prepare("DELETE FROM jobs WHERE last_observed_at < ?").run(cutoff).changes;
1953
1466
  return {
1954
- inserted: 0,
1955
- stateUpdated: 0,
1956
- softDeleted: 0,
1957
- total: 0,
1958
- error: message
1467
+ queuesDeleted: queueNames.length,
1468
+ schedulersDeleted: cascadedSchedulers + staleSchedulers,
1469
+ jobsDeleted: staleJobs
1959
1470
  };
1960
- } finally {
1961
- if (getSqliteDb() === syncDb) {
1962
- try {
1963
- dropSyncStaging();
1964
- } catch (cleanupError) {
1965
- console.error(`Failed to drop sync_staging for queue "${queueName}":`, cleanupError);
1966
- }
1967
- }
1968
- releaseSyncLock();
1969
- }
1471
+ })();
1970
1472
  }
1971
- async function fullSync() {
1972
- let queues;
1473
+
1474
+ // src/data/queue-store-lifecycle.ts
1475
+ var CLEANUP_INTERVAL_MS = 60000;
1476
+ function runQueueStoreCleanupIfDue(ctx, now = Date.now()) {
1477
+ const lastCleanupAt = ctx.queueStore.lastCleanupAt;
1478
+ if (lastCleanupAt !== null && now - lastCleanupAt < CLEANUP_INTERVAL_MS) {
1479
+ return;
1480
+ }
1481
+ ctx.queueStore.lastCleanupAt = now;
1973
1482
  try {
1974
- queues = await discoverQueueNames();
1483
+ expireStaleRecords(ctx, { now });
1975
1484
  } catch (error) {
1976
- const message = error instanceof Error ? error.message : String(error);
1977
- console.error("SQLite full sync: queue discovery failed:", error);
1978
- return {
1979
- queues: 0,
1980
- totalInserted: 0,
1981
- totalSoftDeleted: 0,
1982
- totalCompacted: 0,
1983
- errors: [{ queue: "", error: message }]
1984
- };
1985
- }
1986
- let totalInserted = 0;
1987
- let totalSoftDeleted = 0;
1988
- const errors = [];
1989
- const invariantErrors = [];
1990
- for (const q of queues) {
1991
- try {
1992
- const result = await syncQueue(q);
1993
- if (result.error) {
1994
- errors.push({ queue: q, error: result.error });
1995
- } else {
1996
- totalInserted += result.inserted;
1997
- totalSoftDeleted += result.softDeleted;
1998
- }
1999
- } catch (error) {
2000
- if (!(error instanceof JobResurrectionError)) {
2001
- throw error;
2002
- }
2003
- invariantErrors.push({ queue: q, error: error.message });
2004
- }
2005
- }
2006
- if (invariantErrors.length > 0) {
2007
- throw new FullSyncInvariantError(invariantErrors);
1485
+ console.warn("Failed to expire stale queue-store records:", error instanceof Error ? error.message : error);
2008
1486
  }
2009
- const totalCompacted = compactRemovedJobs(Date.now(), getConfig().retentionMs);
2010
- return {
2011
- queues: queues.length,
2012
- totalInserted,
2013
- totalSoftDeleted,
2014
- totalCompacted,
2015
- errors
2016
- };
2017
1487
  }
2018
1488
 
2019
1489
  // src/polling.ts
@@ -2044,6 +1514,10 @@ function schedulersViewState(schedulers, total) {
2044
1514
  jobsTotalPages: 0
2045
1515
  };
2046
1516
  }
1517
+ function pageSchedulers(schedulers, page, pageSize = SCHEDULER_PAGE_SIZE) {
1518
+ const start = (page - 1) * pageSize;
1519
+ return schedulers.slice(start, start + pageSize);
1520
+ }
2047
1521
  var EMPTY_QUEUE_VIEW = {
2048
1522
  jobs: [],
2049
1523
  jobsTotal: 0,
@@ -2055,20 +1529,36 @@ var EMPTY_QUEUE_VIEW = {
2055
1529
  function clampQueueIndex(queues, currentIndex) {
2056
1530
  return queues.length > 0 ? Math.min(currentIndex, queues.length - 1) : 0;
2057
1531
  }
1532
+ function selectedQueueIndexAfterSort(queues, previousQueues, previousIndex) {
1533
+ const previousName = previousQueues[previousIndex]?.name;
1534
+ if (previousName) {
1535
+ const nextIndex = queues.findIndex((queue) => queue.name === previousName);
1536
+ if (nextIndex !== -1)
1537
+ return nextIndex;
1538
+ }
1539
+ return clampQueueIndex(queues, previousIndex);
1540
+ }
2058
1541
 
2059
1542
  class PollingManager {
2060
1543
  intervalId = null;
2061
1544
  isRunning = false;
2062
1545
  isPolling = false;
2063
- start() {
1546
+ ctx = null;
1547
+ requireCtx() {
1548
+ if (!this.ctx) {
1549
+ throw new Error("PollingManager.start(ctx) must be called before refresh*/poll");
1550
+ }
1551
+ return this.ctx;
1552
+ }
1553
+ start(ctx) {
2064
1554
  if (this.isRunning)
2065
1555
  return;
2066
- const config = getConfig();
1556
+ this.ctx = ctx;
2067
1557
  this.isRunning = true;
2068
1558
  this.poll();
2069
1559
  this.intervalId = setInterval(() => {
2070
1560
  this.poll();
2071
- }, config.pollInterval);
1561
+ }, ctx.config.pollInterval);
2072
1562
  }
2073
1563
  stop() {
2074
1564
  if (this.intervalId) {
@@ -2076,21 +1566,25 @@ class PollingManager {
2076
1566
  this.intervalId = null;
2077
1567
  }
2078
1568
  this.isRunning = false;
1569
+ this.ctx = null;
2079
1570
  }
2080
1571
  async poll() {
2081
1572
  if (this.isPolling)
2082
1573
  return;
1574
+ if (!this.ctx)
1575
+ return;
2083
1576
  this.isPolling = true;
1577
+ const ctx = this.ctx;
2084
1578
  const wasConnected = stateManager.getState().connected;
2085
1579
  try {
2086
1580
  stateManager.setState({ isLoading: true });
2087
- const observedQueues = await getAllQueueStats();
2088
- upsertQueueStats(observedQueues);
2089
- const queues = queryQueueStats();
1581
+ const observedQueues = await getAllQueueStats(ctx);
1582
+ recordObservedQueues(ctx, observedQueues);
1583
+ const currentState = stateManager.getState();
1584
+ const queues = sortQueues(listQueues(ctx), currentState.queueSortBy, currentState.queueSortOrder);
2090
1585
  const rates = updateMetricsTracker(queues);
2091
1586
  const globalMetrics = calculateGlobalMetricsFromQueueStats(queues, rates);
2092
- const currentState = stateManager.getState();
2093
- const clampedIndex = clampQueueIndex(queues, currentState.selectedQueueIndex);
1587
+ const clampedIndex = selectedQueueIndexAfterSort(queues, currentState.queues, currentState.selectedQueueIndex);
2094
1588
  stateManager.setState({
2095
1589
  queues,
2096
1590
  globalMetrics,
@@ -2103,8 +1597,7 @@ class PollingManager {
2103
1597
  if (currentState.jobsStatus === "schedulers") {
2104
1598
  const observed = await this.fetchAllSchedulers(selectedQueue.name);
2105
1599
  this.persistObservedSchedulers(selectedQueue.name, observed.schedulers);
2106
- const storeResult = querySchedulers(selectedQueue.name, currentState.schedulersPage, SCHEDULER_PAGE_SIZE);
2107
- stateManager.setState(schedulersViewState(storeResult.schedulers, observed.total));
1600
+ stateManager.setState(schedulersViewState(pageSchedulers(observed.schedulers, currentState.schedulersPage), observed.total));
2108
1601
  } else {
2109
1602
  const observedJobsResult = await this.fetchVisibleJobs(selectedQueue.name, currentState.jobsStatus, currentState.jobsPage);
2110
1603
  this.persistObservedJobs(selectedQueue.name, observedJobsResult.jobs);
@@ -2119,31 +1612,39 @@ class PollingManager {
2119
1612
  if (wasConnected) {
2120
1613
  resetMetricsTracker();
2121
1614
  }
2122
- await this.applyDisconnectedFallback(errorMessage);
1615
+ await this.applyDisconnectedFallback(ctx, errorMessage);
2123
1616
  } finally {
1617
+ runQueueStoreCleanupIfDue(ctx);
2124
1618
  this.isPolling = false;
2125
1619
  }
2126
1620
  }
2127
- async applyDisconnectedFallback(errorMessage) {
1621
+ async applyDisconnectedFallback(ctx, errorMessage) {
2128
1622
  try {
2129
- const queues = queryQueueStats();
1623
+ const queues = listQueues(ctx);
2130
1624
  const currentState = stateManager.getState();
2131
- const clampedIndex = clampQueueIndex(queues, currentState.selectedQueueIndex);
2132
- const selectedQueue = queues[clampedIndex];
1625
+ const sortedQueues = sortQueues(queues, currentState.queueSortBy, currentState.queueSortOrder);
1626
+ const clampedIndex = selectedQueueIndexAfterSort(sortedQueues, currentState.queues, currentState.selectedQueueIndex);
1627
+ const selectedQueue = sortedQueues[clampedIndex];
2133
1628
  let viewState;
2134
1629
  if (selectedQueue && currentState.jobsStatus === "schedulers") {
2135
- const storeResult = querySchedulers(selectedQueue.name, currentState.schedulersPage, SCHEDULER_PAGE_SIZE);
1630
+ const storeResult = listSchedulers(ctx, selectedQueue.name, {
1631
+ page: currentState.schedulersPage,
1632
+ pageSize: SCHEDULER_PAGE_SIZE
1633
+ });
2136
1634
  viewState = schedulersViewState(storeResult.schedulers, storeResult.total);
2137
1635
  } else if (selectedQueue) {
2138
- const jobsResult = await getJobsFromStore(selectedQueue.name, currentState.jobsStatus, currentState.jobsPage);
1636
+ const jobsResult = listJobs(ctx, selectedQueue.name, {
1637
+ state: currentState.jobsStatus,
1638
+ page: currentState.jobsPage
1639
+ });
2139
1640
  viewState = jobsViewState(jobsResult);
2140
1641
  } else {
2141
1642
  viewState = EMPTY_QUEUE_VIEW;
2142
1643
  }
2143
1644
  stateManager.setState({
2144
- queues,
1645
+ queues: sortedQueues,
2145
1646
  selectedQueueIndex: clampedIndex,
2146
- globalMetrics: calculateGlobalMetricsFromQueueStats(queues, ZERO_RATES),
1647
+ globalMetrics: calculateGlobalMetricsFromQueueStats(sortedQueues, ZERO_RATES),
2147
1648
  connected: false,
2148
1649
  error: errorMessage,
2149
1650
  isLoading: false,
@@ -2159,26 +1660,26 @@ class PollingManager {
2159
1660
  }
2160
1661
  }
2161
1662
  async fetchVisibleJobs(queueName, status, page) {
2162
- return getJobs(queueName, status, page, undefined, true);
1663
+ return getJobs(this.requireCtx(), queueName, status, page, undefined, true);
2163
1664
  }
2164
1665
  persistObservedJobs(queueName, jobs) {
1666
+ const ctx = this.requireCtx();
2165
1667
  try {
2166
- upsertJobs(queueName, jobs);
2167
- markPolledWrites(queueName, jobs.map((j) => j.id));
1668
+ recordObservedJobs(ctx, queueName, jobs);
2168
1669
  } catch (error) {
2169
1670
  console.warn(`Failed to persist observed jobs for "${queueName}":`, error instanceof Error ? error.message : error);
2170
1671
  }
2171
1672
  }
2172
1673
  async fetchAllSchedulers(queueName) {
2173
- const firstBatch = await getAllJobSchedulers(queueName);
1674
+ const firstBatch = await getAllJobSchedulers(this.requireCtx(), queueName);
2174
1675
  if (firstBatch.schedulers.length >= firstBatch.total) {
2175
1676
  return firstBatch;
2176
1677
  }
2177
- return getAllJobSchedulers(queueName, firstBatch.total);
1678
+ return getAllJobSchedulers(this.requireCtx(), queueName, firstBatch.total);
2178
1679
  }
2179
1680
  persistObservedSchedulers(queueName, schedulers) {
2180
1681
  try {
2181
- upsertSchedulers(queueName, schedulers);
1682
+ recordObservedSchedulers(this.requireCtx(), queueName, schedulers);
2182
1683
  } catch (error) {
2183
1684
  console.warn(`Failed to persist observed schedulers for "${queueName}":`, error instanceof Error ? error.message : error);
2184
1685
  }
@@ -2205,7 +1706,10 @@ class PollingManager {
2205
1706
  } catch (error) {
2206
1707
  console.error("Failed to observe jobs:", error instanceof Error ? error.message : error);
2207
1708
  }
2208
- const jobsResult = observedJobsResult ?? await getJobsFromStore(selectedQueue.name, state.jobsStatus, state.jobsPage);
1709
+ const jobsResult = observedJobsResult ?? listJobs(this.requireCtx(), selectedQueue.name, {
1710
+ state: state.jobsStatus,
1711
+ page: state.jobsPage
1712
+ });
2209
1713
  stateManager.setState({
2210
1714
  jobs: jobsResult.jobs,
2211
1715
  jobsTotal: jobsResult.total,
@@ -2233,17 +1737,95 @@ class PollingManager {
2233
1737
  } catch (error) {
2234
1738
  console.error("Failed to refresh schedulers:", error instanceof Error ? error.message : error);
2235
1739
  }
2236
- const storeResult = querySchedulers(selectedQueue.name, state.schedulersPage, SCHEDULER_PAGE_SIZE);
2237
- const total = observed?.total ?? storeResult.total;
1740
+ if (observed) {
1741
+ stateManager.setState({
1742
+ schedulers: pageSchedulers(observed.schedulers, state.schedulersPage),
1743
+ schedulersTotal: observed.total,
1744
+ schedulersTotalPages: Math.ceil(observed.total / SCHEDULER_PAGE_SIZE)
1745
+ });
1746
+ return;
1747
+ }
1748
+ const storeResult = listSchedulers(this.requireCtx(), selectedQueue.name, {
1749
+ page: state.schedulersPage,
1750
+ pageSize: SCHEDULER_PAGE_SIZE
1751
+ });
2238
1752
  stateManager.setState({
2239
1753
  schedulers: storeResult.schedulers,
2240
- schedulersTotal: total,
2241
- schedulersTotalPages: Math.ceil(total / SCHEDULER_PAGE_SIZE)
1754
+ schedulersTotal: storeResult.total,
1755
+ schedulersTotalPages: Math.ceil(storeResult.total / SCHEDULER_PAGE_SIZE)
2242
1756
  });
2243
1757
  }
2244
1758
  }
2245
1759
  var pollingManager = new PollingManager;
2246
1760
 
1761
+ // src/context.ts
1762
+ import { RedisConnection } from "bullmq";
1763
+ function createRedisClient(config) {
1764
+ let connection = null;
1765
+ let clientPromise = null;
1766
+ const getClient = () => {
1767
+ if (!clientPromise) {
1768
+ connection = new RedisConnection({
1769
+ ...redisConnectionOptions(config),
1770
+ lazyConnect: true,
1771
+ retryStrategy: (times) => {
1772
+ if (times > 3) {
1773
+ return null;
1774
+ }
1775
+ return Math.min(times * 200, 2000);
1776
+ }
1777
+ }, { blocking: false });
1778
+ connection.on("error", () => {});
1779
+ clientPromise = connection.client.catch((err) => {
1780
+ clientPromise = null;
1781
+ connection = null;
1782
+ throw err;
1783
+ });
1784
+ }
1785
+ return clientPromise;
1786
+ };
1787
+ return {
1788
+ get status() {
1789
+ return connection?.status ?? "wait";
1790
+ },
1791
+ async connect() {
1792
+ await getClient();
1793
+ },
1794
+ async quit() {
1795
+ if (!connection)
1796
+ return;
1797
+ await connection.close();
1798
+ connection = null;
1799
+ clientPromise = null;
1800
+ },
1801
+ async scan(cursor, ...args) {
1802
+ const client = await getClient();
1803
+ return client.scan(cursor, ...args);
1804
+ },
1805
+ async del(...keys) {
1806
+ const client = await getClient();
1807
+ return client.del(...keys);
1808
+ }
1809
+ };
1810
+ }
1811
+ function createContext(config, opts = {}) {
1812
+ const redis = createRedisClient(config);
1813
+ const db = createSqliteDb(config, opts.dbPath);
1814
+ return {
1815
+ config,
1816
+ redis,
1817
+ db,
1818
+ queueCache: new Map,
1819
+ queueNamesCache: null,
1820
+ queueStore: { lastCleanupAt: null }
1821
+ };
1822
+ }
1823
+ async function closeContext(ctx) {
1824
+ await closeAllQueues(ctx);
1825
+ await ctx.redis.quit().catch(() => {});
1826
+ ctx.db.close();
1827
+ }
1828
+
2247
1829
  // src/ui/layout.ts
2248
1830
  import { BoxRenderable, TextRenderable, t, fg, bold } from "@opentui/core";
2249
1831
 
@@ -2397,6 +1979,14 @@ import {
2397
1979
  SelectRenderable,
2398
1980
  SelectRenderableEvents
2399
1981
  } from "@opentui/core";
1982
+ function formatQueueTaskBar(total, maxTotal, width = 8) {
1983
+ if (width <= 0)
1984
+ return "";
1985
+ if (maxTotal <= 0 || total <= 0)
1986
+ return ".".repeat(width);
1987
+ const filled = Math.max(1, Math.round(total / maxTotal * width));
1988
+ return "#".repeat(filled).padEnd(width, ".");
1989
+ }
2400
1990
  function createQueueList(renderer, parent) {
2401
1991
  const container = new BoxRenderable2(renderer, {
2402
1992
  id: "queue-list-container",
@@ -2454,9 +2044,10 @@ function createQueueList(renderer, parent) {
2454
2044
  });
2455
2045
  return { container, title, select, emptyText };
2456
2046
  }
2457
- function updateQueueList(elements, queues, selectedIndex, isFocused) {
2047
+ function updateQueueList(elements, queues, selectedIndex, isFocused, sortBy = "name", sortOrder = "asc") {
2458
2048
  const { select, emptyText, title } = elements;
2459
- title.content = isFocused ? " QUEUES [*]" : " QUEUES";
2049
+ const sortText = queueSortLabel(sortBy, sortOrder);
2050
+ title.content = isFocused ? ` QUEUES [*] ${sortText}` : ` QUEUES ${sortText}`;
2460
2051
  title.bg = isFocused ? colors.surface1 : colors.surface0;
2461
2052
  if (queues.length === 0) {
2462
2053
  select.visible = false;
@@ -2465,9 +2056,10 @@ function updateQueueList(elements, queues, selectedIndex, isFocused) {
2465
2056
  }
2466
2057
  select.visible = true;
2467
2058
  emptyText.visible = false;
2059
+ const maxTotal = Math.max(...queues.map((queue) => queue.total), 0);
2468
2060
  const options = queues.map((queue) => ({
2469
2061
  name: queue.name,
2470
- description: `${queue.total} jobs | ${queue.isPaused ? "PAUSED" : "active"}`,
2062
+ description: `${formatQueueTaskBar(queue.total, maxTotal)} ${queue.total} jobs | fail ${queue.counts.failed} | ${queue.isPaused ? "PAUSED" : "active"}`,
2471
2063
  value: queue
2472
2064
  }));
2473
2065
  select.options = options;
@@ -3449,10 +3041,16 @@ function updatePageJump(elements, visible, inputValue, currentPage, totalPages)
3449
3041
 
3450
3042
  // src/app.ts
3451
3043
  class App {
3044
+ ctx;
3452
3045
  renderer = null;
3453
3046
  elements = null;
3454
3047
  unsubscribeState = null;
3455
- syncIntervalId = null;
3048
+ constructor(ctx) {
3049
+ this.ctx = ctx;
3050
+ }
3051
+ requireCtx() {
3052
+ return this.ctx;
3053
+ }
3456
3054
  async start() {
3457
3055
  this.renderer = await createCliRenderer({
3458
3056
  exitOnCtrlC: false,
@@ -3464,15 +3062,13 @@ class App {
3464
3062
  this.unsubscribeState = stateManager.subscribe(() => {
3465
3063
  this.render();
3466
3064
  });
3467
- createSqliteDb();
3468
- pollingManager.start();
3469
- const runFullSync = () => {
3470
- fullSync().catch((error) => {
3471
- console.error("SQLite full sync failed:", error);
3472
- });
3473
- };
3474
- runFullSync();
3475
- this.syncIntervalId = setInterval(runFullSync, 60000);
3065
+ const ctx = this.ctx;
3066
+ try {
3067
+ await ctx.redis.connect();
3068
+ } catch (error) {
3069
+ console.error("Redis connection failed at startup:", error instanceof Error ? error.message : error);
3070
+ }
3071
+ pollingManager.start(ctx);
3476
3072
  this.render();
3477
3073
  }
3478
3074
  createElements() {
@@ -3602,6 +3198,12 @@ class App {
3602
3198
  case "r":
3603
3199
  await pollingManager.refresh();
3604
3200
  break;
3201
+ case "s":
3202
+ if (state.focusedPane === "queues") {
3203
+ stateManager.cycleQueueSort();
3204
+ await pollingManager.refresh();
3205
+ }
3206
+ break;
3605
3207
  case "g":
3606
3208
  if (state.focusedPane === "jobs") {
3607
3209
  if (state.jobsStatus === "schedulers" && state.schedulersTotalPages > 1) {
@@ -3638,7 +3240,7 @@ class App {
3638
3240
  if (!selectedJob || !selectedQueue)
3639
3241
  return;
3640
3242
  try {
3641
- const detail = await getJobDetail(selectedQueue.name, selectedJob.id);
3243
+ const detail = await getJobDetail(this.requireCtx(), selectedQueue.name, selectedJob.id);
3642
3244
  if (detail) {
3643
3245
  stateManager.openJobDetail(detail);
3644
3246
  }
@@ -3650,7 +3252,7 @@ class App {
3650
3252
  if (!selectedScheduler || !selectedQueue)
3651
3253
  return;
3652
3254
  try {
3653
- const detail = await getJobSchedulerDetail(selectedQueue.name, selectedScheduler.key);
3255
+ const detail = await getJobSchedulerDetail(this.requireCtx(), selectedQueue.name, selectedScheduler.key);
3654
3256
  if (detail) {
3655
3257
  stateManager.openSchedulerDetail(detail);
3656
3258
  }
@@ -3663,7 +3265,7 @@ class App {
3663
3265
  if (!schedulerDetail?.nextJob || !selectedQueue)
3664
3266
  return;
3665
3267
  try {
3666
- const jobDetail = await getJobDetail(selectedQueue.name, schedulerDetail.nextJob.id);
3268
+ const jobDetail = await getJobDetail(this.requireCtx(), selectedQueue.name, schedulerDetail.nextJob.id);
3667
3269
  if (jobDetail) {
3668
3270
  stateManager.closeSchedulerDetail();
3669
3271
  stateManager.openJobDetail(jobDetail);
@@ -3685,7 +3287,7 @@ class App {
3685
3287
  return;
3686
3288
  }
3687
3289
  try {
3688
- await deleteJob(selectedQueue.name, jobId);
3290
+ await deleteJob(this.requireCtx(), selectedQueue.name, jobId);
3689
3291
  stateManager.hideDeleteConfirm();
3690
3292
  stateManager.closeJobDetail();
3691
3293
  await pollingManager.refresh();
@@ -3727,10 +3329,10 @@ class App {
3727
3329
  confirmDialog,
3728
3330
  pageJump
3729
3331
  } = this.elements;
3730
- const config = getConfig();
3731
- updateHeaderStatus(layout.headerStatus, state.connected, state.error, config.redis.host, config.redis.port);
3332
+ const headerConfig = this.ctx.config;
3333
+ updateHeaderStatus(layout.headerStatus, state.connected, state.error, headerConfig.redis.host, headerConfig.redis.port);
3732
3334
  updateGlobalMetrics(globalMetrics, state.globalMetrics);
3733
- updateQueueList(queueList, state.queues, state.selectedQueueIndex, state.focusedPane === "queues");
3335
+ updateQueueList(queueList, state.queues, state.selectedQueueIndex, state.focusedPane === "queues", state.queueSortBy, state.queueSortOrder);
3734
3336
  const selectedQueue = stateManager.getSelectedQueue();
3735
3337
  updateQueueStats(queueStats, selectedQueue);
3736
3338
  updateStatusFilter(statusFilter, state.jobsStatus);
@@ -3754,28 +3356,254 @@ class App {
3754
3356
  }
3755
3357
  updatePageJump(pageJump, state.showPageJump, state.pageJumpInput, isSchedulersView ? state.schedulersPage : state.jobsPage, isSchedulersView ? state.schedulersTotalPages : state.jobsTotalPages);
3756
3358
  }
3757
- async cleanup() {
3758
- pollingManager.stop();
3759
- if (this.syncIntervalId) {
3760
- clearInterval(this.syncIntervalId);
3761
- this.syncIntervalId = null;
3762
- }
3763
- if (this.unsubscribeState) {
3764
- this.unsubscribeState();
3765
- }
3766
- await closeAllQueues();
3767
- await disconnectRedis();
3768
- closeSqliteDb();
3769
- if (this.renderer) {
3770
- this.renderer.destroy();
3359
+ async cleanup() {
3360
+ pollingManager.stop();
3361
+ if (this.unsubscribeState) {
3362
+ this.unsubscribeState();
3363
+ }
3364
+ await closeContext(this.ctx);
3365
+ if (this.renderer) {
3366
+ this.renderer.destroy();
3367
+ }
3368
+ process.exit(0);
3369
+ }
3370
+ }
3371
+
3372
+ // src/cli.ts
3373
+ import { parseArgs } from "util";
3374
+
3375
+ // src/errors.ts
3376
+ function writeError(message, code, details) {
3377
+ process.stderr.write(JSON.stringify({ error: message, code, details }) + `
3378
+ `);
3379
+ }
3380
+
3381
+ // src/profiles.ts
3382
+ import { existsSync, readFileSync } from "fs";
3383
+ import { homedir } from "os";
3384
+ import { join } from "path";
3385
+ function isRecord(value) {
3386
+ return !!value && typeof value === "object" && !Array.isArray(value);
3387
+ }
3388
+ function coerceOptionalPositiveInt(value) {
3389
+ if (value === undefined)
3390
+ return;
3391
+ const numberValue = Number(value);
3392
+ if (!Number.isInteger(numberValue) || numberValue <= 0)
3393
+ return null;
3394
+ return numberValue;
3395
+ }
3396
+ var PROFILE_ALLOWED_KEYS = ["redis", "pollInterval", "prefix", "queues", "cacheTtlMs"];
3397
+ var PROFILES_FILE_ALLOWED_KEYS = ["defaultProfile", "profiles"];
3398
+ function unknownKeyMessage(path, key, allowed) {
3399
+ return `unknown key '${key}' in ${path} (allowed: ${allowed.join(", ")})`;
3400
+ }
3401
+ function validateProfile(value, path, errors) {
3402
+ if (!isRecord(value)) {
3403
+ errors.push(`${path} must be an object`);
3404
+ return null;
3405
+ }
3406
+ for (const key of Object.keys(value)) {
3407
+ if (!PROFILE_ALLOWED_KEYS.includes(key)) {
3408
+ errors.push(unknownKeyMessage(path, key, PROFILE_ALLOWED_KEYS));
3409
+ }
3410
+ }
3411
+ const profile = {};
3412
+ if (value.redis !== undefined) {
3413
+ if (!isRecord(value.redis)) {
3414
+ errors.push(`${path}.redis must be an object`);
3415
+ } else {
3416
+ for (const key of Object.keys(value.redis)) {
3417
+ if (key !== "url")
3418
+ errors.push(unknownKeyMessage(`${path}.redis`, key, ["url"]));
3419
+ }
3420
+ if (typeof value.redis.url !== "string") {
3421
+ errors.push(`${path}.redis.url must be a string`);
3422
+ } else {
3423
+ profile.redis = { url: value.redis.url };
3424
+ }
3425
+ }
3426
+ }
3427
+ const pollInterval = coerceOptionalPositiveInt(value.pollInterval);
3428
+ if (pollInterval === null)
3429
+ errors.push(`${path}.pollInterval must be a positive integer`);
3430
+ else if (pollInterval !== undefined)
3431
+ profile.pollInterval = pollInterval;
3432
+ if (value.prefix !== undefined) {
3433
+ if (typeof value.prefix !== "string")
3434
+ errors.push(`${path}.prefix must be a string`);
3435
+ else
3436
+ profile.prefix = value.prefix;
3437
+ }
3438
+ if (value.queues !== undefined) {
3439
+ if (!Array.isArray(value.queues) || value.queues.some((queue) => typeof queue !== "string")) {
3440
+ errors.push(`${path}.queues must be an array of strings`);
3441
+ } else {
3442
+ profile.queues = value.queues;
3443
+ }
3444
+ }
3445
+ const cacheTtlMs = coerceOptionalPositiveInt(value.cacheTtlMs);
3446
+ if (cacheTtlMs === null)
3447
+ errors.push(`${path}.cacheTtlMs must be a positive integer`);
3448
+ else if (cacheTtlMs !== undefined)
3449
+ profile.cacheTtlMs = cacheTtlMs;
3450
+ return profile;
3451
+ }
3452
+ function validateProfilesFile(value) {
3453
+ const errors = [];
3454
+ if (!isRecord(value))
3455
+ return ["config file must be an object"];
3456
+ for (const key of Object.keys(value)) {
3457
+ if (!PROFILES_FILE_ALLOWED_KEYS.includes(key)) {
3458
+ errors.push(unknownKeyMessage("config file", key, PROFILES_FILE_ALLOWED_KEYS));
3459
+ }
3460
+ }
3461
+ const file = { profiles: {} };
3462
+ if (value.defaultProfile !== undefined) {
3463
+ if (typeof value.defaultProfile !== "string")
3464
+ errors.push("defaultProfile must be a string");
3465
+ else
3466
+ file.defaultProfile = value.defaultProfile;
3467
+ }
3468
+ if (value.profiles !== undefined) {
3469
+ if (!isRecord(value.profiles)) {
3470
+ errors.push("profiles must be an object");
3471
+ } else {
3472
+ for (const [name, profileValue] of Object.entries(value.profiles)) {
3473
+ const profile = validateProfile(profileValue, `profiles.${name}`, errors);
3474
+ if (profile)
3475
+ file.profiles[name] = profile;
3476
+ }
3477
+ }
3478
+ }
3479
+ return errors.length > 0 ? errors : file;
3480
+ }
3481
+ function resolveConfigPath(explicitPath, env = process.env) {
3482
+ if (explicitPath)
3483
+ return explicitPath;
3484
+ if (env.BULLMQ_DASH_CONFIG)
3485
+ return env.BULLMQ_DASH_CONFIG;
3486
+ const xdg = env.XDG_CONFIG_HOME;
3487
+ if (xdg)
3488
+ return join(xdg, "bullmq-dash", "config.json");
3489
+ return join(homedir(), ".config", "bullmq-dash", "config.json");
3490
+ }
3491
+ var ENV_REF = /^\$\{([A-Z_][A-Z0-9_]*)\}$/;
3492
+ function expandEnvRefs(value, env = process.env) {
3493
+ if (typeof value === "string") {
3494
+ const match = value.match(ENV_REF);
3495
+ if (!match)
3496
+ return value;
3497
+ const key = match[1];
3498
+ const resolved = env[key];
3499
+ if (resolved === undefined) {
3500
+ throw new Error(`Environment variable '${key}' referenced in profile is not set`);
3501
+ }
3502
+ return resolved;
3503
+ }
3504
+ if (Array.isArray(value)) {
3505
+ return value.map((v) => expandEnvRefs(v, env));
3506
+ }
3507
+ if (value && typeof value === "object") {
3508
+ const out = {};
3509
+ for (const [k, v] of Object.entries(value)) {
3510
+ out[k] = expandEnvRefs(v, env);
3511
+ }
3512
+ return out;
3513
+ }
3514
+ return value;
3515
+ }
3516
+ function loadProfile(opts = {}) {
3517
+ const env = opts.env ?? process.env;
3518
+ const explicitConfig = !!opts.configPath || !!env.BULLMQ_DASH_CONFIG;
3519
+ const explicitProfile = !!opts.profileName;
3520
+ const path = resolveConfigPath(opts.configPath, env);
3521
+ if (!existsSync(path)) {
3522
+ if (explicitConfig || explicitProfile) {
3523
+ writeError(`Config file not found: ${path}`, "CONFIG_ERROR", explicitConfig ? "Check the path passed to --config or $BULLMQ_DASH_CONFIG." : `--profile requires a config file. Create one at ${path} or pass --config <path>.`);
3524
+ process.exit(2);
3525
+ }
3526
+ return null;
3527
+ }
3528
+ let raw;
3529
+ try {
3530
+ raw = JSON.parse(readFileSync(path, "utf-8"));
3531
+ } catch (error) {
3532
+ writeError(`Failed to parse config file: ${path}`, "CONFIG_ERROR", error instanceof Error ? error.message : String(error));
3533
+ process.exit(2);
3534
+ }
3535
+ const parsed = validateProfilesFile(raw);
3536
+ if (Array.isArray(parsed)) {
3537
+ writeError(`Invalid config file: ${path}`, "CONFIG_ERROR", parsed.join("; "));
3538
+ process.exit(2);
3539
+ }
3540
+ const file = parsed;
3541
+ const name = opts.profileName ?? file.defaultProfile;
3542
+ if (!name)
3543
+ return null;
3544
+ const profile = file.profiles[name];
3545
+ if (!profile) {
3546
+ const available = Object.keys(file.profiles);
3547
+ writeError(`Profile '${name}' not found in ${path}`, "CONFIG_ERROR", available.length > 0 ? `Available profiles: ${available.join(", ")}` : "No profiles are defined in the config file.");
3548
+ process.exit(2);
3549
+ }
3550
+ let expanded;
3551
+ try {
3552
+ expanded = expandEnvRefs(profile, env);
3553
+ } catch (error) {
3554
+ writeError(`Failed to resolve env vars in profile '${name}'`, "CONFIG_ERROR", error instanceof Error ? error.message : String(error));
3555
+ process.exit(2);
3556
+ }
3557
+ return { sourcePath: path, name, profile: expanded };
3558
+ }
3559
+ function decodeUrlAuthField(raw, field) {
3560
+ try {
3561
+ return decodeURIComponent(raw);
3562
+ } catch {
3563
+ throw new Error(`Invalid percent-encoding in URL ${field}`);
3564
+ }
3565
+ }
3566
+ function redactUrl(input) {
3567
+ return input.replace(/(:\/\/)[^/]*@/, "$1[REDACTED]@");
3568
+ }
3569
+ function parseRedisUrl(input) {
3570
+ let parsed;
3571
+ try {
3572
+ parsed = new URL(input);
3573
+ } catch {
3574
+ throw new Error(`Not a valid URL: ${redactUrl(input)}`);
3575
+ }
3576
+ if (parsed.protocol !== "redis:" && parsed.protocol !== "rediss:") {
3577
+ throw new Error(`Unsupported scheme '${parsed.protocol}' (expected redis:// or rediss://)`);
3578
+ }
3579
+ if (!parsed.hostname) {
3580
+ throw new Error(`Missing host in URL: ${redactUrl(input)}`);
3581
+ }
3582
+ if (parsed.search || parsed.hash) {
3583
+ throw new Error("Query parameters and fragments are not supported in Redis URLs. " + "For TLS, use rediss:// (not ?ssl=true).");
3584
+ }
3585
+ const username = parsed.username ? decodeUrlAuthField(parsed.username, "username") : undefined;
3586
+ const password = parsed.password ? decodeUrlAuthField(parsed.password, "password") : undefined;
3587
+ let db;
3588
+ if (parsed.pathname && parsed.pathname !== "/") {
3589
+ const trimmed = parsed.pathname.replace(/^\//, "");
3590
+ if (!/^\d+$/.test(trimmed)) {
3591
+ throw new Error(`Database in URL must be a non-negative integer, got '/${trimmed}'`);
3771
3592
  }
3772
- process.exit(0);
3593
+ db = Number(trimmed);
3773
3594
  }
3595
+ const port = parsed.port ? Number(parsed.port) : 6379;
3596
+ return {
3597
+ host: parsed.hostname,
3598
+ port,
3599
+ username,
3600
+ password,
3601
+ db,
3602
+ tls: parsed.protocol === "rediss:"
3603
+ };
3774
3604
  }
3775
-
3776
3605
  // src/cli.ts
3777
- import { parseArgs } from "util";
3778
- var PACKAGE_VERSION = "0.3.0";
3606
+ var PACKAGE_VERSION = "0.3.2";
3779
3607
  var HELP_TEXT = `
3780
3608
  bullmq-dash - Terminal UI dashboard for BullMQ queue monitoring
3781
3609
 
@@ -3787,8 +3615,9 @@ Commands:
3787
3615
  queues list List all queues with job counts
3788
3616
  queues delete <queue> Delete a queue and all its jobs
3789
3617
  jobs list <queue> List jobs in a queue
3618
+ jobs failed <queue> List failed jobs in a queue
3790
3619
  jobs get <queue> <job-id> Get full detail for a single job
3791
- jobs retry <queue> Bulk-retry failed jobs (supports --dry-run)
3620
+ jobs retry <queue> Retry failed jobs (supports --dry-run/--yes)
3792
3621
  schedulers list <queue> List schedulers in a queue
3793
3622
  schedulers get <queue> <scheduler-id> Get detail for a single scheduler
3794
3623
 
@@ -3821,8 +3650,10 @@ Examples:
3821
3650
  bullmq-dash queues list --redis-url redis://localhost:6379
3822
3651
  bullmq-dash queues list --profile prod
3823
3652
  bullmq-dash queues list --redis-url redis://localhost --human-friendly
3653
+ bullmq-dash jobs failed email --redis-url redis://localhost
3824
3654
  bullmq-dash jobs list email --redis-url redis://localhost --job-state failed
3825
3655
  bullmq-dash jobs get email 123 --redis-url redis://localhost
3656
+ bullmq-dash jobs retry email --redis-url redis://localhost --job-id 123 --dry-run
3826
3657
  bullmq-dash jobs retry email --redis-url redis://localhost --job-state failed --since 1h --dry-run
3827
3658
  `;
3828
3659
  var CONNECTION_OPTIONS_HELP = `
@@ -3850,11 +3681,19 @@ var QUEUES_LIST_HELP = `
3850
3681
  Usage: bullmq-dash queues list [options]
3851
3682
 
3852
3683
  List all discovered queues with their job counts per state.
3684
+
3685
+ Options:
3686
+ --sort-by <field> Sort queues by: name | task-size | waiting | active | completed | failed | delayed
3687
+ Aliases: size, total, wait
3688
+ --sort-order <order> Sort order: asc | desc
3689
+ Defaults to asc for name, desc for metrics.
3853
3690
  ${CONNECTION_OPTIONS_HELP}
3854
3691
 
3855
3692
  Examples:
3856
3693
  bullmq-dash queues list --redis-url redis://localhost
3857
3694
  bullmq-dash queues list --redis-url redis://localhost:6380
3695
+ bullmq-dash queues list --redis-url redis://localhost --sort-by task-size
3696
+ bullmq-dash queues list --redis-url redis://localhost --sort-by failed
3858
3697
  bullmq-dash queues list --redis-url redis://localhost | jq '.queues[] | select(.counts.failed > 0)'
3859
3698
  `;
3860
3699
  var QUEUES_DELETE_HELP = `
@@ -3877,11 +3716,27 @@ Usage: bullmq-dash jobs <action> <queue> [options]
3877
3716
 
3878
3717
  Actions:
3879
3718
  list <queue> List jobs in a queue
3719
+ failed <queue> List failed jobs in a queue
3880
3720
  get <queue> <job-id> Get full detail for a single job
3881
- retry <queue> Bulk-retry failed jobs (supports --dry-run)
3721
+ retry <queue> Retry failed jobs (supports --dry-run/--yes)
3882
3722
 
3883
3723
  Run 'bullmq-dash jobs <action> --help' for action-specific help.
3884
3724
  `;
3725
+ var JOBS_FAILED_HELP = `
3726
+ Usage: bullmq-dash jobs failed <queue> [options]
3727
+
3728
+ List failed jobs in a queue. This is equivalent to:
3729
+ bullmq-dash jobs list <queue> --job-state failed
3730
+
3731
+ Options:
3732
+ --page-size <n> Max results to return (default: 1000)
3733
+ ${CONNECTION_OPTIONS_HELP}
3734
+
3735
+ Examples:
3736
+ bullmq-dash jobs failed email --redis-url redis://localhost
3737
+ bullmq-dash jobs failed email --redis-url redis://localhost | jq '.jobs[] | {id, name, timestamp}'
3738
+ bullmq-dash jobs retry email --redis-url redis://localhost --job-id 42 --dry-run
3739
+ `;
3885
3740
  var JOBS_LIST_HELP = `
3886
3741
  Usage: bullmq-dash jobs list <queue> [options]
3887
3742
 
@@ -3910,13 +3765,14 @@ Examples:
3910
3765
  bullmq-dash jobs get email 123 --redis-url redis://localhost | jq '.job.data'
3911
3766
  `;
3912
3767
  var JOBS_RETRY_HELP = `
3913
- Usage: bullmq-dash jobs retry <queue> --job-state failed [options]
3768
+ Usage: bullmq-dash jobs retry <queue> (--job-id <id> | --job-state failed) [options]
3914
3769
 
3915
- Bulk-retry failed jobs in a queue. Operates on failed jobs only in v1.
3770
+ Retry one failed job by ID, or bulk-retry failed jobs in a queue.
3916
3771
  Always use --dry-run first to see what would be retried.
3917
3772
 
3918
- Required:
3919
- --job-state failed Must be 'failed' (other states not supported in v1)
3773
+ Required, choose one:
3774
+ --job-id <id> Retry one failed job by ID
3775
+ --job-state failed Bulk-retry failed jobs (other states not supported)
3920
3776
 
3921
3777
  Filters:
3922
3778
  --since <duration> Only jobs that failed within this window.
@@ -3927,21 +3783,24 @@ Filters:
3927
3783
  Safety:
3928
3784
  --dry-run Show what WOULD be retried without enqueueing anything.
3929
3785
  Prints matched count and sample job IDs.
3786
+ --yes Skip confirmation prompt (required in non-interactive scripts).
3930
3787
 
3931
3788
  Exit codes:
3932
3789
  0 Success (dry-run complete, or all matched jobs retried). Includes empty-match.
3933
3790
  1 Runtime / fetch error (e.g. Redis connection failed)
3934
- 2 Config error (invalid flags, missing --job-state, --page-size > 10000)
3791
+ 2 Config error (invalid flags, missing --job-id/--job-state, --page-size > 10000)
3935
3792
  3 Partial failure \u2014 some jobs retried, some errored (see errors[])
3936
3793
 
3937
3794
  ${CONNECTION_OPTIONS_HELP}
3938
3795
 
3939
3796
  Examples:
3940
3797
  # Always start with a dry-run
3798
+ bullmq-dash jobs retry payments --redis-url redis://localhost --job-id 42 --dry-run
3941
3799
  bullmq-dash jobs retry payments --redis-url redis://localhost --job-state failed --since 1h --dry-run
3942
3800
 
3943
3801
  # Then retry for real
3944
- bullmq-dash jobs retry payments --redis-url redis://localhost --job-state failed --since 1h
3802
+ bullmq-dash jobs retry payments --redis-url redis://localhost --job-id 42 --yes
3803
+ bullmq-dash jobs retry payments --redis-url redis://localhost --job-state failed --since 1h --yes
3945
3804
 
3946
3805
  # Filter by job name
3947
3806
  bullmq-dash jobs retry email --redis-url redis://localhost --job-state failed --name welcome-email --dry-run
@@ -3983,7 +3842,7 @@ Examples:
3983
3842
  bullmq-dash schedulers get email my-cron --redis-url redis://localhost | jq '.scheduler.recentJobs'
3984
3843
  `;
3985
3844
  var RESOURCE_COMMANDS = new Set(["queues", "jobs", "schedulers"]);
3986
- var ACTIONS = new Set(["list", "get", "retry", "delete"]);
3845
+ var ACTIONS = new Set(["list", "failed", "get", "retry", "delete"]);
3987
3846
  function extractSubcommand(argv) {
3988
3847
  const firstFlagIndex = argv.findIndex((arg) => arg.startsWith("-"));
3989
3848
  if (firstFlagIndex === -1) {
@@ -4012,7 +3871,37 @@ function getRequiredArg(positionals, index, name, usage) {
4012
3871
  }
4013
3872
  return arg;
4014
3873
  }
4015
- function parseSubcommand(positionals, help, jobState, pageSize, since, name, dryRun, yes = false) {
3874
+ function parseQueueSortBy(rawValue) {
3875
+ if (rawValue === undefined)
3876
+ return;
3877
+ switch (rawValue) {
3878
+ case "size":
3879
+ case "total":
3880
+ case "task-size":
3881
+ return "task-size";
3882
+ case "wait":
3883
+ case "waiting":
3884
+ return "waiting";
3885
+ case "name":
3886
+ case "active":
3887
+ case "completed":
3888
+ case "failed":
3889
+ case "delayed":
3890
+ return rawValue;
3891
+ default:
3892
+ writeError(`Invalid --sort-by value: '${rawValue}'`, "CONFIG_ERROR", `Valid queue sort fields: ${QUEUE_SORT_FIELDS.join(", ")}. Aliases: size, total, wait.`);
3893
+ process.exit(2);
3894
+ }
3895
+ }
3896
+ function parseSortOrder(rawValue) {
3897
+ if (rawValue === undefined)
3898
+ return;
3899
+ if (rawValue === "asc" || rawValue === "desc")
3900
+ return rawValue;
3901
+ writeError(`Invalid --sort-order value: '${rawValue}'`, "CONFIG_ERROR", "Valid sort orders: asc, desc.");
3902
+ process.exit(2);
3903
+ }
3904
+ function parseSubcommand(positionals, help, jobState, pageSize, since, name, retryJobId, dryRun, yes = false, sortBy, sortOrder) {
4016
3905
  if (positionals.length === 0)
4017
3906
  return;
4018
3907
  const resource = positionals[0];
@@ -4044,7 +3933,13 @@ function parseSubcommand(positionals, help, jobState, pageSize, since, name, dry
4044
3933
  if (help)
4045
3934
  showSubcommandHelp(QUEUES_LIST_HELP);
4046
3935
  assertArgCount(positionals, 2, "queues list [options]");
4047
- return { kind: "queues-list" };
3936
+ const parsedSortBy = parseQueueSortBy(sortBy);
3937
+ const parsedSortOrder = parseSortOrder(sortOrder);
3938
+ return {
3939
+ kind: "queues-list",
3940
+ sortBy: parsedSortBy,
3941
+ sortOrder: parsedSortOrder ?? (parsedSortBy ? defaultSortOrder(parsedSortBy) : undefined)
3942
+ };
4048
3943
  }
4049
3944
  if (action === "delete") {
4050
3945
  if (help)
@@ -4066,6 +3961,18 @@ function parseSubcommand(positionals, help, jobState, pageSize, since, name, dry
4066
3961
  assertArgCount(positionals, 3, usage);
4067
3962
  return { kind: "jobs-list", queue, jobState, pageSize };
4068
3963
  }
3964
+ if (action === "failed") {
3965
+ if (help)
3966
+ showSubcommandHelp(JOBS_FAILED_HELP);
3967
+ const usage = "jobs failed <queue> [--page-size <n>]";
3968
+ const queue = getRequiredArg(positionals, 2, "queue", usage);
3969
+ assertArgCount(positionals, 3, usage);
3970
+ if (jobState && jobState !== "failed") {
3971
+ writeError("--job-state cannot override 'jobs failed'", "CONFIG_ERROR", "Use 'jobs list <queue> --job-state <state>' for other states.");
3972
+ process.exit(2);
3973
+ }
3974
+ return { kind: "jobs-list", queue, jobState: "failed", pageSize };
3975
+ }
4069
3976
  if (action === "get") {
4070
3977
  if (help)
4071
3978
  showSubcommandHelp(JOBS_GET_HELP);
@@ -4078,20 +3985,29 @@ function parseSubcommand(positionals, help, jobState, pageSize, since, name, dry
4078
3985
  if (action === "retry") {
4079
3986
  if (help)
4080
3987
  showSubcommandHelp(JOBS_RETRY_HELP);
4081
- const usage = "jobs retry <queue> --job-state failed [--since <duration>] [--name <pattern>] [--dry-run]";
3988
+ const usage = "jobs retry <queue> (--job-id <id> | --job-state failed) [--since <duration>] [--name <pattern>] [--dry-run|--yes]";
4082
3989
  const queue = getRequiredArg(positionals, 2, "queue", usage);
4083
3990
  assertArgCount(positionals, 3, usage);
4084
- if (!jobState) {
4085
- writeError("--job-state is required for 'jobs retry'", "CONFIG_ERROR", "Use --job-state failed. Other states are not currently supported.");
3991
+ if (!jobState && !retryJobId) {
3992
+ writeError("--job-id or --job-state is required for 'jobs retry'", "CONFIG_ERROR", "Use --job-id <id> for one failed job or --job-state failed for a filtered batch.");
4086
3993
  process.exit(2);
4087
3994
  }
4088
- if (jobState !== "failed") {
3995
+ if (jobState && jobState !== "failed") {
4089
3996
  writeError(`Unsupported --job-state '${jobState}' for 'jobs retry'`, "CONFIG_ERROR", "Only --job-state failed is currently supported.");
4090
3997
  process.exit(2);
4091
3998
  }
4092
- return { kind: "jobs-retry", queue, jobState, since, name, pageSize, dryRun };
3999
+ return {
4000
+ kind: "jobs-retry",
4001
+ queue,
4002
+ jobState: "failed",
4003
+ jobId: retryJobId,
4004
+ since,
4005
+ name,
4006
+ pageSize,
4007
+ dryRun
4008
+ };
4093
4009
  }
4094
- writeError(`Invalid action '${action}' for jobs`, "CONFIG_ERROR", "Available actions: list, get, retry. Use --help for usage.");
4010
+ writeError(`Invalid action '${action}' for jobs`, "CONFIG_ERROR", "Available actions: list, failed, get, retry. Use --help for usage.");
4095
4011
  process.exit(2);
4096
4012
  }
4097
4013
  case "schedulers": {
@@ -4149,8 +4065,11 @@ function parseCliArgs() {
4149
4065
  "job-state": { type: "string" },
4150
4066
  "page-size": { type: "string" },
4151
4067
  "human-friendly": { type: "boolean" },
4068
+ "sort-by": { type: "string" },
4069
+ "sort-order": { type: "string" },
4152
4070
  since: { type: "string" },
4153
4071
  name: { type: "string" },
4072
+ "job-id": { type: "string" },
4154
4073
  "dry-run": { type: "boolean" },
4155
4074
  yes: { type: "boolean" },
4156
4075
  profile: { type: "string" },
@@ -4177,7 +4096,15 @@ function parseCliArgs() {
4177
4096
  writeError(`Invalid --since value '${since}'`, "CONFIG_ERROR", "Expected format: 30s, 5m, 1h, 24h, 7d. Must be a positive integer followed by s/m/h/d.");
4178
4097
  process.exit(2);
4179
4098
  }
4180
- const subcommand = parseSubcommand(positionals, !!values.help, values["job-state"], pageSize, since, nameFilter, dryRun, yes ?? false);
4099
+ const subcommand = parseSubcommand(positionals, !!values.help, values["job-state"], pageSize, since, nameFilter, values["job-id"], dryRun, yes ?? false, values["sort-by"], values["sort-order"]);
4100
+ if (values["sort-by"] && (!subcommand || subcommand.kind !== "queues-list")) {
4101
+ writeError("--sort-by can only be used with 'queues list'", "CONFIG_ERROR", "Usage: queues list --sort-by task-size");
4102
+ process.exit(2);
4103
+ }
4104
+ if (values["sort-order"] && (!subcommand || subcommand.kind !== "queues-list")) {
4105
+ writeError("--sort-order can only be used with 'queues list'", "CONFIG_ERROR", "Usage: queues list --sort-by task-size --sort-order desc");
4106
+ process.exit(2);
4107
+ }
4181
4108
  if (subcommand?.kind === "jobs-retry" && pageSize !== undefined && pageSize > MAX_RETRY_PAGE_SIZE) {
4182
4109
  writeError(`--page-size exceeds ${MAX_RETRY_PAGE_SIZE}`, "CONFIG_ERROR", `--page-size is capped at ${MAX_RETRY_PAGE_SIZE}. For larger batches, run multiple passes with narrower filters (--since, --name).`);
4183
4110
  process.exit(2);
@@ -4198,6 +4125,10 @@ function parseCliArgs() {
4198
4125
  writeError("--name can only be used with 'jobs retry'", "CONFIG_ERROR", "Usage: jobs retry <queue> --job-state failed --name <pattern>");
4199
4126
  process.exit(2);
4200
4127
  }
4128
+ if (values["job-id"] && (!subcommand || subcommand.kind !== "jobs-retry")) {
4129
+ writeError("--job-id can only be used with 'jobs retry'", "CONFIG_ERROR", "Usage: jobs retry <queue> --job-id <id> --dry-run");
4130
+ process.exit(2);
4131
+ }
4201
4132
  if (humanFriendly && !subcommand) {
4202
4133
  writeError("--human-friendly can only be used with subcommands", "CONFIG_ERROR", "Usage: bullmq-dash <command> --human-friendly. Use --help for usage.");
4203
4134
  process.exit(2);
@@ -4210,12 +4141,12 @@ function parseCliArgs() {
4210
4141
  writeError("--dry-run can only be used with 'jobs retry' or 'queues delete'", "CONFIG_ERROR", "Usage: jobs retry <queue> --job-state failed --dry-run or queues delete <queue> --dry-run");
4211
4142
  process.exit(2);
4212
4143
  }
4213
- if (yes && (!subcommand || subcommand.kind !== "queues-delete")) {
4214
- writeError("--yes can only be used with 'queues delete'", "CONFIG_ERROR", "Usage: queues delete <queue> --yes");
4144
+ if (yes && (!subcommand || subcommand.kind !== "queues-delete" && subcommand.kind !== "jobs-retry")) {
4145
+ writeError("--yes can only be used with 'queues delete' or 'jobs retry'", "CONFIG_ERROR", "Usage: queues delete <queue> --yes or jobs retry <queue> --job-state failed --yes");
4215
4146
  process.exit(2);
4216
4147
  }
4217
4148
  if (dryRun && yes) {
4218
- writeError("--dry-run and --yes cannot be used together", "CONFIG_ERROR", "Usage: queues delete <queue> [--dry-run] or queues delete <queue> --yes");
4149
+ writeError("--dry-run and --yes cannot be used together", "CONFIG_ERROR", "Usage: queues delete <queue> [--dry-run|--yes] or jobs retry <queue> [--dry-run|--yes]");
4219
4150
  process.exit(2);
4220
4151
  }
4221
4152
  if (humanFriendly && values.tui) {
@@ -4271,6 +4202,141 @@ function shouldLoadProfile(cliArgs) {
4271
4202
  return !cliArgs.redisUrl || !!cliArgs.profile || !!cliArgs.configPath;
4272
4203
  }
4273
4204
 
4205
+ // src/config.ts
4206
+ var DEFAULT_CACHE_TTL_MS = 24 * 60 * 60 * 1000;
4207
+ function coercePositiveInt(value, fallback) {
4208
+ if (value === undefined)
4209
+ return fallback;
4210
+ const numberValue = Number(value);
4211
+ if (!Number.isInteger(numberValue) || numberValue <= 0)
4212
+ return null;
4213
+ return numberValue;
4214
+ }
4215
+ function coerceNonNegativeInt(value, fallback) {
4216
+ if (value === undefined)
4217
+ return fallback;
4218
+ const numberValue = Number(value);
4219
+ if (!Number.isInteger(numberValue) || numberValue < 0)
4220
+ return null;
4221
+ return numberValue;
4222
+ }
4223
+ function coerceString(value, fallback) {
4224
+ if (value === undefined)
4225
+ return fallback;
4226
+ return typeof value === "string" ? value : null;
4227
+ }
4228
+ function coerceOptionalString(value) {
4229
+ if (value === undefined)
4230
+ return;
4231
+ return typeof value === "string" ? value : null;
4232
+ }
4233
+ var CONFIG_ALLOWED_KEYS = ["redis", "pollInterval", "prefix", "queueNames", "cacheTtlMs"];
4234
+ var CONFIG_REDIS_ALLOWED_KEYS = ["host", "port", "username", "password", "db", "tls"];
4235
+ function validateConfig(raw) {
4236
+ const errors = [];
4237
+ for (const key of Object.keys(raw)) {
4238
+ if (!CONFIG_ALLOWED_KEYS.includes(key)) {
4239
+ errors.push(`unknown key '${key}' in config (allowed: ${CONFIG_ALLOWED_KEYS.join(", ")})`);
4240
+ }
4241
+ }
4242
+ for (const key of Object.keys(raw.redis)) {
4243
+ if (!CONFIG_REDIS_ALLOWED_KEYS.includes(key)) {
4244
+ errors.push(`unknown key '${key}' in config.redis (allowed: ${CONFIG_REDIS_ALLOWED_KEYS.join(", ")})`);
4245
+ }
4246
+ }
4247
+ const redis = raw.redis;
4248
+ const host = coerceString(redis.host, "localhost");
4249
+ if (host === null)
4250
+ errors.push("redis.host must be a string");
4251
+ const port = coercePositiveInt(redis.port, 6379);
4252
+ if (port === null)
4253
+ errors.push("redis.port must be a positive integer");
4254
+ const username = coerceOptionalString(redis.username);
4255
+ if (username === null)
4256
+ errors.push("redis.username must be a string");
4257
+ const password = coerceOptionalString(redis.password);
4258
+ if (password === null)
4259
+ errors.push("redis.password must be a string");
4260
+ const db = coerceNonNegativeInt(redis.db, 0);
4261
+ if (db === null)
4262
+ errors.push("redis.db must be a non-negative integer");
4263
+ const tls = redis.tls;
4264
+ if (tls !== undefined && typeof tls !== "boolean") {
4265
+ errors.push("redis.tls must be a boolean");
4266
+ }
4267
+ const pollInterval = coercePositiveInt(raw.pollInterval, 3000);
4268
+ if (pollInterval === null)
4269
+ errors.push("pollInterval must be a positive integer");
4270
+ const prefix = coerceString(raw.prefix, "bull");
4271
+ if (prefix === null)
4272
+ errors.push("prefix must be a string");
4273
+ const queueNames = raw.queueNames;
4274
+ if (queueNames !== undefined && (!Array.isArray(queueNames) || queueNames.some((name) => typeof name !== "string"))) {
4275
+ errors.push("queueNames must be an array of strings");
4276
+ }
4277
+ const cacheTtlMs = coercePositiveInt(raw.cacheTtlMs, DEFAULT_CACHE_TTL_MS);
4278
+ if (cacheTtlMs === null)
4279
+ errors.push("cacheTtlMs must be a positive integer");
4280
+ if (errors.length > 0) {
4281
+ return { success: false, errors };
4282
+ }
4283
+ const config = {
4284
+ redis: {
4285
+ host,
4286
+ port,
4287
+ db
4288
+ },
4289
+ pollInterval,
4290
+ prefix,
4291
+ cacheTtlMs
4292
+ };
4293
+ if (username !== undefined && username !== null)
4294
+ config.redis.username = username;
4295
+ if (password !== undefined && password !== null)
4296
+ config.redis.password = password;
4297
+ if (tls !== undefined)
4298
+ config.redis.tls = tls;
4299
+ if (queueNames !== undefined)
4300
+ config.queueNames = queueNames;
4301
+ return { success: true, data: config };
4302
+ }
4303
+ function loadConfig(cliArgs, profile) {
4304
+ const p = profile?.profile;
4305
+ const url = cliArgs.redisUrl ?? p?.redis?.url;
4306
+ let parts;
4307
+ if (url) {
4308
+ try {
4309
+ parts = parseRedisUrl(url);
4310
+ } catch (error) {
4311
+ writeError(cliArgs.redisUrl ? "Invalid --redis-url" : `Invalid redis.url in profile '${profile?.name}'`, "CONFIG_ERROR", error instanceof Error ? error.message : String(error));
4312
+ process.exit(2);
4313
+ }
4314
+ }
4315
+ const raw = {
4316
+ redis: {
4317
+ host: parts?.host,
4318
+ port: parts?.port,
4319
+ username: parts?.username,
4320
+ password: parts?.password,
4321
+ db: parts?.db,
4322
+ tls: parts?.tls
4323
+ },
4324
+ pollInterval: cliArgs.pollInterval ?? p?.pollInterval,
4325
+ prefix: cliArgs.prefix ?? p?.prefix,
4326
+ queueNames: cliArgs.queues ?? p?.queues,
4327
+ cacheTtlMs: p?.cacheTtlMs
4328
+ };
4329
+ const result = validateConfig(raw);
4330
+ if (!result.success) {
4331
+ writeError("Configuration error", "CONFIG_ERROR", result.errors.join("; "));
4332
+ process.exit(2);
4333
+ }
4334
+ return result.data;
4335
+ }
4336
+ function createConfigFromPrompt(redisUrl, cliArgs) {
4337
+ return loadConfig({ ...cliArgs, redisUrl });
4338
+ }
4339
+
4274
4340
  // src/ui/config-prompt.ts
4275
4341
  import * as readline from "readline";
4276
4342
  var cyan = (text) => `\x1B[36m${text}\x1B[0m`;
@@ -4548,6 +4614,8 @@ var MAX_DISPLAYED_ERRORS = 10;
4548
4614
  function formatJobsRetry(r) {
4549
4615
  const lines = [];
4550
4616
  const filterParts = [`state=${r.filter.jobState}`];
4617
+ if (r.filter.jobId)
4618
+ filterParts.push(`jobId=${r.filter.jobId}`);
4551
4619
  if (r.filter.since)
4552
4620
  filterParts.push(`since=${r.filter.since}`);
4553
4621
  if (r.filter.name)
@@ -4586,7 +4654,7 @@ function formatJobsRetry(r) {
4586
4654
  }
4587
4655
  if (r.dryRun) {
4588
4656
  lines.push("");
4589
- lines.push("Run without --dry-run to retry these jobs.");
4657
+ lines.push("Run with --yes and without --dry-run to retry these jobs from scripts.");
4590
4658
  }
4591
4659
  return lines.join(`
4592
4660
  `);
@@ -4600,10 +4668,28 @@ function createResponse(data) {
4600
4668
  ...data
4601
4669
  };
4602
4670
  }
4603
- async function fetchQueuesOverview() {
4604
- const queueNames = await discoverQueueNames();
4605
- const queues = await Promise.all(queueNames.map((name) => getQueueStats(name)));
4606
- const jobCounts = queues.reduce((acc, q) => ({
4671
+ function omitObservationMetadata(item) {
4672
+ const copy = { ...item };
4673
+ delete copy.lastObservedAt;
4674
+ return copy;
4675
+ }
4676
+ function publicJobSummary(job) {
4677
+ return {
4678
+ id: job.id,
4679
+ name: job.name,
4680
+ state: job.state,
4681
+ timestamp: job.timestamp
4682
+ };
4683
+ }
4684
+ async function fetchQueuesOverview(ctx, sortBy = "name", sortOrder = defaultSortOrder(sortBy)) {
4685
+ const queueNames = await discoverQueueNames(ctx);
4686
+ const queues = await Promise.all(queueNames.map((name) => getQueueStats(ctx, name)));
4687
+ const sortedQueues = sortQueues(queues, sortBy, sortOrder);
4688
+ const observedAt = Date.now();
4689
+ try {
4690
+ recordObservedQueues(ctx, sortedQueues, { observedAt });
4691
+ } catch {}
4692
+ const jobCounts = sortedQueues.reduce((acc, q) => ({
4607
4693
  wait: acc.wait + q.counts.wait,
4608
4694
  active: acc.active + q.counts.active,
4609
4695
  completed: acc.completed + q.counts.completed,
@@ -4612,15 +4698,15 @@ async function fetchQueuesOverview() {
4612
4698
  total: acc.total + q.total
4613
4699
  }), { wait: 0, active: 0, completed: 0, failed: 0, delayed: 0, total: 0 });
4614
4700
  return createResponse({
4615
- queues,
4701
+ queues: sortedQueues.map(omitObservationMetadata),
4616
4702
  metrics: {
4617
- queueCount: queues.length,
4703
+ queueCount: sortedQueues.length,
4618
4704
  jobCounts
4619
4705
  }
4620
4706
  });
4621
4707
  }
4622
- async function fetchQueuesDelete(queueName, dryRun) {
4623
- const result = await deleteQueue(queueName, dryRun);
4708
+ async function fetchQueuesDelete(ctx, queueName, dryRun) {
4709
+ const result = await deleteQueue(ctx, queueName, dryRun);
4624
4710
  return createResponse({
4625
4711
  queue: queueName,
4626
4712
  deleted: !dryRun,
@@ -4629,22 +4715,16 @@ async function fetchQueuesDelete(queueName, dryRun) {
4629
4715
  totalJobs: result.counts.wait + result.counts.active + result.counts.completed + result.counts.failed + result.counts.delayed
4630
4716
  });
4631
4717
  }
4632
- async function fetchJobsList(queueName, jobState, maxResults) {
4633
- const { jobs, total } = await getAllJobs(queueName, jobState, maxResults, true);
4718
+ async function fetchJobsList(ctx, queueName, jobState, maxResults) {
4719
+ const { jobs, total } = await getAllJobs(ctx, queueName, jobState, maxResults, true);
4720
+ const observedAt = Date.now();
4634
4721
  try {
4635
- upsertJobs(queueName, jobs.map((j) => ({
4636
- id: j.id,
4637
- name: j.name,
4638
- state: j.state,
4639
- timestamp: j.timestamp,
4640
- data: j.data
4641
- })));
4642
- markPolledWrites(queueName, jobs.map((j) => j.id));
4722
+ recordObservedJobs(ctx, queueName, jobs, { observedAt });
4643
4723
  } catch {}
4644
4724
  return createResponse({
4645
4725
  queue: queueName,
4646
4726
  jobState: jobState ?? "all",
4647
- jobs,
4727
+ jobs: jobs.map(publicJobSummary),
4648
4728
  total
4649
4729
  });
4650
4730
  }
@@ -4653,9 +4733,11 @@ function computeRetryExitCode(result) {
4653
4733
  return 3;
4654
4734
  return 0;
4655
4735
  }
4656
- async function fetchJobsRetry(queueName, jobState, since, name, pageSize, dryRun) {
4657
- const result = await retryFailedJobs(queueName, { since, name, pageSize, dryRun });
4736
+ async function fetchJobsRetry(ctx, queueName, jobState, jobId, since, name, pageSize, dryRun) {
4737
+ const result = await retryFailedJobs(ctx, queueName, { jobId, since, name, pageSize, dryRun });
4658
4738
  const filter = { jobState };
4739
+ if (jobId !== undefined)
4740
+ filter.jobId = jobId;
4659
4741
  if (since !== undefined)
4660
4742
  filter.since = since;
4661
4743
  if (name !== undefined)
@@ -4673,40 +4755,52 @@ async function fetchJobsRetry(queueName, jobState, since, name, pageSize, dryRun
4673
4755
  truncated: result.truncated
4674
4756
  });
4675
4757
  }
4676
- async function fetchJobDetail(queueName, jobId) {
4677
- const job = await getJobDetail(queueName, jobId);
4758
+ async function fetchJobDetail(ctx, queueName, jobId) {
4759
+ const job = await getJobDetail(ctx, queueName, jobId);
4678
4760
  if (!job) {
4679
4761
  writeError(`Job '${jobId}' not found in queue '${queueName}'`, "RUNTIME_ERROR");
4680
4762
  try {
4681
- await cleanup();
4763
+ await closeContext(ctx);
4682
4764
  } catch {}
4683
4765
  process.exit(1);
4684
4766
  }
4767
+ const observedAt = Date.now();
4768
+ try {
4769
+ recordObservedJobs(ctx, queueName, [job], { observedAt });
4770
+ } catch {}
4685
4771
  return createResponse({
4686
4772
  queue: queueName,
4687
- job
4773
+ job: omitObservationMetadata(job)
4688
4774
  });
4689
4775
  }
4690
- async function fetchSchedulersList(queueName, maxResults) {
4691
- const { schedulers, total } = await getAllJobSchedulers(queueName, maxResults);
4776
+ async function fetchSchedulersList(ctx, queueName, maxResults) {
4777
+ const { schedulers, total } = await getAllJobSchedulers(ctx, queueName, maxResults);
4778
+ const observedAt = Date.now();
4779
+ try {
4780
+ recordObservedSchedulers(ctx, queueName, schedulers, { observedAt });
4781
+ } catch {}
4692
4782
  return createResponse({
4693
4783
  queue: queueName,
4694
- schedulers,
4784
+ schedulers: schedulers.map(omitObservationMetadata),
4695
4785
  total
4696
4786
  });
4697
4787
  }
4698
- async function fetchSchedulerDetail(queueName, schedulerKey) {
4699
- const scheduler = await getJobSchedulerDetail(queueName, schedulerKey);
4788
+ async function fetchSchedulerDetail(ctx, queueName, schedulerKey) {
4789
+ const scheduler = await getJobSchedulerDetail(ctx, queueName, schedulerKey);
4700
4790
  if (!scheduler) {
4701
4791
  writeError(`Scheduler '${schedulerKey}' not found in queue '${queueName}'`, "RUNTIME_ERROR");
4702
4792
  try {
4703
- await cleanup();
4793
+ await closeContext(ctx);
4704
4794
  } catch {}
4705
4795
  process.exit(1);
4706
4796
  }
4797
+ const observedAt = Date.now();
4798
+ try {
4799
+ recordObservedSchedulers(ctx, queueName, [scheduler], { observedAt });
4800
+ } catch {}
4707
4801
  return createResponse({
4708
4802
  queue: queueName,
4709
- scheduler
4803
+ scheduler: omitObservationMetadata(scheduler)
4710
4804
  });
4711
4805
  }
4712
4806
  function validateJobState(jobState) {
@@ -4730,33 +4824,40 @@ function promptConfirmation(message) {
4730
4824
  });
4731
4825
  });
4732
4826
  }
4733
- async function cleanup() {
4734
- await closeAllQueues();
4735
- await disconnectRedis();
4736
- closeSqliteDb();
4827
+ function jobsRetryConfirmationMessage(subcommand) {
4828
+ if (subcommand.jobId) {
4829
+ return `Retry failed job '${subcommand.jobId}' in queue '${subcommand.queue}'?`;
4830
+ }
4831
+ const filters = [];
4832
+ if (subcommand.since)
4833
+ filters.push(`since=${subcommand.since}`);
4834
+ if (subcommand.name)
4835
+ filters.push(`name=${subcommand.name}`);
4836
+ const suffix = filters.length > 0 ? ` matching ${filters.join(", ")}` : "";
4837
+ return `Retry failed jobs in queue '${subcommand.queue}'${suffix}?`;
4737
4838
  }
4738
- async function routeAndFetch(subcommand) {
4839
+ async function routeAndFetch(ctx, subcommand) {
4739
4840
  switch (subcommand.kind) {
4740
4841
  case "queues-list":
4741
- return fetchQueuesOverview();
4842
+ return fetchQueuesOverview(ctx, subcommand.sortBy, subcommand.sortOrder);
4742
4843
  case "queues-delete":
4743
- return fetchQueuesDelete(subcommand.queue, subcommand.dryRun ?? false);
4844
+ return fetchQueuesDelete(ctx, subcommand.queue, subcommand.dryRun ?? false);
4744
4845
  case "jobs-list": {
4745
4846
  const validState = validateJobState(subcommand.jobState);
4746
- return fetchJobsList(subcommand.queue, validState, subcommand.pageSize);
4847
+ return fetchJobsList(ctx, subcommand.queue, validState, subcommand.pageSize);
4747
4848
  }
4748
4849
  case "jobs-get":
4749
- return fetchJobDetail(subcommand.queue, subcommand.jobId);
4850
+ return fetchJobDetail(ctx, subcommand.queue, subcommand.jobId);
4750
4851
  case "jobs-retry": {
4751
4852
  const validState = validateJobState(subcommand.jobState);
4752
4853
  if (!validState)
4753
4854
  throw new Error("jobs retry requires --job-state");
4754
- return fetchJobsRetry(subcommand.queue, validState, subcommand.since, subcommand.name, subcommand.pageSize, subcommand.dryRun);
4855
+ return fetchJobsRetry(ctx, subcommand.queue, validState, subcommand.jobId, subcommand.since, subcommand.name, subcommand.pageSize, subcommand.dryRun);
4755
4856
  }
4756
4857
  case "schedulers-list":
4757
- return fetchSchedulersList(subcommand.queue, subcommand.pageSize);
4858
+ return fetchSchedulersList(ctx, subcommand.queue, subcommand.pageSize);
4758
4859
  case "schedulers-get":
4759
- return fetchSchedulerDetail(subcommand.queue, subcommand.schedulerId);
4860
+ return fetchSchedulerDetail(ctx, subcommand.queue, subcommand.schedulerId);
4760
4861
  default: {
4761
4862
  const _exhaustive = subcommand;
4762
4863
  throw new Error(`Unhandled subcommand: ${_exhaustive.kind}`);
@@ -4788,15 +4889,14 @@ function formatOutput(result, subcommand, humanFriendly) {
4788
4889
  }
4789
4890
  }
4790
4891
  }
4791
- async function runJsonMode(config, subcommand, humanFriendly = false, dryRun = false, yes = false) {
4792
- setConfig(config);
4793
- if (subcommand.kind === "queues-delete" && !yes && !dryRun) {
4892
+ async function runJsonMode(ctx, subcommand, humanFriendly = false, yes = false) {
4893
+ if (subcommand.kind === "queues-delete" && !yes && !(subcommand.dryRun ?? false)) {
4794
4894
  if (process.stdin.isTTY) {
4795
4895
  const confirmed = await promptConfirmation(`Delete queue '${subcommand.queue}' and all its jobs? This cannot be undone.`);
4796
4896
  if (!confirmed) {
4797
4897
  process.stderr.write(`Cancelled.
4798
4898
  `);
4799
- await cleanup();
4899
+ await closeContext(ctx);
4800
4900
  process.exit(1);
4801
4901
  }
4802
4902
  } else {
@@ -4804,16 +4904,33 @@ async function runJsonMode(config, subcommand, humanFriendly = false, dryRun = f
4804
4904
  process.exit(2);
4805
4905
  }
4806
4906
  }
4907
+ if (subcommand.kind === "jobs-retry" && !yes && !subcommand.dryRun) {
4908
+ if (process.stdin.isTTY) {
4909
+ const confirmed = await promptConfirmation(jobsRetryConfirmationMessage(subcommand));
4910
+ if (!confirmed) {
4911
+ process.stderr.write(`Cancelled.
4912
+ `);
4913
+ await closeContext(ctx);
4914
+ process.exit(1);
4915
+ }
4916
+ } else {
4917
+ writeError("Confirmation required: run with --yes flag in non-interactive mode", "CONFIG_ERROR", "Use --yes to retry failed jobs in scripts, or run in interactive terminal.");
4918
+ process.exit(2);
4919
+ }
4920
+ }
4807
4921
  try {
4808
- await connectRedis();
4922
+ await ctx.redis.connect();
4809
4923
  } catch (error) {
4810
4924
  writeError("Redis connection failed", "REDIS_ERROR", error instanceof Error ? error.message : String(error));
4925
+ try {
4926
+ await closeContext(ctx);
4927
+ } catch {}
4811
4928
  process.exit(1);
4812
4929
  }
4813
- createSqliteDb();
4814
4930
  let exitCode = 0;
4815
4931
  try {
4816
- const result = await routeAndFetch(subcommand);
4932
+ const result = await routeAndFetch(ctx, subcommand);
4933
+ runQueueStoreCleanupIfDue(ctx);
4817
4934
  const output = formatOutput(result, subcommand, humanFriendly);
4818
4935
  process.stdout.write(output + `
4819
4936
  `);
@@ -4823,11 +4940,11 @@ async function runJsonMode(config, subcommand, humanFriendly = false, dryRun = f
4823
4940
  } catch (error) {
4824
4941
  writeError("Failed to fetch data", "RUNTIME_ERROR", error instanceof Error ? error.message : String(error));
4825
4942
  try {
4826
- await cleanup();
4943
+ await closeContext(ctx);
4827
4944
  } catch {}
4828
4945
  process.exit(1);
4829
4946
  }
4830
- await cleanup();
4947
+ await closeContext(ctx);
4831
4948
  process.exit(exitCode);
4832
4949
  }
4833
4950
 
@@ -4852,7 +4969,8 @@ async function main() {
4852
4969
  process.exit(2);
4853
4970
  }
4854
4971
  const config = loadConfig(cliArgs, profile);
4855
- await runJsonMode(config, cliArgs.subcommand, cliArgs.humanFriendly, cliArgs.dryRun, cliArgs.yes);
4972
+ const ctx = createContext(config);
4973
+ await runJsonMode(ctx, cliArgs.subcommand, cliArgs.humanFriendly, cliArgs.yes);
4856
4974
  return;
4857
4975
  }
4858
4976
  if (cliArgs.tui) {
@@ -4865,9 +4983,9 @@ async function main() {
4865
4983
  const url = await runConfigPrompt();
4866
4984
  config = createConfigFromPrompt(url, cliArgs);
4867
4985
  }
4868
- setConfig(config);
4869
4986
  try {
4870
- const app = new App;
4987
+ const ctx = createContext(config);
4988
+ const app = new App(ctx);
4871
4989
  await app.start();
4872
4990
  } catch (error) {
4873
4991
  writeError("Failed to start application", "RUNTIME_ERROR", error instanceof Error ? error.message : String(error));