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/index.js CHANGED
@@ -222,6 +222,29 @@ function createClient(options) {
222
222
  import fastq from "fastq";
223
223
  import { sql as sql2 } from "kysely";
224
224
  var DEFAULT_WORKER_TIMEOUT_MS = 6e4;
225
+ function parseClaimedRows(result) {
226
+ const rows = result.rows;
227
+ if (!rows || !Array.isArray(rows)) {
228
+ return [];
229
+ }
230
+ return rows.map((row) => ({
231
+ id: row.id,
232
+ public_id: row.public_id,
233
+ queue: row.queue,
234
+ payload: row.payload,
235
+ attempts: row.attempts,
236
+ max_attempts: row.max_attempts,
237
+ priority: row.priority,
238
+ created_at: row.created_at,
239
+ last_error: row.last_error
240
+ }));
241
+ }
242
+ function requireProcessor(processor) {
243
+ if (!processor) {
244
+ throw new Error("No processor set");
245
+ }
246
+ return processor;
247
+ }
225
248
  function createWorker(options) {
226
249
  const {
227
250
  db,
@@ -306,21 +329,7 @@ function createWorker(options) {
306
329
  )
307
330
  RETURNING id, public_id, queue, payload, attempts, max_attempts, priority, created_at, last_error
308
331
  `.execute(trx);
309
- const rows = result.rows;
310
- if (!rows || !Array.isArray(rows)) {
311
- return [];
312
- }
313
- return rows.map((row) => ({
314
- id: row.id,
315
- public_id: row.public_id,
316
- queue: row.queue,
317
- payload: row.payload,
318
- attempts: row.attempts,
319
- max_attempts: row.max_attempts,
320
- priority: row.priority,
321
- created_at: row.created_at,
322
- last_error: row.last_error
323
- }));
332
+ return parseClaimedRows(result);
324
333
  });
325
334
  }
326
335
  async function markDone(jobId) {
@@ -368,9 +377,7 @@ function createWorker(options) {
368
377
  }
369
378
  }
370
379
  async function processJob(claimedJob) {
371
- if (!processor) {
372
- throw new Error("No processor set");
373
- }
380
+ const fn = requireProcessor(processor);
374
381
  const payload = parsePayload(claimedJob.payload);
375
382
  const job = {
376
383
  id: claimedJob.public_id,
@@ -385,9 +392,9 @@ function createWorker(options) {
385
392
  };
386
393
  try {
387
394
  if (timeoutMs) {
388
- await withTimeout(processor(job), timeoutMs, job.id);
395
+ await withTimeout(fn(job), timeoutMs, job.id);
389
396
  } else {
390
- await processor(job);
397
+ await fn(job);
391
398
  }
392
399
  await markDone(claimedJob.id);
393
400
  } catch (error) {
@@ -545,12 +552,211 @@ function createWorker(options) {
545
552
  return worker;
546
553
  }
547
554
 
555
+ // src/orchestrator.ts
556
+ import { sql as sql3 } from "kysely";
557
+ var DEFAULT_TRANSFER_STATUSES = ["ready", "dead"];
558
+ var DEFAULT_TRANSFER_LIMIT = 1e4;
559
+ function rowsUpdated(result) {
560
+ const row = result[0];
561
+ return Number(row?.numUpdatedRows ?? 0);
562
+ }
563
+ function normalizeStatuses(status) {
564
+ if (!status) return [...DEFAULT_TRANSFER_STATUSES];
565
+ return Array.isArray(status) ? status : [status];
566
+ }
567
+ function createOrchestrator(options) {
568
+ const { db } = options;
569
+ if (!db) {
570
+ throw new Error("Database instance is required");
571
+ }
572
+ const registry = /* @__PURE__ */ new Map();
573
+ function ensureEntry(queue) {
574
+ let entry = registry.get(queue);
575
+ if (!entry) {
576
+ entry = { client: createClient({ db, queue }) };
577
+ registry.set(queue, entry);
578
+ }
579
+ return entry;
580
+ }
581
+ async function setQueuePaused(queue, paused) {
582
+ const timestamp = now();
583
+ const value = paused ? 1 : 0;
584
+ await sql3`
585
+ INSERT INTO workmatic_settings (queue, paused, updated_at)
586
+ VALUES (${queue}, ${value}, ${timestamp})
587
+ ON CONFLICT(queue) DO UPDATE SET
588
+ paused = ${value},
589
+ updated_at = ${timestamp}
590
+ `.execute(db);
591
+ }
592
+ async function selectJobIds(from, status, limit) {
593
+ const rows = await db.selectFrom("workmatic_jobs").select("id").where("queue", "=", from).where("status", "=", status).orderBy("priority", "asc").orderBy("id", "asc").limit(limit).execute();
594
+ return rows.map((r) => r.id);
595
+ }
596
+ async function transferReady(from, to, limit) {
597
+ const ids = await selectJobIds(from, "ready", limit);
598
+ if (ids.length === 0) return 0;
599
+ const timestamp = now();
600
+ const result = await db.updateTable("workmatic_jobs").set({ queue: to, updated_at: timestamp }).where("id", "in", ids).execute();
601
+ return rowsUpdated(result);
602
+ }
603
+ async function transferDead(from, to, limit, resetForRetry) {
604
+ const ids = await selectJobIds(from, "dead", limit);
605
+ if (ids.length === 0) return 0;
606
+ const timestamp = now();
607
+ if (resetForRetry) {
608
+ const result2 = await db.updateTable("workmatic_jobs").set({
609
+ queue: to,
610
+ status: "ready",
611
+ attempts: 0,
612
+ lease_until: 0,
613
+ last_error: null,
614
+ updated_at: timestamp,
615
+ run_at: timestamp
616
+ }).where("id", "in", ids).execute();
617
+ return rowsUpdated(result2);
618
+ }
619
+ const result = await db.updateTable("workmatic_jobs").set({ queue: to, updated_at: timestamp }).where("id", "in", ids).execute();
620
+ return rowsUpdated(result);
621
+ }
622
+ async function transferOtherStatus(from, to, status, limit) {
623
+ const ids = await selectJobIds(from, status, limit);
624
+ if (ids.length === 0) return 0;
625
+ const timestamp = now();
626
+ const result = await db.updateTable("workmatic_jobs").set({ queue: to, updated_at: timestamp }).where("id", "in", ids).execute();
627
+ return rowsUpdated(result);
628
+ }
629
+ const orchestrator = {
630
+ register(queue, opts = {}) {
631
+ const client = createClient({ db, queue });
632
+ let worker;
633
+ if (opts.worker) {
634
+ worker = createWorker({ db, queue, ...opts.worker });
635
+ }
636
+ registry.set(queue, { client, worker });
637
+ return client;
638
+ },
639
+ client(queue) {
640
+ return ensureEntry(queue).client;
641
+ },
642
+ worker(queue) {
643
+ const entry = registry.get(queue);
644
+ if (!entry?.worker) {
645
+ throw new Error(`No worker registered for queue "${queue}"`);
646
+ }
647
+ return entry.worker;
648
+ },
649
+ workers() {
650
+ return [...registry.values()].map((e) => e.worker).filter((w) => w !== void 0);
651
+ },
652
+ async queues() {
653
+ const rows = await db.selectFrom("workmatic_jobs").select("queue").distinct().execute();
654
+ const names = new Set(rows.map((r) => r.queue));
655
+ for (const name of registry.keys()) {
656
+ names.add(name);
657
+ }
658
+ return [...names].sort();
659
+ },
660
+ process(queue, fn) {
661
+ this.worker(queue).process(fn);
662
+ },
663
+ startAll() {
664
+ for (const entry of registry.values()) {
665
+ entry.worker?.start();
666
+ }
667
+ },
668
+ async stopAll() {
669
+ const stops = [...registry.values()].map((e) => e.worker?.stop()).filter((p) => p !== void 0);
670
+ await Promise.all(stops);
671
+ },
672
+ async pause(queue) {
673
+ const entry = registry.get(queue);
674
+ entry?.worker?.pause();
675
+ await setQueuePaused(queue, true);
676
+ },
677
+ async resume(queue) {
678
+ const entry = registry.get(queue);
679
+ entry?.worker?.resume();
680
+ await setQueuePaused(queue, false);
681
+ },
682
+ async isPaused(queue) {
683
+ const setting = await db.selectFrom("workmatic_settings").select("paused").where("queue", "=", queue).executeTakeFirst();
684
+ return setting?.paused === 1;
685
+ },
686
+ async stats(queueName) {
687
+ const names = queueName ? [queueName] : await this.queues();
688
+ const result = {};
689
+ for (const name of names) {
690
+ result[name] = await ensureEntry(name).client.stats();
691
+ }
692
+ return result;
693
+ },
694
+ async transfer(opts) {
695
+ const { from, to, resetForRetry = false } = opts;
696
+ if (from === to) {
697
+ return { moved: 0 };
698
+ }
699
+ const statuses = normalizeStatuses(opts.status);
700
+ let remaining = opts.limit ?? DEFAULT_TRANSFER_LIMIT;
701
+ let moved = 0;
702
+ if (statuses.includes("ready") && remaining > 0) {
703
+ const n = await transferReady(from, to, remaining);
704
+ moved += n;
705
+ remaining -= n;
706
+ }
707
+ if (statuses.includes("dead") && remaining > 0) {
708
+ const n = await transferDead(from, to, remaining, resetForRetry);
709
+ moved += n;
710
+ remaining -= n;
711
+ }
712
+ for (const status of statuses) {
713
+ if (status === "ready" || status === "dead" || remaining <= 0) continue;
714
+ const n = await transferOtherStatus(from, to, status, remaining);
715
+ moved += n;
716
+ remaining -= n;
717
+ }
718
+ return { moved };
719
+ },
720
+ async moveJob(publicId, toQueue, opts = {}) {
721
+ const allowed = normalizeStatuses(opts.status);
722
+ const row = await db.selectFrom("workmatic_jobs").select(["id", "queue", "status"]).where("public_id", "=", publicId).executeTakeFirst();
723
+ if (!row) {
724
+ throw new Error(`Job not found: ${publicId}`);
725
+ }
726
+ if (row.queue === toQueue) {
727
+ return;
728
+ }
729
+ if (!allowed.includes(row.status)) {
730
+ throw new Error(
731
+ `Job ${publicId} has status "${row.status}" and cannot be moved (allowed: ${allowed.join(", ")})`
732
+ );
733
+ }
734
+ const timestamp = now();
735
+ const resetForRetry = opts.resetForRetry ?? false;
736
+ if (row.status === "dead" && resetForRetry) {
737
+ await db.updateTable("workmatic_jobs").set({
738
+ queue: toQueue,
739
+ status: "ready",
740
+ attempts: 0,
741
+ lease_until: 0,
742
+ last_error: null,
743
+ updated_at: timestamp,
744
+ run_at: timestamp
745
+ }).where("public_id", "=", publicId).execute();
746
+ return;
747
+ }
748
+ await db.updateTable("workmatic_jobs").set({ queue: toQueue, updated_at: timestamp }).where("public_id", "=", publicId).execute();
749
+ }
750
+ };
751
+ return orchestrator;
752
+ }
753
+
548
754
  // src/dashboard.ts
549
755
  import { createServer } from "http";
550
756
  import { fileURLToPath } from "url";
551
757
  import { dirname, join } from "path";
552
758
  import { readFile } from "fs/promises";
553
- import { sql as sql3 } from "kysely";
759
+ import { sql as sql4 } from "kysely";
554
760
  var __filename2 = fileURLToPath(import.meta.url);
555
761
  var __dirname2 = dirname(__filename2);
556
762
  var CONTENT_TYPES = {
@@ -559,6 +765,13 @@ var CONTENT_TYPES = {
559
765
  ".js": "application/javascript; charset=utf-8",
560
766
  ".json": "application/json; charset=utf-8"
561
767
  };
768
+ function requestUrl(url) {
769
+ return url || "";
770
+ }
771
+ function staticContentTypeFor(filePath) {
772
+ const ext = filePath.substring(filePath.lastIndexOf(".")) || ".html";
773
+ return CONTENT_TYPES[ext] || "application/octet-stream";
774
+ }
562
775
  function createRequestHandler(db, workerMap, basePath = "") {
563
776
  function sendJson(res, data, status = 200) {
564
777
  res.writeHead(status, { "Content-Type": "application/json" });
@@ -581,7 +794,7 @@ function createRequestHandler(db, workerMap, basePath = "") {
581
794
  return path;
582
795
  }
583
796
  async function handleGetJobs(req, res) {
584
- const query = parseQuery(req.url || "");
797
+ const query = parseQuery(requestUrl(req.url));
585
798
  const queueFilter = query.get("queue");
586
799
  const statusFilter = query.get("status");
587
800
  const limit = Math.min(parseInt(query.get("limit") || "50", 10), 100);
@@ -622,11 +835,11 @@ function createRequestHandler(db, workerMap, basePath = "") {
622
835
  sendJson(res, { jobs: apiJobs, limit, offset });
623
836
  }
624
837
  async function handleGetStats(req, res) {
625
- const query = parseQuery(req.url || "");
838
+ const query = parseQuery(requestUrl(req.url));
626
839
  const queueFilter = query.get("queue");
627
840
  let statsQuery = db.selectFrom("workmatic_jobs").select([
628
841
  "status",
629
- sql3`count(*)`.as("count")
842
+ sql4`count(*)`.as("count")
630
843
  ]).groupBy("status");
631
844
  if (queueFilter) {
632
845
  statsQuery = statsQuery.where("queue", "=", queueFilter);
@@ -708,8 +921,7 @@ function createRequestHandler(db, workerMap, basePath = "") {
708
921
  async function serveStatic(res, filePath) {
709
922
  const dashboardDir = join(__dirname2, "..", "dashboard");
710
923
  const fullPath = join(dashboardDir, filePath);
711
- const ext = filePath.substring(filePath.lastIndexOf(".")) || ".html";
712
- const contentType = CONTENT_TYPES[ext] || "application/octet-stream";
924
+ const contentType = staticContentTypeFor(filePath);
713
925
  try {
714
926
  const content = await readFile(fullPath, "utf-8");
715
927
  res.writeHead(200, { "Content-Type": contentType });
@@ -719,21 +931,21 @@ function createRequestHandler(db, workerMap, basePath = "") {
719
931
  }
720
932
  }
721
933
  return async function handleRequest(req, res, next) {
722
- const path = getPath(req.url || "/");
723
- const fullUrl = req.url || "/";
724
- if (basePath && !fullUrl.startsWith(basePath)) {
725
- if (next) next();
726
- return false;
727
- }
728
- res.setHeader("Access-Control-Allow-Origin", "*");
729
- res.setHeader("Access-Control-Allow-Methods", "GET, POST, OPTIONS");
730
- res.setHeader("Access-Control-Allow-Headers", "Content-Type");
731
- if (req.method === "OPTIONS") {
732
- res.writeHead(204);
733
- res.end();
734
- return true;
735
- }
736
934
  try {
935
+ const path = getPath(req.url || "/");
936
+ const fullUrl = req.url || "/";
937
+ if (basePath && !fullUrl.startsWith(basePath)) {
938
+ if (next) next();
939
+ return false;
940
+ }
941
+ res.setHeader("Access-Control-Allow-Origin", "*");
942
+ res.setHeader("Access-Control-Allow-Methods", "GET, POST, OPTIONS");
943
+ res.setHeader("Access-Control-Allow-Headers", "Content-Type");
944
+ if (req.method === "OPTIONS") {
945
+ res.writeHead(204);
946
+ res.end();
947
+ return true;
948
+ }
737
949
  if (path === "/api/jobs" && req.method === "GET") {
738
950
  await handleGetJobs(req, res);
739
951
  return true;
@@ -793,11 +1005,7 @@ function createDashboard(options) {
793
1005
  }
794
1006
  const handleRequest = createRequestHandler(db, workerMap);
795
1007
  const server = createServer((req, res) => {
796
- handleRequest(req, res).catch((error) => {
797
- console.error("[workmatic] Unhandled error:", error);
798
- res.writeHead(500, { "Content-Type": "application/json" });
799
- res.end(JSON.stringify({ error: "Internal server error" }));
800
- });
1008
+ void handleRequest(req, res);
801
1009
  });
802
1010
  server.listen(port);
803
1011
  return {
@@ -829,15 +1037,7 @@ function createDashboardMiddleware(options) {
829
1037
  }
830
1038
  const handleRequest = createRequestHandler(db, workerMap, basePath);
831
1039
  return (req, res, next) => {
832
- handleRequest(req, res, next).catch((error) => {
833
- console.error("[workmatic] Unhandled error:", error);
834
- if (next) {
835
- next();
836
- } else {
837
- res.writeHead(500, { "Content-Type": "application/json" });
838
- res.end(JSON.stringify({ error: "Internal server error" }));
839
- }
840
- });
1040
+ void handleRequest(req, res, next);
841
1041
  };
842
1042
  }
843
1043
  export {
@@ -846,6 +1046,7 @@ export {
846
1046
  createDashboard,
847
1047
  createDashboardMiddleware,
848
1048
  createDatabase,
1049
+ createOrchestrator,
849
1050
  createWorker,
850
1051
  defaultBackoff,
851
1052
  getUnderlyingDb,