workmatic 1.0.5 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/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
@@ -44,23 +783,21 @@ Commands:
44
783
  export <db> [output.csv] Export jobs to CSV (default: stdout)
45
784
  import <db> <input.csv> Import jobs from CSV
46
785
  purge <db> --status=<status> Delete jobs by status
47
- retry <db> [--status=dead] Retry dead/failed jobs (reset to ready)
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|failed|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=failed > failed-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,28 +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
- const statusOrder = ["ready", "running", "done", "failed", "dead"];
136
- const statusEmoji = {
137
- ready: "\u23F3",
138
- running: "\u25B6\uFE0F",
139
- done: "\u2705",
140
- failed: "\u26A0\uFE0F",
141
- dead: "\u{1F480}"
142
- };
876
+ const statusOrder = ["ready", "running", "done", "dead"];
143
877
  const statusTable = new import_cli_table3.default({
144
878
  head: ["", "Status", "Count"],
145
879
  style: { head: ["cyan"], border: ["gray"] },
@@ -148,7 +882,7 @@ async function cmdStats(dbPath) {
148
882
  for (const status of statusOrder) {
149
883
  const stat = stats.find((s) => s.status === status);
150
884
  const count = stat?.count ?? 0;
151
- const emoji = statusEmoji[status] || "";
885
+ const emoji = jobStatusEmoji(status);
152
886
  statusTable.push([emoji, status, count.toLocaleString()]);
153
887
  }
154
888
  statusTable.push([{ colSpan: 2, content: "Total", hAlign: "right" }, total.toLocaleString()]);
@@ -171,7 +905,7 @@ async function cmdStats(dbPath) {
171
905
  }
172
906
  }
173
907
  async function cmdList(dbPath, options) {
174
- const db = new import_better_sqlite3.default(dbPath, { readonly: true });
908
+ const db = new import_better_sqlite32.default(dbPath, { readonly: true });
175
909
  try {
176
910
  let query = "SELECT public_id, queue, status, priority, attempts, max_attempts, created_at, last_error FROM workmatic_jobs WHERE 1=1";
177
911
  const params = [];
@@ -190,13 +924,6 @@ async function cmdList(dbPath, options) {
190
924
  console.log("No jobs found.");
191
925
  return;
192
926
  }
193
- const statusEmoji = {
194
- ready: "\u23F3",
195
- running: "\u25B6\uFE0F",
196
- done: "\u2705",
197
- failed: "\u26A0\uFE0F",
198
- dead: "\u{1F480}"
199
- };
200
927
  const table = new import_cli_table3.default({
201
928
  head: ["ID", "Queue", "Status", "Pri", "Attempts", "Created"],
202
929
  style: { head: ["cyan"], border: ["gray"] },
@@ -205,7 +932,7 @@ async function cmdList(dbPath, options) {
205
932
  wordWrap: true
206
933
  });
207
934
  for (const job of jobs) {
208
- const emoji = statusEmoji[job.status] || "";
935
+ const emoji = jobStatusEmoji(job.status);
209
936
  table.push([
210
937
  job.public_id.slice(0, 21),
211
938
  job.queue.slice(0, 14),
@@ -225,7 +952,7 @@ async function cmdList(dbPath, options) {
225
952
  }
226
953
  }
227
954
  async function cmdExport(dbPath, outputPath, options) {
228
- const db = new import_better_sqlite3.default(dbPath, { readonly: true });
955
+ const db = new import_better_sqlite32.default(dbPath, { readonly: true });
229
956
  try {
230
957
  let query = "SELECT * FROM workmatic_jobs WHERE 1=1";
231
958
  const params = [];
@@ -268,7 +995,9 @@ async function cmdExport(dbPath, outputPath, options) {
268
995
  }
269
996
  }
270
997
  if (outputPath) {
271
- output.end();
998
+ const stream = output;
999
+ stream.end();
1000
+ await (0, import_promises.finished)(stream);
272
1001
  console.error(`\u2705 Exported ${jobs.length} jobs to ${outputPath}`);
273
1002
  }
274
1003
  } finally {
@@ -276,7 +1005,7 @@ async function cmdExport(dbPath, outputPath, options) {
276
1005
  }
277
1006
  }
278
1007
  async function cmdImport(dbPath, inputPath) {
279
- const db = new import_better_sqlite3.default(dbPath);
1008
+ const db = new import_better_sqlite32.default(dbPath);
280
1009
  db.exec(`
281
1010
  CREATE TABLE IF NOT EXISTS workmatic_jobs (
282
1011
  id INTEGER PRIMARY KEY AUTOINCREMENT,
@@ -357,11 +1086,9 @@ async function cmdImport(dbPath, inputPath) {
357
1086
  }
358
1087
  async function cmdPurge(dbPath, options) {
359
1088
  if (!options.status) {
360
- console.error("Error: --status is required for purge command");
361
- console.error("Example: workmatic purge ./jobs.db --status=done");
362
- process.exit(1);
1089
+ throw new Error("--status is required for purge command");
363
1090
  }
364
- const db = new import_better_sqlite3.default(dbPath);
1091
+ const db = new import_better_sqlite32.default(dbPath);
365
1092
  try {
366
1093
  const result = db.prepare("DELETE FROM workmatic_jobs WHERE status = ?").run(options.status);
367
1094
  console.log(`\u{1F5D1}\uFE0F Deleted ${result.changes} jobs with status '${options.status}'`);
@@ -371,10 +1098,11 @@ async function cmdPurge(dbPath, options) {
371
1098
  }
372
1099
  async function cmdRetry(dbPath, options) {
373
1100
  const status = options.status || "dead";
374
- const db = new import_better_sqlite3.default(dbPath);
1101
+ const db = new import_better_sqlite32.default(dbPath);
375
1102
  try {
376
- const now = Date.now();
377
- const result = db.prepare(`
1103
+ const ts = Date.now();
1104
+ const result = db.prepare(
1105
+ `
378
1106
  UPDATE workmatic_jobs
379
1107
  SET status = 'ready',
380
1108
  attempts = 0,
@@ -382,14 +1110,15 @@ async function cmdRetry(dbPath, options) {
382
1110
  lease_until = 0,
383
1111
  updated_at = ?
384
1112
  WHERE status = ?
385
- `).run(now, now, status);
1113
+ `
1114
+ ).run(ts, ts, status);
386
1115
  console.log(`\u{1F504} Reset ${result.changes} jobs from '${status}' to 'ready'`);
387
1116
  } finally {
388
1117
  db.close();
389
1118
  }
390
1119
  }
391
1120
  async function cmdPause(dbPath, queueName) {
392
- const db = new import_better_sqlite3.default(dbPath);
1121
+ const db = new import_better_sqlite32.default(dbPath);
393
1122
  try {
394
1123
  db.exec(`
395
1124
  CREATE TABLE IF NOT EXISTS workmatic_settings (
@@ -398,12 +1127,14 @@ async function cmdPause(dbPath, queueName) {
398
1127
  updated_at INTEGER NOT NULL
399
1128
  )
400
1129
  `);
401
- const now = Date.now();
402
- db.prepare(`
1130
+ const ts = Date.now();
1131
+ db.prepare(
1132
+ `
403
1133
  INSERT INTO workmatic_settings (queue, paused, updated_at)
404
1134
  VALUES (?, 1, ?)
405
1135
  ON CONFLICT(queue) DO UPDATE SET paused = 1, updated_at = ?
406
- `).run(queueName, now, now);
1136
+ `
1137
+ ).run(queueName, ts, ts);
407
1138
  console.log(`\u23F8\uFE0F Paused queue '${queueName}'`);
408
1139
  console.log(` Running workers will stop claiming new jobs.`);
409
1140
  } finally {
@@ -411,7 +1142,7 @@ async function cmdPause(dbPath, queueName) {
411
1142
  }
412
1143
  }
413
1144
  async function cmdResume(dbPath, queueName) {
414
- const db = new import_better_sqlite3.default(dbPath);
1145
+ const db = new import_better_sqlite32.default(dbPath);
415
1146
  try {
416
1147
  db.exec(`
417
1148
  CREATE TABLE IF NOT EXISTS workmatic_settings (
@@ -420,12 +1151,14 @@ async function cmdResume(dbPath, queueName) {
420
1151
  updated_at INTEGER NOT NULL
421
1152
  )
422
1153
  `);
423
- const now = Date.now();
424
- db.prepare(`
1154
+ const ts = Date.now();
1155
+ db.prepare(
1156
+ `
425
1157
  INSERT INTO workmatic_settings (queue, paused, updated_at)
426
1158
  VALUES (?, 0, ?)
427
1159
  ON CONFLICT(queue) DO UPDATE SET paused = 0, updated_at = ?
428
- `).run(queueName, now, now);
1160
+ `
1161
+ ).run(queueName, ts, ts);
429
1162
  console.log(`\u25B6\uFE0F Resumed queue '${queueName}'`);
430
1163
  console.log(` Workers will start claiming jobs again.`);
431
1164
  } finally {
@@ -433,14 +1166,17 @@ async function cmdResume(dbPath, queueName) {
433
1166
  }
434
1167
  }
435
1168
  async function cmdQueues(dbPath) {
436
- const db = new import_better_sqlite3.default(dbPath, { readonly: true });
1169
+ const db = new import_better_sqlite32.default(dbPath, { readonly: true });
437
1170
  try {
438
- const settingsExists = db.prepare(`
1171
+ const settingsExists = db.prepare(
1172
+ `
439
1173
  SELECT name FROM sqlite_master WHERE type='table' AND name='workmatic_settings'
440
- `).get();
1174
+ `
1175
+ ).get();
441
1176
  let queues;
442
1177
  if (settingsExists) {
443
- queues = db.prepare(`
1178
+ queues = db.prepare(
1179
+ `
444
1180
  SELECT
445
1181
  j.queue,
446
1182
  COUNT(*) as total,
@@ -450,9 +1186,11 @@ async function cmdQueues(dbPath) {
450
1186
  FROM workmatic_jobs j
451
1187
  LEFT JOIN workmatic_settings s ON j.queue = s.queue
452
1188
  GROUP BY j.queue
453
- `).all();
1189
+ `
1190
+ ).all();
454
1191
  } else {
455
- queues = db.prepare(`
1192
+ queues = db.prepare(
1193
+ `
456
1194
  SELECT
457
1195
  queue,
458
1196
  COUNT(*) as total,
@@ -461,7 +1199,8 @@ async function cmdQueues(dbPath) {
461
1199
  0 as paused
462
1200
  FROM workmatic_jobs
463
1201
  GROUP BY queue
464
- `).all();
1202
+ `
1203
+ ).all();
465
1204
  }
466
1205
  if (queues.length === 0) {
467
1206
  console.log("No queues found.");
@@ -489,11 +1228,115 @@ async function cmdQueues(dbPath) {
489
1228
  db.close();
490
1229
  }
491
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
+ }
492
1330
  async function main() {
493
1331
  if (!command || command === "--help" || command === "-h") {
494
1332
  printUsage();
495
1333
  process.exit(0);
496
1334
  }
1335
+ if (!isCliCommand(command)) {
1336
+ console.error(`Unknown command: ${command}`);
1337
+ printUsage();
1338
+ process.exit(1);
1339
+ }
497
1340
  const positionalArgs = getPositionalArgs(args.slice(1));
498
1341
  const options = parseOptions(args);
499
1342
  const dbPath = positionalArgs[0];
@@ -503,53 +1346,7 @@ async function main() {
503
1346
  process.exit(1);
504
1347
  }
505
1348
  try {
506
- switch (command) {
507
- case "stats":
508
- await cmdStats(dbPath);
509
- break;
510
- case "list":
511
- await cmdList(dbPath, options);
512
- break;
513
- case "export":
514
- await cmdExport(dbPath, positionalArgs[1], options);
515
- break;
516
- case "import":
517
- if (!positionalArgs[1]) {
518
- console.error("Error: Input CSV file is required");
519
- process.exit(1);
520
- }
521
- await cmdImport(dbPath, positionalArgs[1]);
522
- break;
523
- case "purge":
524
- await cmdPurge(dbPath, options);
525
- break;
526
- case "retry":
527
- await cmdRetry(dbPath, options);
528
- break;
529
- case "pause":
530
- if (!positionalArgs[1]) {
531
- console.error("Error: Queue name is required");
532
- console.error("Example: workmatic pause ./jobs.db emails");
533
- process.exit(1);
534
- }
535
- await cmdPause(dbPath, positionalArgs[1]);
536
- break;
537
- case "resume":
538
- if (!positionalArgs[1]) {
539
- console.error("Error: Queue name is required");
540
- console.error("Example: workmatic resume ./jobs.db emails");
541
- process.exit(1);
542
- }
543
- await cmdResume(dbPath, positionalArgs[1]);
544
- break;
545
- case "queues":
546
- await cmdQueues(dbPath);
547
- break;
548
- default:
549
- console.error(`Unknown command: ${command}`);
550
- printUsage();
551
- process.exit(1);
552
- }
1349
+ await runCommand(command, dbPath, positionalArgs, options);
553
1350
  } catch (error) {
554
1351
  console.error("Error:", error instanceof Error ? error.message : error);
555
1352
  process.exit(1);