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