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/README.md +60 -0
- package/dist/cli.cjs +903 -104
- package/dist/cli.cjs.map +1 -1
- package/dist/cli.js +903 -104
- package/dist/cli.js.map +1 -1
- package/dist/index.cjs +256 -54
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +78 -1
- package/dist/index.d.ts +78 -1
- package/dist/index.js +255 -54
- package/dist/index.js.map +1 -1
- package/package.json +3 -1
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
|
-
|
|
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
|
-
|
|
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(
|
|
444
|
+
await withTimeout(fn(job), timeoutMs, job.id);
|
|
437
445
|
} else {
|
|
438
|
-
await
|
|
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
|
|
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
|
-
|
|
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
|
|
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)
|
|
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)
|
|
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,
|