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.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
@@ -21,23 +760,21 @@ Commands:
21
760
  export <db> [output.csv] Export jobs to CSV (default: stdout)
22
761
  import <db> <input.csv> Import jobs from CSV
23
762
  purge <db> --status=<status> Delete jobs by status
24
- retry <db> [--status=dead] Retry dead/failed jobs (reset to ready)
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|failed|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=failed > failed-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,28 +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
- const statusOrder = ["ready", "running", "done", "failed", "dead"];
113
- const statusEmoji = {
114
- ready: "\u23F3",
115
- running: "\u25B6\uFE0F",
116
- done: "\u2705",
117
- failed: "\u26A0\uFE0F",
118
- dead: "\u{1F480}"
119
- };
853
+ const statusOrder = ["ready", "running", "done", "dead"];
120
854
  const statusTable = new Table({
121
855
  head: ["", "Status", "Count"],
122
856
  style: { head: ["cyan"], border: ["gray"] },
@@ -125,7 +859,7 @@ async function cmdStats(dbPath) {
125
859
  for (const status of statusOrder) {
126
860
  const stat = stats.find((s) => s.status === status);
127
861
  const count = stat?.count ?? 0;
128
- const emoji = statusEmoji[status] || "";
862
+ const emoji = jobStatusEmoji(status);
129
863
  statusTable.push([emoji, status, count.toLocaleString()]);
130
864
  }
131
865
  statusTable.push([{ colSpan: 2, content: "Total", hAlign: "right" }, total.toLocaleString()]);
@@ -148,7 +882,7 @@ async function cmdStats(dbPath) {
148
882
  }
149
883
  }
150
884
  async function cmdList(dbPath, options) {
151
- const db = new Database(dbPath, { readonly: true });
885
+ const db = new Database2(dbPath, { readonly: true });
152
886
  try {
153
887
  let query = "SELECT public_id, queue, status, priority, attempts, max_attempts, created_at, last_error FROM workmatic_jobs WHERE 1=1";
154
888
  const params = [];
@@ -167,13 +901,6 @@ async function cmdList(dbPath, options) {
167
901
  console.log("No jobs found.");
168
902
  return;
169
903
  }
170
- const statusEmoji = {
171
- ready: "\u23F3",
172
- running: "\u25B6\uFE0F",
173
- done: "\u2705",
174
- failed: "\u26A0\uFE0F",
175
- dead: "\u{1F480}"
176
- };
177
904
  const table = new Table({
178
905
  head: ["ID", "Queue", "Status", "Pri", "Attempts", "Created"],
179
906
  style: { head: ["cyan"], border: ["gray"] },
@@ -182,7 +909,7 @@ async function cmdList(dbPath, options) {
182
909
  wordWrap: true
183
910
  });
184
911
  for (const job of jobs) {
185
- const emoji = statusEmoji[job.status] || "";
912
+ const emoji = jobStatusEmoji(job.status);
186
913
  table.push([
187
914
  job.public_id.slice(0, 21),
188
915
  job.queue.slice(0, 14),
@@ -202,7 +929,7 @@ async function cmdList(dbPath, options) {
202
929
  }
203
930
  }
204
931
  async function cmdExport(dbPath, outputPath, options) {
205
- const db = new Database(dbPath, { readonly: true });
932
+ const db = new Database2(dbPath, { readonly: true });
206
933
  try {
207
934
  let query = "SELECT * FROM workmatic_jobs WHERE 1=1";
208
935
  const params = [];
@@ -245,7 +972,9 @@ async function cmdExport(dbPath, outputPath, options) {
245
972
  }
246
973
  }
247
974
  if (outputPath) {
248
- output.end();
975
+ const stream = output;
976
+ stream.end();
977
+ await finished(stream);
249
978
  console.error(`\u2705 Exported ${jobs.length} jobs to ${outputPath}`);
250
979
  }
251
980
  } finally {
@@ -253,7 +982,7 @@ async function cmdExport(dbPath, outputPath, options) {
253
982
  }
254
983
  }
255
984
  async function cmdImport(dbPath, inputPath) {
256
- const db = new Database(dbPath);
985
+ const db = new Database2(dbPath);
257
986
  db.exec(`
258
987
  CREATE TABLE IF NOT EXISTS workmatic_jobs (
259
988
  id INTEGER PRIMARY KEY AUTOINCREMENT,
@@ -334,11 +1063,9 @@ async function cmdImport(dbPath, inputPath) {
334
1063
  }
335
1064
  async function cmdPurge(dbPath, options) {
336
1065
  if (!options.status) {
337
- console.error("Error: --status is required for purge command");
338
- console.error("Example: workmatic purge ./jobs.db --status=done");
339
- process.exit(1);
1066
+ throw new Error("--status is required for purge command");
340
1067
  }
341
- const db = new Database(dbPath);
1068
+ const db = new Database2(dbPath);
342
1069
  try {
343
1070
  const result = db.prepare("DELETE FROM workmatic_jobs WHERE status = ?").run(options.status);
344
1071
  console.log(`\u{1F5D1}\uFE0F Deleted ${result.changes} jobs with status '${options.status}'`);
@@ -348,10 +1075,11 @@ async function cmdPurge(dbPath, options) {
348
1075
  }
349
1076
  async function cmdRetry(dbPath, options) {
350
1077
  const status = options.status || "dead";
351
- const db = new Database(dbPath);
1078
+ const db = new Database2(dbPath);
352
1079
  try {
353
- const now = Date.now();
354
- const result = db.prepare(`
1080
+ const ts = Date.now();
1081
+ const result = db.prepare(
1082
+ `
355
1083
  UPDATE workmatic_jobs
356
1084
  SET status = 'ready',
357
1085
  attempts = 0,
@@ -359,14 +1087,15 @@ async function cmdRetry(dbPath, options) {
359
1087
  lease_until = 0,
360
1088
  updated_at = ?
361
1089
  WHERE status = ?
362
- `).run(now, now, status);
1090
+ `
1091
+ ).run(ts, ts, status);
363
1092
  console.log(`\u{1F504} Reset ${result.changes} jobs from '${status}' to 'ready'`);
364
1093
  } finally {
365
1094
  db.close();
366
1095
  }
367
1096
  }
368
1097
  async function cmdPause(dbPath, queueName) {
369
- const db = new Database(dbPath);
1098
+ const db = new Database2(dbPath);
370
1099
  try {
371
1100
  db.exec(`
372
1101
  CREATE TABLE IF NOT EXISTS workmatic_settings (
@@ -375,12 +1104,14 @@ async function cmdPause(dbPath, queueName) {
375
1104
  updated_at INTEGER NOT NULL
376
1105
  )
377
1106
  `);
378
- const now = Date.now();
379
- db.prepare(`
1107
+ const ts = Date.now();
1108
+ db.prepare(
1109
+ `
380
1110
  INSERT INTO workmatic_settings (queue, paused, updated_at)
381
1111
  VALUES (?, 1, ?)
382
1112
  ON CONFLICT(queue) DO UPDATE SET paused = 1, updated_at = ?
383
- `).run(queueName, now, now);
1113
+ `
1114
+ ).run(queueName, ts, ts);
384
1115
  console.log(`\u23F8\uFE0F Paused queue '${queueName}'`);
385
1116
  console.log(` Running workers will stop claiming new jobs.`);
386
1117
  } finally {
@@ -388,7 +1119,7 @@ async function cmdPause(dbPath, queueName) {
388
1119
  }
389
1120
  }
390
1121
  async function cmdResume(dbPath, queueName) {
391
- const db = new Database(dbPath);
1122
+ const db = new Database2(dbPath);
392
1123
  try {
393
1124
  db.exec(`
394
1125
  CREATE TABLE IF NOT EXISTS workmatic_settings (
@@ -397,12 +1128,14 @@ async function cmdResume(dbPath, queueName) {
397
1128
  updated_at INTEGER NOT NULL
398
1129
  )
399
1130
  `);
400
- const now = Date.now();
401
- db.prepare(`
1131
+ const ts = Date.now();
1132
+ db.prepare(
1133
+ `
402
1134
  INSERT INTO workmatic_settings (queue, paused, updated_at)
403
1135
  VALUES (?, 0, ?)
404
1136
  ON CONFLICT(queue) DO UPDATE SET paused = 0, updated_at = ?
405
- `).run(queueName, now, now);
1137
+ `
1138
+ ).run(queueName, ts, ts);
406
1139
  console.log(`\u25B6\uFE0F Resumed queue '${queueName}'`);
407
1140
  console.log(` Workers will start claiming jobs again.`);
408
1141
  } finally {
@@ -410,14 +1143,17 @@ async function cmdResume(dbPath, queueName) {
410
1143
  }
411
1144
  }
412
1145
  async function cmdQueues(dbPath) {
413
- const db = new Database(dbPath, { readonly: true });
1146
+ const db = new Database2(dbPath, { readonly: true });
414
1147
  try {
415
- const settingsExists = db.prepare(`
1148
+ const settingsExists = db.prepare(
1149
+ `
416
1150
  SELECT name FROM sqlite_master WHERE type='table' AND name='workmatic_settings'
417
- `).get();
1151
+ `
1152
+ ).get();
418
1153
  let queues;
419
1154
  if (settingsExists) {
420
- queues = db.prepare(`
1155
+ queues = db.prepare(
1156
+ `
421
1157
  SELECT
422
1158
  j.queue,
423
1159
  COUNT(*) as total,
@@ -427,9 +1163,11 @@ async function cmdQueues(dbPath) {
427
1163
  FROM workmatic_jobs j
428
1164
  LEFT JOIN workmatic_settings s ON j.queue = s.queue
429
1165
  GROUP BY j.queue
430
- `).all();
1166
+ `
1167
+ ).all();
431
1168
  } else {
432
- queues = db.prepare(`
1169
+ queues = db.prepare(
1170
+ `
433
1171
  SELECT
434
1172
  queue,
435
1173
  COUNT(*) as total,
@@ -438,7 +1176,8 @@ async function cmdQueues(dbPath) {
438
1176
  0 as paused
439
1177
  FROM workmatic_jobs
440
1178
  GROUP BY queue
441
- `).all();
1179
+ `
1180
+ ).all();
442
1181
  }
443
1182
  if (queues.length === 0) {
444
1183
  console.log("No queues found.");
@@ -466,11 +1205,115 @@ async function cmdQueues(dbPath) {
466
1205
  db.close();
467
1206
  }
468
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
+ }
469
1307
  async function main() {
470
1308
  if (!command || command === "--help" || command === "-h") {
471
1309
  printUsage();
472
1310
  process.exit(0);
473
1311
  }
1312
+ if (!isCliCommand(command)) {
1313
+ console.error(`Unknown command: ${command}`);
1314
+ printUsage();
1315
+ process.exit(1);
1316
+ }
474
1317
  const positionalArgs = getPositionalArgs(args.slice(1));
475
1318
  const options = parseOptions(args);
476
1319
  const dbPath = positionalArgs[0];
@@ -480,53 +1323,7 @@ async function main() {
480
1323
  process.exit(1);
481
1324
  }
482
1325
  try {
483
- switch (command) {
484
- case "stats":
485
- await cmdStats(dbPath);
486
- break;
487
- case "list":
488
- await cmdList(dbPath, options);
489
- break;
490
- case "export":
491
- await cmdExport(dbPath, positionalArgs[1], options);
492
- break;
493
- case "import":
494
- if (!positionalArgs[1]) {
495
- console.error("Error: Input CSV file is required");
496
- process.exit(1);
497
- }
498
- await cmdImport(dbPath, positionalArgs[1]);
499
- break;
500
- case "purge":
501
- await cmdPurge(dbPath, options);
502
- break;
503
- case "retry":
504
- await cmdRetry(dbPath, options);
505
- break;
506
- case "pause":
507
- if (!positionalArgs[1]) {
508
- console.error("Error: Queue name is required");
509
- console.error("Example: workmatic pause ./jobs.db emails");
510
- process.exit(1);
511
- }
512
- await cmdPause(dbPath, positionalArgs[1]);
513
- break;
514
- case "resume":
515
- if (!positionalArgs[1]) {
516
- console.error("Error: Queue name is required");
517
- console.error("Example: workmatic resume ./jobs.db emails");
518
- process.exit(1);
519
- }
520
- await cmdResume(dbPath, positionalArgs[1]);
521
- break;
522
- case "queues":
523
- await cmdQueues(dbPath);
524
- break;
525
- default:
526
- console.error(`Unknown command: ${command}`);
527
- printUsage();
528
- process.exit(1);
529
- }
1326
+ await runCommand(command, dbPath, positionalArgs, options);
530
1327
  } catch (error) {
531
1328
  console.error("Error:", error instanceof Error ? error.message : error);
532
1329
  process.exit(1);