workmatic 1.0.7 → 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/index.cjs CHANGED
@@ -35,6 +35,7 @@ __export(index_exports, {
35
35
  createDashboard: () => createDashboard,
36
36
  createDashboardMiddleware: () => createDashboardMiddleware,
37
37
  createDatabase: () => createDatabase,
38
+ createOrchestrator: () => createOrchestrator,
38
39
  createWorker: () => createWorker,
39
40
  defaultBackoff: () => defaultBackoff,
40
41
  getUnderlyingDb: () => getUnderlyingDb,
@@ -270,6 +271,29 @@ function createClient(options) {
270
271
  var import_fastq = __toESM(require("fastq"), 1);
271
272
  var import_kysely3 = require("kysely");
272
273
  var DEFAULT_WORKER_TIMEOUT_MS = 6e4;
274
+ function parseClaimedRows(result) {
275
+ const rows = result.rows;
276
+ if (!rows || !Array.isArray(rows)) {
277
+ return [];
278
+ }
279
+ return rows.map((row) => ({
280
+ id: row.id,
281
+ public_id: row.public_id,
282
+ queue: row.queue,
283
+ payload: row.payload,
284
+ attempts: row.attempts,
285
+ max_attempts: row.max_attempts,
286
+ priority: row.priority,
287
+ created_at: row.created_at,
288
+ last_error: row.last_error
289
+ }));
290
+ }
291
+ function requireProcessor(processor) {
292
+ if (!processor) {
293
+ throw new Error("No processor set");
294
+ }
295
+ return processor;
296
+ }
273
297
  function createWorker(options) {
274
298
  const {
275
299
  db,
@@ -354,21 +378,7 @@ function createWorker(options) {
354
378
  )
355
379
  RETURNING id, public_id, queue, payload, attempts, max_attempts, priority, created_at, last_error
356
380
  `.execute(trx);
357
- const rows = result.rows;
358
- if (!rows || !Array.isArray(rows)) {
359
- return [];
360
- }
361
- return rows.map((row) => ({
362
- id: row.id,
363
- public_id: row.public_id,
364
- queue: row.queue,
365
- payload: row.payload,
366
- attempts: row.attempts,
367
- max_attempts: row.max_attempts,
368
- priority: row.priority,
369
- created_at: row.created_at,
370
- last_error: row.last_error
371
- }));
381
+ return parseClaimedRows(result);
372
382
  });
373
383
  }
374
384
  async function markDone(jobId) {
@@ -416,9 +426,7 @@ function createWorker(options) {
416
426
  }
417
427
  }
418
428
  async function processJob(claimedJob) {
419
- if (!processor) {
420
- throw new Error("No processor set");
421
- }
429
+ const fn = requireProcessor(processor);
422
430
  const payload = parsePayload(claimedJob.payload);
423
431
  const job = {
424
432
  id: claimedJob.public_id,
@@ -433,9 +441,9 @@ function createWorker(options) {
433
441
  };
434
442
  try {
435
443
  if (timeoutMs) {
436
- await withTimeout(processor(job), timeoutMs, job.id);
444
+ await withTimeout(fn(job), timeoutMs, job.id);
437
445
  } else {
438
- await processor(job);
446
+ await fn(job);
439
447
  }
440
448
  await markDone(claimedJob.id);
441
449
  } catch (error) {
@@ -593,12 +601,211 @@ function createWorker(options) {
593
601
  return worker;
594
602
  }
595
603
 
604
+ // src/orchestrator.ts
605
+ var import_kysely4 = require("kysely");
606
+ var DEFAULT_TRANSFER_STATUSES = ["ready", "dead"];
607
+ var DEFAULT_TRANSFER_LIMIT = 1e4;
608
+ function rowsUpdated(result) {
609
+ const row = result[0];
610
+ return Number(row?.numUpdatedRows ?? 0);
611
+ }
612
+ function normalizeStatuses(status) {
613
+ if (!status) return [...DEFAULT_TRANSFER_STATUSES];
614
+ return Array.isArray(status) ? status : [status];
615
+ }
616
+ function createOrchestrator(options) {
617
+ const { db } = options;
618
+ if (!db) {
619
+ throw new Error("Database instance is required");
620
+ }
621
+ const registry = /* @__PURE__ */ new Map();
622
+ function ensureEntry(queue) {
623
+ let entry = registry.get(queue);
624
+ if (!entry) {
625
+ entry = { client: createClient({ db, queue }) };
626
+ registry.set(queue, entry);
627
+ }
628
+ return entry;
629
+ }
630
+ async function setQueuePaused(queue, paused) {
631
+ const timestamp = now();
632
+ const value = paused ? 1 : 0;
633
+ await import_kysely4.sql`
634
+ INSERT INTO workmatic_settings (queue, paused, updated_at)
635
+ VALUES (${queue}, ${value}, ${timestamp})
636
+ ON CONFLICT(queue) DO UPDATE SET
637
+ paused = ${value},
638
+ updated_at = ${timestamp}
639
+ `.execute(db);
640
+ }
641
+ async function selectJobIds(from, status, limit) {
642
+ const rows = await db.selectFrom("workmatic_jobs").select("id").where("queue", "=", from).where("status", "=", status).orderBy("priority", "asc").orderBy("id", "asc").limit(limit).execute();
643
+ return rows.map((r) => r.id);
644
+ }
645
+ async function transferReady(from, to, limit) {
646
+ const ids = await selectJobIds(from, "ready", limit);
647
+ if (ids.length === 0) return 0;
648
+ const timestamp = now();
649
+ const result = await db.updateTable("workmatic_jobs").set({ queue: to, updated_at: timestamp }).where("id", "in", ids).execute();
650
+ return rowsUpdated(result);
651
+ }
652
+ async function transferDead(from, to, limit, resetForRetry) {
653
+ const ids = await selectJobIds(from, "dead", limit);
654
+ if (ids.length === 0) return 0;
655
+ const timestamp = now();
656
+ if (resetForRetry) {
657
+ const result2 = await db.updateTable("workmatic_jobs").set({
658
+ queue: to,
659
+ status: "ready",
660
+ attempts: 0,
661
+ lease_until: 0,
662
+ last_error: null,
663
+ updated_at: timestamp,
664
+ run_at: timestamp
665
+ }).where("id", "in", ids).execute();
666
+ return rowsUpdated(result2);
667
+ }
668
+ const result = await db.updateTable("workmatic_jobs").set({ queue: to, updated_at: timestamp }).where("id", "in", ids).execute();
669
+ return rowsUpdated(result);
670
+ }
671
+ async function transferOtherStatus(from, to, status, limit) {
672
+ const ids = await selectJobIds(from, status, limit);
673
+ if (ids.length === 0) return 0;
674
+ const timestamp = now();
675
+ const result = await db.updateTable("workmatic_jobs").set({ queue: to, updated_at: timestamp }).where("id", "in", ids).execute();
676
+ return rowsUpdated(result);
677
+ }
678
+ const orchestrator = {
679
+ register(queue, opts = {}) {
680
+ const client = createClient({ db, queue });
681
+ let worker;
682
+ if (opts.worker) {
683
+ worker = createWorker({ db, queue, ...opts.worker });
684
+ }
685
+ registry.set(queue, { client, worker });
686
+ return client;
687
+ },
688
+ client(queue) {
689
+ return ensureEntry(queue).client;
690
+ },
691
+ worker(queue) {
692
+ const entry = registry.get(queue);
693
+ if (!entry?.worker) {
694
+ throw new Error(`No worker registered for queue "${queue}"`);
695
+ }
696
+ return entry.worker;
697
+ },
698
+ workers() {
699
+ return [...registry.values()].map((e) => e.worker).filter((w) => w !== void 0);
700
+ },
701
+ async queues() {
702
+ const rows = await db.selectFrom("workmatic_jobs").select("queue").distinct().execute();
703
+ const names = new Set(rows.map((r) => r.queue));
704
+ for (const name of registry.keys()) {
705
+ names.add(name);
706
+ }
707
+ return [...names].sort();
708
+ },
709
+ process(queue, fn) {
710
+ this.worker(queue).process(fn);
711
+ },
712
+ startAll() {
713
+ for (const entry of registry.values()) {
714
+ entry.worker?.start();
715
+ }
716
+ },
717
+ async stopAll() {
718
+ const stops = [...registry.values()].map((e) => e.worker?.stop()).filter((p) => p !== void 0);
719
+ await Promise.all(stops);
720
+ },
721
+ async pause(queue) {
722
+ const entry = registry.get(queue);
723
+ entry?.worker?.pause();
724
+ await setQueuePaused(queue, true);
725
+ },
726
+ async resume(queue) {
727
+ const entry = registry.get(queue);
728
+ entry?.worker?.resume();
729
+ await setQueuePaused(queue, false);
730
+ },
731
+ async isPaused(queue) {
732
+ const setting = await db.selectFrom("workmatic_settings").select("paused").where("queue", "=", queue).executeTakeFirst();
733
+ return setting?.paused === 1;
734
+ },
735
+ async stats(queueName) {
736
+ const names = queueName ? [queueName] : await this.queues();
737
+ const result = {};
738
+ for (const name of names) {
739
+ result[name] = await ensureEntry(name).client.stats();
740
+ }
741
+ return result;
742
+ },
743
+ async transfer(opts) {
744
+ const { from, to, resetForRetry = false } = opts;
745
+ if (from === to) {
746
+ return { moved: 0 };
747
+ }
748
+ const statuses = normalizeStatuses(opts.status);
749
+ let remaining = opts.limit ?? DEFAULT_TRANSFER_LIMIT;
750
+ let moved = 0;
751
+ if (statuses.includes("ready") && remaining > 0) {
752
+ const n = await transferReady(from, to, remaining);
753
+ moved += n;
754
+ remaining -= n;
755
+ }
756
+ if (statuses.includes("dead") && remaining > 0) {
757
+ const n = await transferDead(from, to, remaining, resetForRetry);
758
+ moved += n;
759
+ remaining -= n;
760
+ }
761
+ for (const status of statuses) {
762
+ if (status === "ready" || status === "dead" || remaining <= 0) continue;
763
+ const n = await transferOtherStatus(from, to, status, remaining);
764
+ moved += n;
765
+ remaining -= n;
766
+ }
767
+ return { moved };
768
+ },
769
+ async moveJob(publicId, toQueue, opts = {}) {
770
+ const allowed = normalizeStatuses(opts.status);
771
+ const row = await db.selectFrom("workmatic_jobs").select(["id", "queue", "status"]).where("public_id", "=", publicId).executeTakeFirst();
772
+ if (!row) {
773
+ throw new Error(`Job not found: ${publicId}`);
774
+ }
775
+ if (row.queue === toQueue) {
776
+ return;
777
+ }
778
+ if (!allowed.includes(row.status)) {
779
+ throw new Error(
780
+ `Job ${publicId} has status "${row.status}" and cannot be moved (allowed: ${allowed.join(", ")})`
781
+ );
782
+ }
783
+ const timestamp = now();
784
+ const resetForRetry = opts.resetForRetry ?? false;
785
+ if (row.status === "dead" && resetForRetry) {
786
+ await db.updateTable("workmatic_jobs").set({
787
+ queue: toQueue,
788
+ status: "ready",
789
+ attempts: 0,
790
+ lease_until: 0,
791
+ last_error: null,
792
+ updated_at: timestamp,
793
+ run_at: timestamp
794
+ }).where("public_id", "=", publicId).execute();
795
+ return;
796
+ }
797
+ await db.updateTable("workmatic_jobs").set({ queue: toQueue, updated_at: timestamp }).where("public_id", "=", publicId).execute();
798
+ }
799
+ };
800
+ return orchestrator;
801
+ }
802
+
596
803
  // src/dashboard.ts
597
804
  var import_http = require("http");
598
805
  var import_url = require("url");
599
806
  var import_path = require("path");
600
807
  var import_promises = require("fs/promises");
601
- var import_kysely4 = require("kysely");
808
+ var import_kysely5 = require("kysely");
602
809
  var __filename2 = (0, import_url.fileURLToPath)(importMetaUrl);
603
810
  var __dirname = (0, import_path.dirname)(__filename2);
604
811
  var CONTENT_TYPES = {
@@ -607,6 +814,13 @@ var CONTENT_TYPES = {
607
814
  ".js": "application/javascript; charset=utf-8",
608
815
  ".json": "application/json; charset=utf-8"
609
816
  };
817
+ function requestUrl(url) {
818
+ return url || "";
819
+ }
820
+ function staticContentTypeFor(filePath) {
821
+ const ext = filePath.substring(filePath.lastIndexOf(".")) || ".html";
822
+ return CONTENT_TYPES[ext] || "application/octet-stream";
823
+ }
610
824
  function createRequestHandler(db, workerMap, basePath = "") {
611
825
  function sendJson(res, data, status = 200) {
612
826
  res.writeHead(status, { "Content-Type": "application/json" });
@@ -629,7 +843,7 @@ function createRequestHandler(db, workerMap, basePath = "") {
629
843
  return path;
630
844
  }
631
845
  async function handleGetJobs(req, res) {
632
- const query = parseQuery(req.url || "");
846
+ const query = parseQuery(requestUrl(req.url));
633
847
  const queueFilter = query.get("queue");
634
848
  const statusFilter = query.get("status");
635
849
  const limit = Math.min(parseInt(query.get("limit") || "50", 10), 100);
@@ -670,11 +884,11 @@ function createRequestHandler(db, workerMap, basePath = "") {
670
884
  sendJson(res, { jobs: apiJobs, limit, offset });
671
885
  }
672
886
  async function handleGetStats(req, res) {
673
- const query = parseQuery(req.url || "");
887
+ const query = parseQuery(requestUrl(req.url));
674
888
  const queueFilter = query.get("queue");
675
889
  let statsQuery = db.selectFrom("workmatic_jobs").select([
676
890
  "status",
677
- import_kysely4.sql`count(*)`.as("count")
891
+ import_kysely5.sql`count(*)`.as("count")
678
892
  ]).groupBy("status");
679
893
  if (queueFilter) {
680
894
  statsQuery = statsQuery.where("queue", "=", queueFilter);
@@ -756,8 +970,7 @@ function createRequestHandler(db, workerMap, basePath = "") {
756
970
  async function serveStatic(res, filePath) {
757
971
  const dashboardDir = (0, import_path.join)(__dirname, "..", "dashboard");
758
972
  const fullPath = (0, import_path.join)(dashboardDir, filePath);
759
- const ext = filePath.substring(filePath.lastIndexOf(".")) || ".html";
760
- const contentType = CONTENT_TYPES[ext] || "application/octet-stream";
973
+ const contentType = staticContentTypeFor(filePath);
761
974
  try {
762
975
  const content = await (0, import_promises.readFile)(fullPath, "utf-8");
763
976
  res.writeHead(200, { "Content-Type": contentType });
@@ -767,21 +980,21 @@ function createRequestHandler(db, workerMap, basePath = "") {
767
980
  }
768
981
  }
769
982
  return async function handleRequest(req, res, next) {
770
- const path = getPath(req.url || "/");
771
- const fullUrl = req.url || "/";
772
- if (basePath && !fullUrl.startsWith(basePath)) {
773
- if (next) next();
774
- return false;
775
- }
776
- res.setHeader("Access-Control-Allow-Origin", "*");
777
- res.setHeader("Access-Control-Allow-Methods", "GET, POST, OPTIONS");
778
- res.setHeader("Access-Control-Allow-Headers", "Content-Type");
779
- if (req.method === "OPTIONS") {
780
- res.writeHead(204);
781
- res.end();
782
- return true;
783
- }
784
983
  try {
984
+ const path = getPath(req.url || "/");
985
+ const fullUrl = req.url || "/";
986
+ if (basePath && !fullUrl.startsWith(basePath)) {
987
+ if (next) next();
988
+ return false;
989
+ }
990
+ res.setHeader("Access-Control-Allow-Origin", "*");
991
+ res.setHeader("Access-Control-Allow-Methods", "GET, POST, OPTIONS");
992
+ res.setHeader("Access-Control-Allow-Headers", "Content-Type");
993
+ if (req.method === "OPTIONS") {
994
+ res.writeHead(204);
995
+ res.end();
996
+ return true;
997
+ }
785
998
  if (path === "/api/jobs" && req.method === "GET") {
786
999
  await handleGetJobs(req, res);
787
1000
  return true;
@@ -841,11 +1054,7 @@ function createDashboard(options) {
841
1054
  }
842
1055
  const handleRequest = createRequestHandler(db, workerMap);
843
1056
  const server = (0, import_http.createServer)((req, res) => {
844
- handleRequest(req, res).catch((error) => {
845
- console.error("[workmatic] Unhandled error:", error);
846
- res.writeHead(500, { "Content-Type": "application/json" });
847
- res.end(JSON.stringify({ error: "Internal server error" }));
848
- });
1057
+ void handleRequest(req, res);
849
1058
  });
850
1059
  server.listen(port);
851
1060
  return {
@@ -877,15 +1086,7 @@ function createDashboardMiddleware(options) {
877
1086
  }
878
1087
  const handleRequest = createRequestHandler(db, workerMap, basePath);
879
1088
  return (req, res, next) => {
880
- handleRequest(req, res, next).catch((error) => {
881
- console.error("[workmatic] Unhandled error:", error);
882
- if (next) {
883
- next();
884
- } else {
885
- res.writeHead(500, { "Content-Type": "application/json" });
886
- res.end(JSON.stringify({ error: "Internal server error" }));
887
- }
888
- });
1089
+ void handleRequest(req, res, next);
889
1090
  };
890
1091
  }
891
1092
  // Annotate the CommonJS export names for ESM import in node:
@@ -895,6 +1096,7 @@ function createDashboardMiddleware(options) {
895
1096
  createDashboard,
896
1097
  createDashboardMiddleware,
897
1098
  createDatabase,
1099
+ createOrchestrator,
898
1100
  createWorker,
899
1101
  defaultBackoff,
900
1102
  getUnderlyingDb,