@terreno/jobs 57.4.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.
Files changed (83) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +56 -0
  3. package/biome.jsonc +20 -0
  4. package/bunfig.toml +5 -0
  5. package/dist/activeJobExecution.d.ts +3 -0
  6. package/dist/activeJobExecution.js +29 -0
  7. package/dist/claimLock.d.ts +16 -0
  8. package/dist/claimLock.js +34 -0
  9. package/dist/dispatchCompensation.d.ts +3 -0
  10. package/dist/dispatchCompensation.js +89 -0
  11. package/dist/dispatchError.d.ts +10 -0
  12. package/dist/dispatchError.js +33 -0
  13. package/dist/index.d.ts +9 -0
  14. package/dist/index.js +15 -0
  15. package/dist/jobExecutor.d.ts +34 -0
  16. package/dist/jobExecutor.js +328 -0
  17. package/dist/jobsApp.d.ts +61 -0
  18. package/dist/jobsApp.js +308 -0
  19. package/dist/jobsService.d.ts +38 -0
  20. package/dist/jobsService.js +505 -0
  21. package/dist/modelTypes.d.ts +52 -0
  22. package/dist/modelTypes.js +4 -0
  23. package/dist/models/job.d.ts +2 -0
  24. package/dist/models/job.js +124 -0
  25. package/dist/models/jobSchedule.d.ts +2 -0
  26. package/dist/models/jobSchedule.js +48 -0
  27. package/dist/retryBackoff.d.ts +17 -0
  28. package/dist/retryBackoff.js +22 -0
  29. package/dist/routes/jobsAdmin.d.ts +15 -0
  30. package/dist/routes/jobsAdmin.js +596 -0
  31. package/dist/routes/jobsExecute.d.ts +12 -0
  32. package/dist/routes/jobsExecute.js +119 -0
  33. package/dist/runners/gcpCloudTasks.d.ts +78 -0
  34. package/dist/runners/gcpCloudTasks.js +256 -0
  35. package/dist/runners/mongoRunner.d.ts +14 -0
  36. package/dist/runners/mongoRunner.js +204 -0
  37. package/dist/runners/vercelQueues.d.ts +111 -0
  38. package/dist/runners/vercelQueues.js +326 -0
  39. package/dist/scheduleCron.d.ts +15 -0
  40. package/dist/scheduleCron.js +61 -0
  41. package/dist/scheduler.d.ts +8 -0
  42. package/dist/scheduler.js +178 -0
  43. package/dist/types.d.ts +64 -0
  44. package/dist/types.js +2 -0
  45. package/package.json +82 -0
  46. package/src/activeJobExecution.ts +28 -0
  47. package/src/claimLock.ts +37 -0
  48. package/src/dispatchCompensation.ts +39 -0
  49. package/src/dispatchError.ts +14 -0
  50. package/src/index.ts +24 -0
  51. package/src/jobExecutor.ts +333 -0
  52. package/src/jobsApp.ts +255 -0
  53. package/src/jobsService.ts +395 -0
  54. package/src/modelTypes.ts +78 -0
  55. package/src/models/job.ts +129 -0
  56. package/src/models/jobSchedule.ts +57 -0
  57. package/src/retryBackoff.ts +34 -0
  58. package/src/routes/jobsAdmin.ts +562 -0
  59. package/src/routes/jobsExecute.ts +95 -0
  60. package/src/runners/gcpCloudTasks.ts +290 -0
  61. package/src/runners/mongoRunner.ts +135 -0
  62. package/src/runners/vercelQueues.ts +394 -0
  63. package/src/scheduleCron.ts +65 -0
  64. package/src/scheduler.ts +112 -0
  65. package/src/tests/bunSetup.ts +9 -0
  66. package/src/tests/gcpCloudTasksRunner.test.ts +349 -0
  67. package/src/tests/jobModel.test.ts +62 -0
  68. package/src/tests/jobScheduleModel.test.ts +44 -0
  69. package/src/tests/jobsAdmin.test.ts +728 -0
  70. package/src/tests/jobsApp.test.ts +113 -0
  71. package/src/tests/jobsCustomRunner.test.ts +367 -0
  72. package/src/tests/jobsDelayIdempotency.test.ts +255 -0
  73. package/src/tests/jobsExecute.test.ts +640 -0
  74. package/src/tests/jobsRetry.test.ts +420 -0
  75. package/src/tests/jobsSchedule.test.ts +505 -0
  76. package/src/tests/jobsTracer.test.ts +165 -0
  77. package/src/tests/jobsWorkerLifecycle.test.ts +509 -0
  78. package/src/tests/packageExports.test.ts +24 -0
  79. package/src/tests/retryBackoff.test.ts +76 -0
  80. package/src/tests/scheduleCron.test.ts +87 -0
  81. package/src/tests/vercelQueuesRunner.test.ts +524 -0
  82. package/src/types.ts +74 -0
  83. package/tsconfig.json +33 -0
@@ -0,0 +1,178 @@
1
+ "use strict";
2
+ var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
3
+ function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
4
+ return new (P || (P = Promise))(function (resolve, reject) {
5
+ function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
6
+ function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
7
+ function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
8
+ step((generator = generator.apply(thisArg, _arguments || [])).next());
9
+ });
10
+ };
11
+ var __generator = (this && this.__generator) || function (thisArg, body) {
12
+ var _ = { label: 0, sent: function() { if (t[0] & 1) throw t[1]; return t[1]; }, trys: [], ops: [] }, f, y, t, g = Object.create((typeof Iterator === "function" ? Iterator : Object).prototype);
13
+ return g.next = verb(0), g["throw"] = verb(1), g["return"] = verb(2), typeof Symbol === "function" && (g[Symbol.iterator] = function() { return this; }), g;
14
+ function verb(n) { return function (v) { return step([n, v]); }; }
15
+ function step(op) {
16
+ if (f) throw new TypeError("Generator is already executing.");
17
+ while (g && (g = 0, op[0] && (_ = 0)), _) try {
18
+ if (f = 1, y && (t = op[0] & 2 ? y["return"] : op[0] ? y["throw"] || ((t = y["return"]) && t.call(y), 0) : y.next) && !(t = t.call(y, op[1])).done) return t;
19
+ if (y = 0, t) op = [op[0] & 2, t.value];
20
+ switch (op[0]) {
21
+ case 0: case 1: t = op; break;
22
+ case 4: _.label++; return { value: op[1], done: false };
23
+ case 5: _.label++; y = op[1]; op = [0]; continue;
24
+ case 7: op = _.ops.pop(); _.trys.pop(); continue;
25
+ default:
26
+ if (!(t = _.trys, t = t.length > 0 && t[t.length - 1]) && (op[0] === 6 || op[0] === 2)) { _ = 0; continue; }
27
+ if (op[0] === 3 && (!t || (op[1] > t[0] && op[1] < t[3]))) { _.label = op[1]; break; }
28
+ if (op[0] === 6 && _.label < t[1]) { _.label = t[1]; t = op; break; }
29
+ if (t && _.label < t[2]) { _.label = t[2]; _.ops.push(op); break; }
30
+ if (t[2]) _.ops.pop();
31
+ _.trys.pop(); continue;
32
+ }
33
+ op = body.call(thisArg, _);
34
+ } catch (e) { op = [6, e]; y = 0; } finally { f = t = 0; }
35
+ if (op[0] & 5) throw op[1]; return { value: op[0] ? op[1] : void 0, done: true };
36
+ }
37
+ };
38
+ var __values = (this && this.__values) || function(o) {
39
+ var s = typeof Symbol === "function" && Symbol.iterator, m = s && o[s], i = 0;
40
+ if (m) return m.call(o);
41
+ if (o && typeof o.length === "number") return {
42
+ next: function () {
43
+ if (o && i >= o.length) o = void 0;
44
+ return { value: o && o[i++], done: !o };
45
+ }
46
+ };
47
+ throw new TypeError(s ? "Object is not iterable." : "Symbol.iterator is not defined.");
48
+ };
49
+ Object.defineProperty(exports, "__esModule", { value: true });
50
+ exports.tickDueSchedules = exports.buildScheduleRunIdempotencyKey = void 0;
51
+ var api_1 = require("@terreno/api");
52
+ var luxon_1 = require("luxon");
53
+ var dispatchError_1 = require("./dispatchError");
54
+ var job_1 = require("./models/job");
55
+ var jobSchedule_1 = require("./models/jobSchedule");
56
+ var modelTypes_1 = require("./modelTypes");
57
+ var scheduleCron_1 = require("./scheduleCron");
58
+ /** @internal */
59
+ var buildScheduleRunIdempotencyKey = function (scheduleId, dueRunAt) {
60
+ return "schedule:".concat(scheduleId, ":").concat(dueRunAt.toISOString());
61
+ };
62
+ exports.buildScheduleRunIdempotencyKey = buildScheduleRunIdempotencyKey;
63
+ var tickOneSchedule = function (_a) { return __awaiter(void 0, [_a], void 0, function (_b) {
64
+ var dueRunAt, advancedNextRunAt, claimed, inFlight, idempotencyKey, error_1, shouldRollbackSchedule;
65
+ var jobsService = _b.jobsService, now = _b.now, schedule = _b.schedule;
66
+ return __generator(this, function (_c) {
67
+ switch (_c.label) {
68
+ case 0:
69
+ dueRunAt = schedule.nextRunAt;
70
+ advancedNextRunAt = (0, scheduleCron_1.computeNextRunAtAfter)(schedule.cron, schedule.timezone, now);
71
+ return [4 /*yield*/, jobSchedule_1.JobSchedule.findOneAndUpdate({
72
+ _id: schedule._id,
73
+ enabled: true,
74
+ nextRunAt: dueRunAt,
75
+ }, {
76
+ $set: {
77
+ nextRunAt: advancedNextRunAt,
78
+ },
79
+ }, { returnDocument: "before" })];
80
+ case 1:
81
+ claimed = _c.sent();
82
+ if (!claimed) {
83
+ return [2 /*return*/];
84
+ }
85
+ return [4 /*yield*/, job_1.Job.findOneOrNone({
86
+ scheduleId: claimed._id,
87
+ status: { $in: modelTypes_1.IN_FLIGHT_JOB_STATUSES },
88
+ })];
89
+ case 2:
90
+ inFlight = _c.sent();
91
+ if (inFlight) {
92
+ return [2 /*return*/];
93
+ }
94
+ idempotencyKey = (0, exports.buildScheduleRunIdempotencyKey)(claimed._id.toString(), claimed.nextRunAt);
95
+ _c.label = 3;
96
+ case 3:
97
+ _c.trys.push([3, 5, , 9]);
98
+ return [4 /*yield*/, jobsService.enqueue({
99
+ idempotencyKey: idempotencyKey,
100
+ name: claimed.handlerName,
101
+ payload: {},
102
+ runAt: claimed.nextRunAt,
103
+ scheduleId: claimed._id.toString(),
104
+ })];
105
+ case 4:
106
+ _c.sent();
107
+ return [3 /*break*/, 9];
108
+ case 5:
109
+ error_1 = _c.sent();
110
+ shouldRollbackSchedule = !(error_1 instanceof dispatchError_1.JobDispatchError) || error_1.compensationSucceeded;
111
+ if (!shouldRollbackSchedule) return [3 /*break*/, 7];
112
+ return [4 /*yield*/, jobSchedule_1.JobSchedule.updateOne({ _id: claimed._id, nextRunAt: advancedNextRunAt }, { $set: { nextRunAt: dueRunAt } })];
113
+ case 6:
114
+ _c.sent();
115
+ return [3 /*break*/, 8];
116
+ case 7:
117
+ api_1.logger.error("[JobSchedule] Dispatch compensation lost race for \"".concat(claimed.name, "\" (").concat(claimed._id.toString(), "); schedule nextRunAt left advanced"));
118
+ _c.label = 8;
119
+ case 8: throw error_1;
120
+ case 9: return [2 /*return*/];
121
+ }
122
+ });
123
+ }); };
124
+ var tickDueSchedules = function (_a) { return __awaiter(void 0, [_a], void 0, function (_b) {
125
+ var dueSchedules, dueSchedules_1, dueSchedules_1_1, schedule, scheduleLog, error_2, e_1_1;
126
+ var e_1, _c;
127
+ var jobsService = _b.jobsService, _d = _b.now, now = _d === void 0 ? luxon_1.DateTime.utc().toJSDate() : _d;
128
+ return __generator(this, function (_e) {
129
+ switch (_e.label) {
130
+ case 0: return [4 /*yield*/, jobSchedule_1.JobSchedule.find({
131
+ enabled: true,
132
+ nextRunAt: { $lte: now },
133
+ }).sort({ nextRunAt: 1 })];
134
+ case 1:
135
+ dueSchedules = _e.sent();
136
+ _e.label = 2;
137
+ case 2:
138
+ _e.trys.push([2, 9, 10, 11]);
139
+ dueSchedules_1 = __values(dueSchedules), dueSchedules_1_1 = dueSchedules_1.next();
140
+ _e.label = 3;
141
+ case 3:
142
+ if (!!dueSchedules_1_1.done) return [3 /*break*/, 8];
143
+ schedule = dueSchedules_1_1.value;
144
+ scheduleLog = (0, api_1.createScopedLogger)({
145
+ labels: { scheduleName: schedule.name },
146
+ prefix: "[JobSchedule]",
147
+ });
148
+ _e.label = 4;
149
+ case 4:
150
+ _e.trys.push([4, 6, , 7]);
151
+ return [4 /*yield*/, tickOneSchedule({ jobsService: jobsService, now: now, schedule: schedule })];
152
+ case 5:
153
+ _e.sent();
154
+ return [3 /*break*/, 7];
155
+ case 6:
156
+ error_2 = _e.sent();
157
+ api_1.logger.error("[JobSchedule] Tick failed for \"".concat(schedule.name, "\": ").concat(error_2 instanceof Error ? error_2.message : String(error_2)));
158
+ scheduleLog.error("Tick failed: ".concat(error_2 instanceof Error ? error_2.message : String(error_2)));
159
+ return [3 /*break*/, 7];
160
+ case 7:
161
+ dueSchedules_1_1 = dueSchedules_1.next();
162
+ return [3 /*break*/, 3];
163
+ case 8: return [3 /*break*/, 11];
164
+ case 9:
165
+ e_1_1 = _e.sent();
166
+ e_1 = { error: e_1_1 };
167
+ return [3 /*break*/, 11];
168
+ case 10:
169
+ try {
170
+ if (dueSchedules_1_1 && !dueSchedules_1_1.done && (_c = dueSchedules_1.return)) _c.call(dueSchedules_1);
171
+ }
172
+ finally { if (e_1) throw e_1.error; }
173
+ return [7 /*endfinally*/];
174
+ case 11: return [2 /*return*/];
175
+ }
176
+ });
177
+ }); };
178
+ exports.tickDueSchedules = tickDueSchedules;
@@ -0,0 +1,64 @@
1
+ import type { ScopedLogger } from "@terreno/api";
2
+ import type { JobDocument } from "./modelTypes";
3
+ export interface JobsAdminPayloadViewInput {
4
+ name: string;
5
+ payload: unknown;
6
+ payloadRedacted: boolean;
7
+ }
8
+ /** Projects job payload for admin list/detail responses. Omit/return undefined to hide payload. */
9
+ export type JobsAdminRedactPayload = (input: JobsAdminPayloadViewInput) => unknown | undefined;
10
+ export interface JobHandlerContext {
11
+ jobId: string;
12
+ log: ScopedLogger;
13
+ signal: AbortSignal;
14
+ }
15
+ export interface JobScheduleDefinition {
16
+ cron: string;
17
+ timezone?: string;
18
+ }
19
+ export interface JobDefinition {
20
+ handler: (payload: unknown, ctx: JobHandlerContext) => Promise<void>;
21
+ retry?: {
22
+ backoffMs?: number;
23
+ maxAttempts?: number;
24
+ maxBackoffMs?: number;
25
+ };
26
+ schedule?: JobScheduleDefinition;
27
+ }
28
+ export interface EnqueueJobParams {
29
+ idempotencyKey?: string;
30
+ name: string;
31
+ payload: unknown;
32
+ runAt?: Date;
33
+ scheduleId?: string;
34
+ }
35
+ export interface JobRunnerStartOptions {
36
+ /** Host surface passed to runners so they can resolve handlers and schedules without importing internals. */
37
+ jobs: JobsRunnerHost;
38
+ pollIntervalMs?: number;
39
+ signal: AbortSignal;
40
+ }
41
+ /** Public host surface for {@link JobRunner.start} — implemented by {@link JobsApp}. */
42
+ export interface JobsRunnerHost {
43
+ getDefaultTimezone(): string;
44
+ getDefinition(name: string): JobDefinition | undefined;
45
+ getLockTtlMs(): number;
46
+ reconcileSchedules(): Promise<void>;
47
+ reconcileSchedulesIfDirty(): Promise<void>;
48
+ tickSchedules(now?: Date): Promise<void>;
49
+ }
50
+ /**
51
+ * Pluggable job dispatch and worker lifecycle.
52
+ *
53
+ * {@link enqueue} is invoked only after a **new** job row is persisted; idempotent
54
+ * dedupe returns the existing row without redispatching. {@link MongoJobRunner} is
55
+ * the default — its {@link enqueue} is a no-op because the poll loop claims Mongo rows.
56
+ */
57
+ export interface JobRunner {
58
+ readonly id: string;
59
+ /** When true, {@link JobsApp} mounts the internal execute HTTP route (requires {@link JobsAppOptions.executeAuth}). */
60
+ readonly requiresExecuteRoute?: boolean;
61
+ enqueue(job: JobDocument): Promise<void>;
62
+ start?(options: JobRunnerStartOptions): Promise<void>;
63
+ stop?(): Promise<void>;
64
+ }
package/dist/types.js ADDED
@@ -0,0 +1,2 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
package/package.json ADDED
@@ -0,0 +1,82 @@
1
+ {
2
+ "author": "Josh Gachnang",
3
+ "bugs": {
4
+ "url": "https://github.com/FlourishHealth/terreno/issues"
5
+ },
6
+ "dependencies": {
7
+ "@terreno/api": "57.4.0",
8
+ "cron": "^4.3.4",
9
+ "express": "^5.2.1",
10
+ "luxon": "^3.7.2"
11
+ },
12
+ "description": "Durable background jobs plugin for @terreno/api — part of Terreno, the batteries-included TypeScript framework for universal apps.",
13
+ "devDependencies": {
14
+ "@biomejs/biome": "^2.3.6",
15
+ "@terreno/test": "57.4.0",
16
+ "@types/bun": "^1.4.0",
17
+ "@types/express": "^5.0.6",
18
+ "@types/luxon": "^3.7.1",
19
+ "@types/supertest": "^7.2.0",
20
+ "chai": "^6.2.2",
21
+ "supertest": "^7.2.2",
22
+ "typescript": "~6.0.3"
23
+ },
24
+ "exports": {
25
+ ".": {
26
+ "default": "./dist/index.js",
27
+ "types": "./dist/index.d.ts"
28
+ },
29
+ "./runners/gcpCloudTasks": {
30
+ "default": "./dist/runners/gcpCloudTasks.js",
31
+ "types": "./dist/runners/gcpCloudTasks.d.ts"
32
+ },
33
+ "./runners/vercelQueues": {
34
+ "default": "./dist/runners/vercelQueues.js",
35
+ "types": "./dist/runners/vercelQueues.d.ts"
36
+ }
37
+ },
38
+ "homepage": "https://github.com/FlourishHealth/terreno#readme",
39
+ "keywords": [
40
+ "background-jobs",
41
+ "queue",
42
+ "api",
43
+ "express",
44
+ "terreno"
45
+ ],
46
+ "license": "MIT",
47
+ "main": "dist/index.js",
48
+ "name": "@terreno/jobs",
49
+ "peerDependencies": {
50
+ "@google-cloud/tasks": "7.1.0",
51
+ "@vercel/queue": "^0.5.1",
52
+ "mongoose": "^8.0.0 || ^9.0.0"
53
+ },
54
+ "peerDependenciesMeta": {
55
+ "@google-cloud/tasks": {
56
+ "optional": true
57
+ },
58
+ "@vercel/queue": {
59
+ "optional": true
60
+ }
61
+ },
62
+ "publishConfig": {
63
+ "access": "public"
64
+ },
65
+ "repository": {
66
+ "type": "git",
67
+ "url": "git+ssh://git@github.com/FlourishHealth/terreno.git"
68
+ },
69
+ "scripts": {
70
+ "compile": "bun tsc",
71
+ "compile:watch": "bun tsc -w",
72
+ "dev": "bun tsc -w",
73
+ "lint": "biome check ./src",
74
+ "lint:fix": "biome check --write ./src",
75
+ "test": "bun test",
76
+ "test:agent": "AGENT=1 bun test --only-failures",
77
+ "test:ci": "bun test",
78
+ "test:coverage": "bun run ../scripts/check-coverage.ts"
79
+ },
80
+ "types": "dist/index.d.ts",
81
+ "version": "57.4.0"
82
+ }
@@ -0,0 +1,28 @@
1
+ const activeExecutions = new Map<string, AbortController>();
2
+
3
+ export const beginActiveJobExecution = (jobId: string, parentSignal: AbortSignal): AbortSignal => {
4
+ const controller = new AbortController();
5
+ activeExecutions.set(jobId, controller);
6
+
7
+ if (parentSignal.aborted) {
8
+ controller.abort();
9
+ } else {
10
+ parentSignal.addEventListener("abort", () => controller.abort(), {once: true});
11
+ }
12
+
13
+ return controller.signal;
14
+ };
15
+
16
+ export const endActiveJobExecution = (jobId: string): void => {
17
+ activeExecutions.delete(jobId);
18
+ };
19
+
20
+ export const abortActiveJobExecution = (jobId: string): boolean => {
21
+ const controller = activeExecutions.get(jobId);
22
+ if (!controller) {
23
+ return false;
24
+ }
25
+
26
+ controller.abort();
27
+ return true;
28
+ };
@@ -0,0 +1,37 @@
1
+ import {randomUUID} from "node:crypto";
2
+ import os from "node:os";
3
+
4
+ import type {JobDocument} from "./modelTypes";
5
+
6
+ /** @internal */
7
+ export const getWorkerId = (): string => `${os.hostname()}:${process.pid}`;
8
+
9
+ export interface JobClaimLock {
10
+ lockedAt: Date;
11
+ lockedBy: string;
12
+ }
13
+
14
+ export const createClaimLock = (lockedAt: Date): JobClaimLock => ({
15
+ lockedAt,
16
+ lockedBy: `${getWorkerId()}:${randomUUID()}`,
17
+ });
18
+
19
+ export const buildClaimOwnershipFilter = (
20
+ jobId: JobDocument["_id"],
21
+ claim: JobClaimLock
22
+ ): {_id: JobDocument["_id"]; lockedAt: Date; lockedBy: string} => ({
23
+ _id: jobId,
24
+ lockedAt: claim.lockedAt,
25
+ lockedBy: claim.lockedBy,
26
+ });
27
+
28
+ /** @internal */
29
+ export const hasWorkerIdPrefix = (lockedBy: string | undefined): boolean => {
30
+ if (!lockedBy) {
31
+ return false;
32
+ }
33
+ return lockedBy.startsWith(`${getWorkerId()}:`);
34
+ };
35
+
36
+ export const isAbortError = (error: unknown): boolean =>
37
+ error instanceof Error && error.name === "AbortError";
@@ -0,0 +1,39 @@
1
+ import {logger} from "@terreno/api";
2
+
3
+ import {JobDispatchError} from "./dispatchError";
4
+ import {Job} from "./models/job";
5
+ import type {JobDocument} from "./modelTypes";
6
+ import type {JobRunner} from "./types";
7
+
8
+ const removeUntouchedPendingJob = async (jobId: JobDocument["_id"]): Promise<boolean> => {
9
+ const removed = await Job.findOneAndDelete({
10
+ _id: jobId,
11
+ $and: [
12
+ {$or: [{lockedAt: {$exists: false}}, {lockedAt: null}]},
13
+ {$or: [{lockedBy: {$exists: false}}, {lockedBy: null}]},
14
+ ],
15
+ attemptCount: 0,
16
+ status: "pending",
17
+ });
18
+
19
+ return removed !== null;
20
+ };
21
+
22
+ export const dispatchJobToRunner = async (runner: JobRunner, job: JobDocument): Promise<void> => {
23
+ try {
24
+ await runner.enqueue(job);
25
+ } catch (error: unknown) {
26
+ const compensationSucceeded = await removeUntouchedPendingJob(job._id);
27
+ if (!compensationSucceeded) {
28
+ logger.error(
29
+ `[jobs] Dispatch failed for job ${job._id.toString()} and compensation lost race — row was already claimed or mutated`
30
+ );
31
+ }
32
+
33
+ throw new JobDispatchError({
34
+ cause: error,
35
+ compensationSucceeded,
36
+ jobId: job._id.toString(),
37
+ });
38
+ }
39
+ };
@@ -0,0 +1,14 @@
1
+ export class JobDispatchError extends Error {
2
+ readonly cause: unknown;
3
+ readonly compensationSucceeded: boolean;
4
+ readonly jobId: string;
5
+
6
+ constructor(options: {cause: unknown; compensationSucceeded: boolean; jobId: string}) {
7
+ const detail = options.cause instanceof Error ? options.cause.message : String(options.cause);
8
+ super(`Job dispatch failed for ${options.jobId}: ${detail}`);
9
+ this.name = "JobDispatchError";
10
+ this.cause = options.cause;
11
+ this.compensationSucceeded = options.compensationSucceeded;
12
+ this.jobId = options.jobId;
13
+ }
14
+ }
package/src/index.ts ADDED
@@ -0,0 +1,24 @@
1
+ export type {JobsAppOptions} from "./jobsApp";
2
+ export {JobsApp} from "./jobsApp";
3
+ export {getJobsService, tryGetJobsService, unregisterJobsService} from "./jobsService";
4
+ export {Job} from "./models/job";
5
+ export {JobSchedule} from "./models/jobSchedule";
6
+ export type {
7
+ JobAttempt,
8
+ JobDocument,
9
+ JobModel,
10
+ JobScheduleDocument,
11
+ JobScheduleModel,
12
+ JobStatus,
13
+ } from "./modelTypes";
14
+ export type {ExecuteAuthVerifier} from "./routes/jobsExecute";
15
+ export {MongoJobRunner} from "./runners/mongoRunner";
16
+ export type {
17
+ EnqueueJobParams,
18
+ JobDefinition,
19
+ JobHandlerContext,
20
+ JobRunner,
21
+ JobRunnerStartOptions,
22
+ JobScheduleDefinition,
23
+ JobsRunnerHost,
24
+ } from "./types";