workmatic 1.0.7 → 1.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.cjs CHANGED
@@ -23,13 +23,752 @@ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__ge
23
23
  mod
24
24
  ));
25
25
 
26
- // src/cli.ts
27
- var import_better_sqlite3 = __toESM(require("better-sqlite3"), 1);
26
+ // src/cli/handlers.ts
27
+ var import_better_sqlite32 = __toESM(require("better-sqlite3"), 1);
28
28
  var import_fs = require("fs");
29
+ var import_promises = require("stream/promises");
29
30
  var import_readline = require("readline");
30
31
  var import_cli_table3 = __toESM(require("cli-table3"), 1);
31
- var args = process.argv.slice(2);
32
- var command = args[0];
32
+
33
+ // src/database.ts
34
+ var import_better_sqlite3 = __toESM(require("better-sqlite3"), 1);
35
+ var import_kysely = require("kysely");
36
+ var kyselyToSqlite = /* @__PURE__ */ new WeakMap();
37
+ function createDatabase(options = {}) {
38
+ let sqliteDb;
39
+ if (options.db) {
40
+ sqliteDb = options.db;
41
+ } else {
42
+ const filename = options.filename ?? ":memory:";
43
+ sqliteDb = new import_better_sqlite3.default(filename);
44
+ }
45
+ sqliteDb.pragma("journal_mode = WAL");
46
+ sqliteDb.pragma("synchronous = NORMAL");
47
+ sqliteDb.pragma("busy_timeout = 5000");
48
+ const db = new import_kysely.Kysely({
49
+ dialect: new import_kysely.SqliteDialect({
50
+ database: sqliteDb
51
+ })
52
+ });
53
+ createSchema(sqliteDb);
54
+ kyselyToSqlite.set(db, sqliteDb);
55
+ return db;
56
+ }
57
+ function createSchema(db) {
58
+ db.exec(`
59
+ CREATE TABLE IF NOT EXISTS workmatic_jobs (
60
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
61
+ public_id TEXT UNIQUE NOT NULL,
62
+ queue TEXT NOT NULL,
63
+ payload TEXT NOT NULL,
64
+ status TEXT NOT NULL DEFAULT 'ready',
65
+ priority INTEGER NOT NULL DEFAULT 0,
66
+ run_at INTEGER NOT NULL,
67
+ attempts INTEGER NOT NULL DEFAULT 0,
68
+ max_attempts INTEGER NOT NULL DEFAULT 3,
69
+ lease_until INTEGER NOT NULL DEFAULT 0,
70
+ created_at INTEGER NOT NULL,
71
+ updated_at INTEGER NOT NULL,
72
+ last_error TEXT
73
+ )
74
+ `);
75
+ db.exec(`
76
+ CREATE INDEX IF NOT EXISTS idx_workmatic_jobs_claim
77
+ ON workmatic_jobs (queue, status, run_at, priority, id)
78
+ `);
79
+ db.exec(`
80
+ CREATE INDEX IF NOT EXISTS idx_workmatic_jobs_lease
81
+ ON workmatic_jobs (status, lease_until)
82
+ `);
83
+ db.exec(`
84
+ CREATE TABLE IF NOT EXISTS workmatic_settings (
85
+ queue TEXT PRIMARY KEY,
86
+ paused INTEGER NOT NULL DEFAULT 0,
87
+ updated_at INTEGER NOT NULL
88
+ )
89
+ `);
90
+ db.exec(`
91
+ UPDATE workmatic_jobs SET status = 'dead' WHERE status = 'failed'
92
+ `);
93
+ }
94
+
95
+ // src/orchestrator.ts
96
+ var import_kysely4 = require("kysely");
97
+
98
+ // src/client.ts
99
+ var import_nanoid = require("nanoid");
100
+ var import_kysely2 = require("kysely");
101
+
102
+ // src/utils.ts
103
+ var defaultBackoff = (attempts) => {
104
+ return 1e3 * Math.pow(2, attempts);
105
+ };
106
+ function validatePayload(payload) {
107
+ try {
108
+ return JSON.stringify(payload);
109
+ } catch (error) {
110
+ throw new Error(
111
+ `Payload is not JSON-serializable: ${error instanceof Error ? error.message : "Unknown error"}`
112
+ );
113
+ }
114
+ }
115
+ function parsePayload(json) {
116
+ try {
117
+ return JSON.parse(json);
118
+ } catch (error) {
119
+ throw new Error(
120
+ `Invalid JSON payload: ${error instanceof Error ? error.message : "Unknown error"}`
121
+ );
122
+ }
123
+ }
124
+ function now() {
125
+ return Date.now();
126
+ }
127
+
128
+ // src/client.ts
129
+ function createClient(options) {
130
+ const { db, queue = "default" } = options;
131
+ if (!db) {
132
+ throw new Error("Database instance is required");
133
+ }
134
+ return {
135
+ /**
136
+ * Add a job to the queue
137
+ */
138
+ async add(payload, opts = {}) {
139
+ const {
140
+ priority = 0,
141
+ delayMs = 0,
142
+ maxAttempts = 3
143
+ } = opts;
144
+ const payloadJson = validatePayload(payload);
145
+ const publicId = (0, import_nanoid.nanoid)();
146
+ const timestamp = now();
147
+ const runAt = timestamp + delayMs;
148
+ await db.insertInto("workmatic_jobs").values({
149
+ public_id: publicId,
150
+ queue,
151
+ payload: payloadJson,
152
+ status: "ready",
153
+ priority,
154
+ run_at: runAt,
155
+ attempts: 0,
156
+ max_attempts: maxAttempts,
157
+ lease_until: 0,
158
+ created_at: timestamp,
159
+ updated_at: timestamp,
160
+ last_error: null
161
+ }).execute();
162
+ return { ok: true, id: publicId };
163
+ },
164
+ async addMany(payloads, opts = {}) {
165
+ const {
166
+ priority = 0,
167
+ delayMs = 0,
168
+ maxAttempts = 3
169
+ } = opts;
170
+ if (payloads.length === 0) {
171
+ return { ok: true, ids: [] };
172
+ }
173
+ const timestamp = now();
174
+ const runAt = timestamp + delayMs;
175
+ return await db.transaction().execute(async (trx) => {
176
+ const ids = [];
177
+ const rows = payloads.map((payload) => {
178
+ const payloadJson = validatePayload(payload);
179
+ const publicId = (0, import_nanoid.nanoid)();
180
+ ids.push(publicId);
181
+ return {
182
+ public_id: publicId,
183
+ queue,
184
+ payload: payloadJson,
185
+ status: "ready",
186
+ priority,
187
+ run_at: runAt,
188
+ attempts: 0,
189
+ max_attempts: maxAttempts,
190
+ lease_until: 0,
191
+ created_at: timestamp,
192
+ updated_at: timestamp,
193
+ last_error: null
194
+ };
195
+ });
196
+ await trx.insertInto("workmatic_jobs").values(rows).execute();
197
+ return { ok: true, ids };
198
+ });
199
+ },
200
+ /**
201
+ * Get job statistics for the queue
202
+ */
203
+ async stats() {
204
+ const result = await db.selectFrom("workmatic_jobs").select([
205
+ "status",
206
+ import_kysely2.sql`count(*)`.as("count")
207
+ ]).where("queue", "=", queue).groupBy("status").execute();
208
+ const stats = {
209
+ ready: 0,
210
+ running: 0,
211
+ done: 0,
212
+ dead: 0,
213
+ total: 0
214
+ };
215
+ for (const row of result) {
216
+ const status = row.status;
217
+ const count = Number(row.count);
218
+ if (status in stats) {
219
+ stats[status] = count;
220
+ }
221
+ stats.total += count;
222
+ }
223
+ return stats;
224
+ },
225
+ /**
226
+ * Clear all jobs from the queue
227
+ */
228
+ async clear(options2 = {}) {
229
+ let query = db.deleteFrom("workmatic_jobs").where("queue", "=", queue);
230
+ if (options2.status) {
231
+ query = query.where("status", "=", options2.status);
232
+ }
233
+ const result = await query.execute();
234
+ return Number(result[0]?.numDeletedRows ?? 0);
235
+ }
236
+ };
237
+ }
238
+
239
+ // src/worker.ts
240
+ var import_fastq = __toESM(require("fastq"), 1);
241
+ var import_kysely3 = require("kysely");
242
+ var DEFAULT_WORKER_TIMEOUT_MS = 6e4;
243
+ function parseClaimedRows(result) {
244
+ const rows = result.rows;
245
+ if (!rows || !Array.isArray(rows)) {
246
+ return [];
247
+ }
248
+ return rows.map((row) => ({
249
+ id: row.id,
250
+ public_id: row.public_id,
251
+ queue: row.queue,
252
+ payload: row.payload,
253
+ attempts: row.attempts,
254
+ max_attempts: row.max_attempts,
255
+ priority: row.priority,
256
+ created_at: row.created_at,
257
+ last_error: row.last_error
258
+ }));
259
+ }
260
+ function requireProcessor(processor) {
261
+ if (!processor) {
262
+ throw new Error("No processor set");
263
+ }
264
+ return processor;
265
+ }
266
+ function createWorker(options) {
267
+ const {
268
+ db,
269
+ queue = "default",
270
+ concurrency = 1,
271
+ leaseMs = 3e4,
272
+ pollMs = 1e3,
273
+ timeoutMs = DEFAULT_WORKER_TIMEOUT_MS,
274
+ backoff = defaultBackoff,
275
+ persistState = false,
276
+ autoRestore = true,
277
+ pauseCheckIntervalMs = 300,
278
+ requeueExpiredIntervalMs = 0,
279
+ onPumpError
280
+ } = options;
281
+ if (!db) {
282
+ throw new Error("Database instance is required");
283
+ }
284
+ let running = false;
285
+ let paused = false;
286
+ let processor = null;
287
+ let pumpTimeout = null;
288
+ let fastqQueue = null;
289
+ let lastPauseCheckAt = 0;
290
+ let cachedDbPaused = false;
291
+ let lastRequeueAt = 0;
292
+ function notifyPumpError(error) {
293
+ console.error("[workmatic] Pump error:", error);
294
+ onPumpError?.(error);
295
+ }
296
+ function getStateKey() {
297
+ return `worker_state_${queue}`;
298
+ }
299
+ async function saveState(state) {
300
+ if (!persistState) return;
301
+ const timestamp = now();
302
+ const key = getStateKey();
303
+ await import_kysely3.sql`
304
+ INSERT INTO workmatic_settings (queue, paused, updated_at)
305
+ VALUES (${key}, ${state === "paused" ? 1 : state === "running" ? 2 : 0}, ${timestamp})
306
+ ON CONFLICT(queue) DO UPDATE SET
307
+ paused = ${state === "paused" ? 1 : state === "running" ? 2 : 0},
308
+ updated_at = ${timestamp}
309
+ `.execute(db);
310
+ }
311
+ async function loadState() {
312
+ if (!persistState) return null;
313
+ try {
314
+ const key = getStateKey();
315
+ const result = await db.selectFrom("workmatic_settings").select("paused").where("queue", "=", key).executeTakeFirst();
316
+ if (!result) return null;
317
+ if (result.paused === 2) return "running";
318
+ if (result.paused === 1) return "paused";
319
+ return "stopped";
320
+ } catch {
321
+ return null;
322
+ }
323
+ }
324
+ async function requeueExpiredLeases() {
325
+ const timestamp = now();
326
+ const result = await db.updateTable("workmatic_jobs").set({
327
+ status: "ready",
328
+ lease_until: 0,
329
+ updated_at: timestamp
330
+ }).where("status", "=", "running").where("lease_until", "<", timestamp).where("lease_until", ">", 0).execute();
331
+ return Number(result[0]?.numUpdatedRows ?? 0);
332
+ }
333
+ async function claimBatch(limit) {
334
+ const timestamp = now();
335
+ const leaseUntil = timestamp + leaseMs;
336
+ return await db.transaction().execute(async (trx) => {
337
+ const result = await import_kysely3.sql`
338
+ UPDATE workmatic_jobs
339
+ SET status = 'running', lease_until = ${leaseUntil}, updated_at = ${timestamp}
340
+ WHERE rowid IN (
341
+ SELECT rowid FROM workmatic_jobs
342
+ WHERE queue = ${queue}
343
+ AND status = 'ready'
344
+ AND run_at <= ${timestamp}
345
+ ORDER BY priority ASC, id ASC
346
+ LIMIT ${limit}
347
+ )
348
+ RETURNING id, public_id, queue, payload, attempts, max_attempts, priority, created_at, last_error
349
+ `.execute(trx);
350
+ return parseClaimedRows(result);
351
+ });
352
+ }
353
+ async function markDone(jobId) {
354
+ await db.updateTable("workmatic_jobs").set({
355
+ status: "done",
356
+ lease_until: 0,
357
+ updated_at: now()
358
+ }).where("id", "=", jobId).execute();
359
+ }
360
+ async function markFailed(jobId, attempts, maxAttempts, error) {
361
+ const timestamp = now();
362
+ const newAttempts = attempts + 1;
363
+ const errorMessage = error.message || String(error);
364
+ if (newAttempts < maxAttempts) {
365
+ const runAt = timestamp + backoff(newAttempts);
366
+ await db.updateTable("workmatic_jobs").set({
367
+ status: "ready",
368
+ attempts: newAttempts,
369
+ run_at: runAt,
370
+ lease_until: 0,
371
+ last_error: errorMessage,
372
+ updated_at: timestamp
373
+ }).where("id", "=", jobId).execute();
374
+ } else {
375
+ await db.updateTable("workmatic_jobs").set({
376
+ status: "dead",
377
+ attempts: newAttempts,
378
+ lease_until: 0,
379
+ last_error: errorMessage,
380
+ updated_at: timestamp
381
+ }).where("id", "=", jobId).execute();
382
+ }
383
+ }
384
+ async function withTimeout(promise, ms, jobId) {
385
+ let timeoutId;
386
+ const timeoutPromise = new Promise((_, reject) => {
387
+ timeoutId = setTimeout(() => {
388
+ reject(new Error(`Job ${jobId} timed out after ${ms}ms`));
389
+ }, ms);
390
+ });
391
+ try {
392
+ return await Promise.race([promise, timeoutPromise]);
393
+ } finally {
394
+ clearTimeout(timeoutId);
395
+ }
396
+ }
397
+ async function processJob(claimedJob) {
398
+ const fn = requireProcessor(processor);
399
+ const payload = parsePayload(claimedJob.payload);
400
+ const job = {
401
+ id: claimedJob.public_id,
402
+ queue: claimedJob.queue,
403
+ payload,
404
+ status: "running",
405
+ priority: claimedJob.priority,
406
+ attempts: claimedJob.attempts,
407
+ maxAttempts: claimedJob.max_attempts,
408
+ createdAt: claimedJob.created_at,
409
+ lastError: claimedJob.last_error
410
+ };
411
+ try {
412
+ if (timeoutMs) {
413
+ await withTimeout(fn(job), timeoutMs, job.id);
414
+ } else {
415
+ await fn(job);
416
+ }
417
+ await markDone(claimedJob.id);
418
+ } catch (error) {
419
+ await markFailed(
420
+ claimedJob.id,
421
+ claimedJob.attempts,
422
+ claimedJob.max_attempts,
423
+ error instanceof Error ? error : new Error(String(error))
424
+ );
425
+ }
426
+ }
427
+ async function isQueuePausedInDb() {
428
+ const setting = await db.selectFrom("workmatic_settings").select("paused").where("queue", "=", queue).executeTakeFirst();
429
+ return setting?.paused === 1;
430
+ }
431
+ async function pump() {
432
+ if (!running) {
433
+ return;
434
+ }
435
+ if (paused) {
436
+ pumpTimeout = setTimeout(pump, pollMs);
437
+ return;
438
+ }
439
+ try {
440
+ const t = now();
441
+ if (t - lastPauseCheckAt >= pauseCheckIntervalMs) {
442
+ lastPauseCheckAt = t;
443
+ cachedDbPaused = await isQueuePausedInDb();
444
+ }
445
+ if (cachedDbPaused) {
446
+ pumpTimeout = setTimeout(pump, pollMs);
447
+ return;
448
+ }
449
+ if (requeueExpiredIntervalMs <= 0 || t - lastRequeueAt >= requeueExpiredIntervalMs) {
450
+ if (requeueExpiredIntervalMs > 0) {
451
+ lastRequeueAt = t;
452
+ }
453
+ await requeueExpiredLeases();
454
+ }
455
+ const batchSize = concurrency * 2;
456
+ const jobs = await claimBatch(batchSize);
457
+ if (jobs.length > 0) {
458
+ for (const job of jobs) {
459
+ fastqQueue.push(job);
460
+ }
461
+ pumpTimeout = setTimeout(pump, 0);
462
+ } else {
463
+ pumpTimeout = setTimeout(pump, pollMs);
464
+ }
465
+ } catch (error) {
466
+ notifyPumpError(error);
467
+ pumpTimeout = setTimeout(pump, pollMs);
468
+ }
469
+ }
470
+ const worker = {
471
+ process(fn) {
472
+ processor = fn;
473
+ },
474
+ start() {
475
+ if (running) {
476
+ return;
477
+ }
478
+ if (!processor) {
479
+ throw new Error("No processor set. Call process() before start()");
480
+ }
481
+ running = true;
482
+ paused = false;
483
+ fastqQueue = import_fastq.default.promise(processJob, concurrency);
484
+ saveState("running").catch(() => {
485
+ });
486
+ pump();
487
+ },
488
+ async stop() {
489
+ if (!running) {
490
+ return;
491
+ }
492
+ running = false;
493
+ if (pumpTimeout) {
494
+ clearTimeout(pumpTimeout);
495
+ pumpTimeout = null;
496
+ }
497
+ if (fastqQueue) {
498
+ await fastqQueue.drained();
499
+ fastqQueue = null;
500
+ }
501
+ await saveState("stopped");
502
+ },
503
+ pause() {
504
+ paused = true;
505
+ saveState("paused").catch(() => {
506
+ });
507
+ },
508
+ resume() {
509
+ paused = false;
510
+ saveState("running").catch(() => {
511
+ });
512
+ },
513
+ async stats() {
514
+ const result = await db.selectFrom("workmatic_jobs").select([
515
+ "status",
516
+ import_kysely3.sql`count(*)`.as("count")
517
+ ]).where("queue", "=", queue).groupBy("status").execute();
518
+ const stats = {
519
+ ready: 0,
520
+ running: 0,
521
+ done: 0,
522
+ dead: 0,
523
+ total: 0
524
+ };
525
+ for (const row of result) {
526
+ const status = row.status;
527
+ const count = Number(row.count);
528
+ if (status in stats) {
529
+ stats[status] = count;
530
+ }
531
+ stats.total += count;
532
+ }
533
+ return stats;
534
+ },
535
+ get isRunning() {
536
+ return running;
537
+ },
538
+ get isPaused() {
539
+ return paused;
540
+ },
541
+ get queue() {
542
+ return queue;
543
+ },
544
+ async restoreState() {
545
+ const state = await loadState();
546
+ if (state === "running" && processor) {
547
+ this.start();
548
+ } else if (state === "paused" && processor) {
549
+ this.start();
550
+ this.pause();
551
+ }
552
+ return state;
553
+ },
554
+ async clear(options2 = {}) {
555
+ let query = db.deleteFrom("workmatic_jobs").where("queue", "=", queue);
556
+ if (options2.status) {
557
+ query = query.where("status", "=", options2.status);
558
+ }
559
+ const result = await query.execute();
560
+ return Number(result[0]?.numDeletedRows ?? 0);
561
+ }
562
+ };
563
+ if (persistState && autoRestore) {
564
+ setImmediate(async () => {
565
+ if (processor) {
566
+ await worker.restoreState();
567
+ }
568
+ });
569
+ }
570
+ return worker;
571
+ }
572
+
573
+ // src/orchestrator.ts
574
+ var DEFAULT_TRANSFER_STATUSES = ["ready", "dead"];
575
+ var DEFAULT_TRANSFER_LIMIT = 1e4;
576
+ function rowsUpdated(result) {
577
+ const row = result[0];
578
+ return Number(row?.numUpdatedRows ?? 0);
579
+ }
580
+ function normalizeStatuses(status) {
581
+ if (!status) return [...DEFAULT_TRANSFER_STATUSES];
582
+ return Array.isArray(status) ? status : [status];
583
+ }
584
+ function createOrchestrator(options) {
585
+ const { db } = options;
586
+ if (!db) {
587
+ throw new Error("Database instance is required");
588
+ }
589
+ const registry = /* @__PURE__ */ new Map();
590
+ function ensureEntry(queue) {
591
+ let entry = registry.get(queue);
592
+ if (!entry) {
593
+ entry = { client: createClient({ db, queue }) };
594
+ registry.set(queue, entry);
595
+ }
596
+ return entry;
597
+ }
598
+ async function setQueuePaused(queue, paused) {
599
+ const timestamp = now();
600
+ const value = paused ? 1 : 0;
601
+ await import_kysely4.sql`
602
+ INSERT INTO workmatic_settings (queue, paused, updated_at)
603
+ VALUES (${queue}, ${value}, ${timestamp})
604
+ ON CONFLICT(queue) DO UPDATE SET
605
+ paused = ${value},
606
+ updated_at = ${timestamp}
607
+ `.execute(db);
608
+ }
609
+ async function selectJobIds(from, status, limit) {
610
+ const rows = await db.selectFrom("workmatic_jobs").select("id").where("queue", "=", from).where("status", "=", status).orderBy("priority", "asc").orderBy("id", "asc").limit(limit).execute();
611
+ return rows.map((r) => r.id);
612
+ }
613
+ async function transferReady(from, to, limit) {
614
+ const ids = await selectJobIds(from, "ready", limit);
615
+ if (ids.length === 0) return 0;
616
+ const timestamp = now();
617
+ const result = await db.updateTable("workmatic_jobs").set({ queue: to, updated_at: timestamp }).where("id", "in", ids).execute();
618
+ return rowsUpdated(result);
619
+ }
620
+ async function transferDead(from, to, limit, resetForRetry) {
621
+ const ids = await selectJobIds(from, "dead", limit);
622
+ if (ids.length === 0) return 0;
623
+ const timestamp = now();
624
+ if (resetForRetry) {
625
+ const result2 = await db.updateTable("workmatic_jobs").set({
626
+ queue: to,
627
+ status: "ready",
628
+ attempts: 0,
629
+ lease_until: 0,
630
+ last_error: null,
631
+ updated_at: timestamp,
632
+ run_at: timestamp
633
+ }).where("id", "in", ids).execute();
634
+ return rowsUpdated(result2);
635
+ }
636
+ const result = await db.updateTable("workmatic_jobs").set({ queue: to, updated_at: timestamp }).where("id", "in", ids).execute();
637
+ return rowsUpdated(result);
638
+ }
639
+ async function transferOtherStatus(from, to, status, limit) {
640
+ const ids = await selectJobIds(from, status, limit);
641
+ if (ids.length === 0) return 0;
642
+ const timestamp = now();
643
+ const result = await db.updateTable("workmatic_jobs").set({ queue: to, updated_at: timestamp }).where("id", "in", ids).execute();
644
+ return rowsUpdated(result);
645
+ }
646
+ const orchestrator = {
647
+ register(queue, opts = {}) {
648
+ const client = createClient({ db, queue });
649
+ let worker;
650
+ if (opts.worker) {
651
+ worker = createWorker({ db, queue, ...opts.worker });
652
+ }
653
+ registry.set(queue, { client, worker });
654
+ return client;
655
+ },
656
+ client(queue) {
657
+ return ensureEntry(queue).client;
658
+ },
659
+ worker(queue) {
660
+ const entry = registry.get(queue);
661
+ if (!entry?.worker) {
662
+ throw new Error(`No worker registered for queue "${queue}"`);
663
+ }
664
+ return entry.worker;
665
+ },
666
+ workers() {
667
+ return [...registry.values()].map((e) => e.worker).filter((w) => w !== void 0);
668
+ },
669
+ async queues() {
670
+ const rows = await db.selectFrom("workmatic_jobs").select("queue").distinct().execute();
671
+ const names = new Set(rows.map((r) => r.queue));
672
+ for (const name of registry.keys()) {
673
+ names.add(name);
674
+ }
675
+ return [...names].sort();
676
+ },
677
+ process(queue, fn) {
678
+ this.worker(queue).process(fn);
679
+ },
680
+ startAll() {
681
+ for (const entry of registry.values()) {
682
+ entry.worker?.start();
683
+ }
684
+ },
685
+ async stopAll() {
686
+ const stops = [...registry.values()].map((e) => e.worker?.stop()).filter((p) => p !== void 0);
687
+ await Promise.all(stops);
688
+ },
689
+ async pause(queue) {
690
+ const entry = registry.get(queue);
691
+ entry?.worker?.pause();
692
+ await setQueuePaused(queue, true);
693
+ },
694
+ async resume(queue) {
695
+ const entry = registry.get(queue);
696
+ entry?.worker?.resume();
697
+ await setQueuePaused(queue, false);
698
+ },
699
+ async isPaused(queue) {
700
+ const setting = await db.selectFrom("workmatic_settings").select("paused").where("queue", "=", queue).executeTakeFirst();
701
+ return setting?.paused === 1;
702
+ },
703
+ async stats(queueName) {
704
+ const names = queueName ? [queueName] : await this.queues();
705
+ const result = {};
706
+ for (const name of names) {
707
+ result[name] = await ensureEntry(name).client.stats();
708
+ }
709
+ return result;
710
+ },
711
+ async transfer(opts) {
712
+ const { from, to, resetForRetry = false } = opts;
713
+ if (from === to) {
714
+ return { moved: 0 };
715
+ }
716
+ const statuses = normalizeStatuses(opts.status);
717
+ let remaining = opts.limit ?? DEFAULT_TRANSFER_LIMIT;
718
+ let moved = 0;
719
+ if (statuses.includes("ready") && remaining > 0) {
720
+ const n = await transferReady(from, to, remaining);
721
+ moved += n;
722
+ remaining -= n;
723
+ }
724
+ if (statuses.includes("dead") && remaining > 0) {
725
+ const n = await transferDead(from, to, remaining, resetForRetry);
726
+ moved += n;
727
+ remaining -= n;
728
+ }
729
+ for (const status of statuses) {
730
+ if (status === "ready" || status === "dead" || remaining <= 0) continue;
731
+ const n = await transferOtherStatus(from, to, status, remaining);
732
+ moved += n;
733
+ remaining -= n;
734
+ }
735
+ return { moved };
736
+ },
737
+ async moveJob(publicId, toQueue, opts = {}) {
738
+ const allowed = normalizeStatuses(opts.status);
739
+ const row = await db.selectFrom("workmatic_jobs").select(["id", "queue", "status"]).where("public_id", "=", publicId).executeTakeFirst();
740
+ if (!row) {
741
+ throw new Error(`Job not found: ${publicId}`);
742
+ }
743
+ if (row.queue === toQueue) {
744
+ return;
745
+ }
746
+ if (!allowed.includes(row.status)) {
747
+ throw new Error(
748
+ `Job ${publicId} has status "${row.status}" and cannot be moved (allowed: ${allowed.join(", ")})`
749
+ );
750
+ }
751
+ const timestamp = now();
752
+ const resetForRetry = opts.resetForRetry ?? false;
753
+ if (row.status === "dead" && resetForRetry) {
754
+ await db.updateTable("workmatic_jobs").set({
755
+ queue: toQueue,
756
+ status: "ready",
757
+ attempts: 0,
758
+ lease_until: 0,
759
+ last_error: null,
760
+ updated_at: timestamp,
761
+ run_at: timestamp
762
+ }).where("public_id", "=", publicId).execute();
763
+ return;
764
+ }
765
+ await db.updateTable("workmatic_jobs").set({ queue: toQueue, updated_at: timestamp }).where("public_id", "=", publicId).execute();
766
+ }
767
+ };
768
+ return orchestrator;
769
+ }
770
+
771
+ // src/cli/handlers.ts
33
772
  function printUsage() {
34
773
  console.log(`
35
774
  Workmatic CLI - Job Queue Management
@@ -47,20 +786,18 @@ Commands:
47
786
  retry <db> [--status=dead] Retry dead jobs (reset to ready)
48
787
  pause <db> <queue> Pause a queue (running workers stop claiming)
49
788
  resume <db> <queue> Resume a paused queue
789
+ transfer <db> <from> <to> Move jobs between queues
50
790
 
51
791
  Options:
52
- --status=<status> Filter by status (ready|running|done|dead)
792
+ --status=<status> Filter by status (ready|running|done|dead), comma-separated for transfer
53
793
  --queue=<queue> Filter by queue name
54
- --limit=<n> Limit number of results (default: 100)
794
+ --limit=<n> Limit number of results or jobs to transfer (default: 100 / 10000)
795
+ --retry When transferring dead jobs, reset them to ready for retry
55
796
 
56
797
  Examples:
57
798
  workmatic stats ./jobs.db
58
799
  workmatic list ./jobs.db --status=dead --limit=10
59
- workmatic export ./jobs.db backup.csv
60
- workmatic export ./jobs.db --status=dead > dead-jobs.csv
61
- workmatic import ./jobs.db backup.csv
62
- workmatic purge ./jobs.db --status=done
63
- workmatic retry ./jobs.db --status=dead
800
+ workmatic transfer ./jobs.db emails retry-emails --status=dead --retry
64
801
  workmatic pause ./jobs.db emails
65
802
  workmatic resume ./jobs.db emails
66
803
  `);
@@ -118,27 +855,25 @@ function parseCSVLine(line) {
118
855
  return result;
119
856
  }
120
857
  async function cmdStats(dbPath) {
121
- const db = new import_better_sqlite3.default(dbPath, { readonly: true });
858
+ const db = new import_better_sqlite32.default(dbPath, { readonly: true });
122
859
  try {
123
- const stats = db.prepare(`
860
+ const stats = db.prepare(
861
+ `
124
862
  SELECT status, COUNT(*) as count
125
863
  FROM workmatic_jobs
126
864
  GROUP BY status
127
- `).all();
128
- const queues = db.prepare(`
865
+ `
866
+ ).all();
867
+ const queues = db.prepare(
868
+ `
129
869
  SELECT queue, COUNT(*) as count
130
870
  FROM workmatic_jobs
131
871
  GROUP BY queue
132
- `).all();
872
+ `
873
+ ).all();
133
874
  const total = stats.reduce((sum, s) => sum + s.count, 0);
134
875
  console.log("\n\u{1F4CA} Job Statistics\n");
135
876
  const statusOrder = ["ready", "running", "done", "dead"];
136
- const statusEmoji = {
137
- ready: "\u23F3",
138
- running: "\u25B6\uFE0F",
139
- done: "\u2705",
140
- dead: "\u{1F480}"
141
- };
142
877
  const statusTable = new import_cli_table3.default({
143
878
  head: ["", "Status", "Count"],
144
879
  style: { head: ["cyan"], border: ["gray"] },
@@ -147,7 +882,7 @@ async function cmdStats(dbPath) {
147
882
  for (const status of statusOrder) {
148
883
  const stat = stats.find((s) => s.status === status);
149
884
  const count = stat?.count ?? 0;
150
- const emoji = statusEmoji[status] || "";
885
+ const emoji = jobStatusEmoji(status);
151
886
  statusTable.push([emoji, status, count.toLocaleString()]);
152
887
  }
153
888
  statusTable.push([{ colSpan: 2, content: "Total", hAlign: "right" }, total.toLocaleString()]);
@@ -170,7 +905,7 @@ async function cmdStats(dbPath) {
170
905
  }
171
906
  }
172
907
  async function cmdList(dbPath, options) {
173
- const db = new import_better_sqlite3.default(dbPath, { readonly: true });
908
+ const db = new import_better_sqlite32.default(dbPath, { readonly: true });
174
909
  try {
175
910
  let query = "SELECT public_id, queue, status, priority, attempts, max_attempts, created_at, last_error FROM workmatic_jobs WHERE 1=1";
176
911
  const params = [];
@@ -189,12 +924,6 @@ async function cmdList(dbPath, options) {
189
924
  console.log("No jobs found.");
190
925
  return;
191
926
  }
192
- const statusEmoji = {
193
- ready: "\u23F3",
194
- running: "\u25B6\uFE0F",
195
- done: "\u2705",
196
- dead: "\u{1F480}"
197
- };
198
927
  const table = new import_cli_table3.default({
199
928
  head: ["ID", "Queue", "Status", "Pri", "Attempts", "Created"],
200
929
  style: { head: ["cyan"], border: ["gray"] },
@@ -203,7 +932,7 @@ async function cmdList(dbPath, options) {
203
932
  wordWrap: true
204
933
  });
205
934
  for (const job of jobs) {
206
- const emoji = statusEmoji[job.status] || "";
935
+ const emoji = jobStatusEmoji(job.status);
207
936
  table.push([
208
937
  job.public_id.slice(0, 21),
209
938
  job.queue.slice(0, 14),
@@ -223,7 +952,7 @@ async function cmdList(dbPath, options) {
223
952
  }
224
953
  }
225
954
  async function cmdExport(dbPath, outputPath, options) {
226
- const db = new import_better_sqlite3.default(dbPath, { readonly: true });
955
+ const db = new import_better_sqlite32.default(dbPath, { readonly: true });
227
956
  try {
228
957
  let query = "SELECT * FROM workmatic_jobs WHERE 1=1";
229
958
  const params = [];
@@ -266,7 +995,9 @@ async function cmdExport(dbPath, outputPath, options) {
266
995
  }
267
996
  }
268
997
  if (outputPath) {
269
- output.end();
998
+ const stream = output;
999
+ stream.end();
1000
+ await (0, import_promises.finished)(stream);
270
1001
  console.error(`\u2705 Exported ${jobs.length} jobs to ${outputPath}`);
271
1002
  }
272
1003
  } finally {
@@ -274,7 +1005,7 @@ async function cmdExport(dbPath, outputPath, options) {
274
1005
  }
275
1006
  }
276
1007
  async function cmdImport(dbPath, inputPath) {
277
- const db = new import_better_sqlite3.default(dbPath);
1008
+ const db = new import_better_sqlite32.default(dbPath);
278
1009
  db.exec(`
279
1010
  CREATE TABLE IF NOT EXISTS workmatic_jobs (
280
1011
  id INTEGER PRIMARY KEY AUTOINCREMENT,
@@ -355,11 +1086,9 @@ async function cmdImport(dbPath, inputPath) {
355
1086
  }
356
1087
  async function cmdPurge(dbPath, options) {
357
1088
  if (!options.status) {
358
- console.error("Error: --status is required for purge command");
359
- console.error("Example: workmatic purge ./jobs.db --status=done");
360
- process.exit(1);
1089
+ throw new Error("--status is required for purge command");
361
1090
  }
362
- const db = new import_better_sqlite3.default(dbPath);
1091
+ const db = new import_better_sqlite32.default(dbPath);
363
1092
  try {
364
1093
  const result = db.prepare("DELETE FROM workmatic_jobs WHERE status = ?").run(options.status);
365
1094
  console.log(`\u{1F5D1}\uFE0F Deleted ${result.changes} jobs with status '${options.status}'`);
@@ -369,10 +1098,11 @@ async function cmdPurge(dbPath, options) {
369
1098
  }
370
1099
  async function cmdRetry(dbPath, options) {
371
1100
  const status = options.status || "dead";
372
- const db = new import_better_sqlite3.default(dbPath);
1101
+ const db = new import_better_sqlite32.default(dbPath);
373
1102
  try {
374
- const now = Date.now();
375
- const result = db.prepare(`
1103
+ const ts = Date.now();
1104
+ const result = db.prepare(
1105
+ `
376
1106
  UPDATE workmatic_jobs
377
1107
  SET status = 'ready',
378
1108
  attempts = 0,
@@ -380,14 +1110,15 @@ async function cmdRetry(dbPath, options) {
380
1110
  lease_until = 0,
381
1111
  updated_at = ?
382
1112
  WHERE status = ?
383
- `).run(now, now, status);
1113
+ `
1114
+ ).run(ts, ts, status);
384
1115
  console.log(`\u{1F504} Reset ${result.changes} jobs from '${status}' to 'ready'`);
385
1116
  } finally {
386
1117
  db.close();
387
1118
  }
388
1119
  }
389
1120
  async function cmdPause(dbPath, queueName) {
390
- const db = new import_better_sqlite3.default(dbPath);
1121
+ const db = new import_better_sqlite32.default(dbPath);
391
1122
  try {
392
1123
  db.exec(`
393
1124
  CREATE TABLE IF NOT EXISTS workmatic_settings (
@@ -396,12 +1127,14 @@ async function cmdPause(dbPath, queueName) {
396
1127
  updated_at INTEGER NOT NULL
397
1128
  )
398
1129
  `);
399
- const now = Date.now();
400
- db.prepare(`
1130
+ const ts = Date.now();
1131
+ db.prepare(
1132
+ `
401
1133
  INSERT INTO workmatic_settings (queue, paused, updated_at)
402
1134
  VALUES (?, 1, ?)
403
1135
  ON CONFLICT(queue) DO UPDATE SET paused = 1, updated_at = ?
404
- `).run(queueName, now, now);
1136
+ `
1137
+ ).run(queueName, ts, ts);
405
1138
  console.log(`\u23F8\uFE0F Paused queue '${queueName}'`);
406
1139
  console.log(` Running workers will stop claiming new jobs.`);
407
1140
  } finally {
@@ -409,7 +1142,7 @@ async function cmdPause(dbPath, queueName) {
409
1142
  }
410
1143
  }
411
1144
  async function cmdResume(dbPath, queueName) {
412
- const db = new import_better_sqlite3.default(dbPath);
1145
+ const db = new import_better_sqlite32.default(dbPath);
413
1146
  try {
414
1147
  db.exec(`
415
1148
  CREATE TABLE IF NOT EXISTS workmatic_settings (
@@ -418,12 +1151,14 @@ async function cmdResume(dbPath, queueName) {
418
1151
  updated_at INTEGER NOT NULL
419
1152
  )
420
1153
  `);
421
- const now = Date.now();
422
- db.prepare(`
1154
+ const ts = Date.now();
1155
+ db.prepare(
1156
+ `
423
1157
  INSERT INTO workmatic_settings (queue, paused, updated_at)
424
1158
  VALUES (?, 0, ?)
425
1159
  ON CONFLICT(queue) DO UPDATE SET paused = 0, updated_at = ?
426
- `).run(queueName, now, now);
1160
+ `
1161
+ ).run(queueName, ts, ts);
427
1162
  console.log(`\u25B6\uFE0F Resumed queue '${queueName}'`);
428
1163
  console.log(` Workers will start claiming jobs again.`);
429
1164
  } finally {
@@ -431,14 +1166,17 @@ async function cmdResume(dbPath, queueName) {
431
1166
  }
432
1167
  }
433
1168
  async function cmdQueues(dbPath) {
434
- const db = new import_better_sqlite3.default(dbPath, { readonly: true });
1169
+ const db = new import_better_sqlite32.default(dbPath, { readonly: true });
435
1170
  try {
436
- const settingsExists = db.prepare(`
1171
+ const settingsExists = db.prepare(
1172
+ `
437
1173
  SELECT name FROM sqlite_master WHERE type='table' AND name='workmatic_settings'
438
- `).get();
1174
+ `
1175
+ ).get();
439
1176
  let queues;
440
1177
  if (settingsExists) {
441
- queues = db.prepare(`
1178
+ queues = db.prepare(
1179
+ `
442
1180
  SELECT
443
1181
  j.queue,
444
1182
  COUNT(*) as total,
@@ -448,9 +1186,11 @@ async function cmdQueues(dbPath) {
448
1186
  FROM workmatic_jobs j
449
1187
  LEFT JOIN workmatic_settings s ON j.queue = s.queue
450
1188
  GROUP BY j.queue
451
- `).all();
1189
+ `
1190
+ ).all();
452
1191
  } else {
453
- queues = db.prepare(`
1192
+ queues = db.prepare(
1193
+ `
454
1194
  SELECT
455
1195
  queue,
456
1196
  COUNT(*) as total,
@@ -459,7 +1199,8 @@ async function cmdQueues(dbPath) {
459
1199
  0 as paused
460
1200
  FROM workmatic_jobs
461
1201
  GROUP BY queue
462
- `).all();
1202
+ `
1203
+ ).all();
463
1204
  }
464
1205
  if (queues.length === 0) {
465
1206
  console.log("No queues found.");
@@ -487,11 +1228,115 @@ async function cmdQueues(dbPath) {
487
1228
  db.close();
488
1229
  }
489
1230
  }
1231
+ var JOB_STATUS_EMOJI = {
1232
+ ready: "\u23F3",
1233
+ running: "\u25B6\uFE0F",
1234
+ done: "\u2705",
1235
+ dead: "\u{1F480}"
1236
+ };
1237
+ function jobStatusEmoji(status) {
1238
+ return JOB_STATUS_EMOJI[status] || "";
1239
+ }
1240
+ function parseTransferStatuses(raw) {
1241
+ if (!raw) return ["ready", "dead"];
1242
+ return raw.split(",").map((s) => s.trim());
1243
+ }
1244
+ async function cmdTransfer(dbPath, from, to, options) {
1245
+ const db = createDatabase({ filename: dbPath });
1246
+ try {
1247
+ const orch = createOrchestrator({ db });
1248
+ const status = parseTransferStatuses(options.status);
1249
+ const limit = options.limit ? parseInt(options.limit, 10) : void 0;
1250
+ const resetForRetry = options.retry === "true";
1251
+ const { moved } = await orch.transfer({
1252
+ from,
1253
+ to,
1254
+ status,
1255
+ limit,
1256
+ resetForRetry
1257
+ });
1258
+ console.log(`\u2194\uFE0F Moved ${moved} job(s) from '${from}' to '${to}'`);
1259
+ } finally {
1260
+ await db.destroy();
1261
+ }
1262
+ }
1263
+ async function runCommand(command2, dbPath, positionalArgs, options) {
1264
+ switch (command2) {
1265
+ case "stats":
1266
+ await cmdStats(dbPath);
1267
+ break;
1268
+ case "list":
1269
+ await cmdList(dbPath, options);
1270
+ break;
1271
+ case "export":
1272
+ await cmdExport(dbPath, positionalArgs[1], options);
1273
+ break;
1274
+ case "import":
1275
+ if (!positionalArgs[1]) {
1276
+ throw new Error("Input CSV file is required");
1277
+ }
1278
+ await cmdImport(dbPath, positionalArgs[1]);
1279
+ break;
1280
+ case "purge":
1281
+ await cmdPurge(dbPath, options);
1282
+ break;
1283
+ case "retry":
1284
+ await cmdRetry(dbPath, options);
1285
+ break;
1286
+ case "pause":
1287
+ if (!positionalArgs[1]) {
1288
+ throw new Error("Queue name is required");
1289
+ }
1290
+ await cmdPause(dbPath, positionalArgs[1]);
1291
+ break;
1292
+ case "resume":
1293
+ if (!positionalArgs[1]) {
1294
+ throw new Error("Queue name is required");
1295
+ }
1296
+ await cmdResume(dbPath, positionalArgs[1]);
1297
+ break;
1298
+ case "queues":
1299
+ await cmdQueues(dbPath);
1300
+ break;
1301
+ case "transfer":
1302
+ if (!positionalArgs[1] || !positionalArgs[2]) {
1303
+ throw new Error("Source and target queue names are required");
1304
+ }
1305
+ await cmdTransfer(dbPath, positionalArgs[1], positionalArgs[2], options);
1306
+ break;
1307
+ default:
1308
+ throw new Error(`Unknown command: ${command2}`);
1309
+ }
1310
+ }
1311
+
1312
+ // src/cli.ts
1313
+ var args = process.argv.slice(2);
1314
+ var command = args[0];
1315
+ var KNOWN_COMMANDS = [
1316
+ "stats",
1317
+ "list",
1318
+ "export",
1319
+ "import",
1320
+ "purge",
1321
+ "retry",
1322
+ "pause",
1323
+ "resume",
1324
+ "queues",
1325
+ "transfer"
1326
+ ];
1327
+ function isCliCommand(value) {
1328
+ return KNOWN_COMMANDS.includes(value);
1329
+ }
490
1330
  async function main() {
491
1331
  if (!command || command === "--help" || command === "-h") {
492
1332
  printUsage();
493
1333
  process.exit(0);
494
1334
  }
1335
+ if (!isCliCommand(command)) {
1336
+ console.error(`Unknown command: ${command}`);
1337
+ printUsage();
1338
+ process.exit(1);
1339
+ }
495
1340
  const positionalArgs = getPositionalArgs(args.slice(1));
496
1341
  const options = parseOptions(args);
497
1342
  const dbPath = positionalArgs[0];
@@ -501,53 +1346,7 @@ async function main() {
501
1346
  process.exit(1);
502
1347
  }
503
1348
  try {
504
- switch (command) {
505
- case "stats":
506
- await cmdStats(dbPath);
507
- break;
508
- case "list":
509
- await cmdList(dbPath, options);
510
- break;
511
- case "export":
512
- await cmdExport(dbPath, positionalArgs[1], options);
513
- break;
514
- case "import":
515
- if (!positionalArgs[1]) {
516
- console.error("Error: Input CSV file is required");
517
- process.exit(1);
518
- }
519
- await cmdImport(dbPath, positionalArgs[1]);
520
- break;
521
- case "purge":
522
- await cmdPurge(dbPath, options);
523
- break;
524
- case "retry":
525
- await cmdRetry(dbPath, options);
526
- break;
527
- case "pause":
528
- if (!positionalArgs[1]) {
529
- console.error("Error: Queue name is required");
530
- console.error("Example: workmatic pause ./jobs.db emails");
531
- process.exit(1);
532
- }
533
- await cmdPause(dbPath, positionalArgs[1]);
534
- break;
535
- case "resume":
536
- if (!positionalArgs[1]) {
537
- console.error("Error: Queue name is required");
538
- console.error("Example: workmatic resume ./jobs.db emails");
539
- process.exit(1);
540
- }
541
- await cmdResume(dbPath, positionalArgs[1]);
542
- break;
543
- case "queues":
544
- await cmdQueues(dbPath);
545
- break;
546
- default:
547
- console.error(`Unknown command: ${command}`);
548
- printUsage();
549
- process.exit(1);
550
- }
1349
+ await runCommand(command, dbPath, positionalArgs, options);
551
1350
  } catch (error) {
552
1351
  console.error("Error:", error instanceof Error ? error.message : error);
553
1352
  process.exit(1);