@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,562 @@
1
+ import {
2
+ ADMIN_PAGE_ACTION,
3
+ type AnyTerrenoAccess,
4
+ APIError,
5
+ asyncHandler,
6
+ authenticateMiddleware,
7
+ createOpenApiBuilder,
8
+ ForbiddenError,
9
+ NotFoundError,
10
+ type OpenApiMiddleware,
11
+ UnauthorizedError,
12
+ } from "@terreno/api";
13
+ import type {Application, Request, Response} from "express";
14
+ import {DateTime} from "luxon";
15
+ import mongoose from "mongoose";
16
+
17
+ import type {JobsService} from "../jobsService";
18
+ import {Job} from "../models/job";
19
+ import {JobSchedule} from "../models/jobSchedule";
20
+ import type {JobDocument, JobScheduleDocument, JobStatus} from "../modelTypes";
21
+ import type {JobsAdminRedactPayload} from "../types";
22
+
23
+ export interface RegisterJobsAdminRouteOptions {
24
+ accessControl?: AnyTerrenoAccess;
25
+ basePath: string;
26
+ jobsService: JobsService;
27
+ openApi?: unknown;
28
+ redactPayload?: JobsAdminRedactPayload;
29
+ }
30
+
31
+ interface JobsListFilters {
32
+ end?: Date;
33
+ name?: string;
34
+ q?: string;
35
+ scheduleId?: string;
36
+ start?: Date;
37
+ status?: JobStatus;
38
+ }
39
+
40
+ const DEFAULT_LIMIT = 50;
41
+ const MAX_LIMIT = 200;
42
+ const MAX_Q_LENGTH = 200;
43
+
44
+ const VALID_JOB_STATUSES = new Set<JobStatus>([
45
+ "cancelled",
46
+ "completed",
47
+ "dead",
48
+ "failed",
49
+ "pending",
50
+ "running",
51
+ "scheduled",
52
+ ]);
53
+
54
+ const JOB_ADMIN_ITEM_SCHEMA = {
55
+ _id: {type: "string"},
56
+ attemptCount: {type: "number"},
57
+ id: {type: "string"},
58
+ name: {type: "string"},
59
+ status: {type: "string"},
60
+ } as const;
61
+
62
+ const SCHEDULE_ADMIN_ITEM_SCHEMA = {
63
+ cron: {type: "string"},
64
+ enabled: {type: "boolean"},
65
+ handlerName: {type: "string"},
66
+ id: {type: "string"},
67
+ name: {type: "string"},
68
+ nextRunAt: {format: "date-time", type: "string"},
69
+ timezone: {type: "string"},
70
+ } as const;
71
+
72
+ const pathParam = (value: string | string[] | undefined): string | undefined => {
73
+ if (Array.isArray(value)) {
74
+ return value[0];
75
+ }
76
+ return value;
77
+ };
78
+
79
+ const readString = (value: unknown): string | undefined => {
80
+ if (typeof value !== "string") {
81
+ return undefined;
82
+ }
83
+
84
+ const trimmed = value.trim();
85
+ return trimmed.length > 0 ? trimmed : undefined;
86
+ };
87
+
88
+ const readPositiveInt = (value: unknown, fallback: number, max: number): number => {
89
+ const parsed =
90
+ typeof value === "number"
91
+ ? value
92
+ : typeof value === "string" && value.trim().length > 0
93
+ ? Number(value)
94
+ : Number.NaN;
95
+
96
+ if (!Number.isFinite(parsed) || parsed <= 0) {
97
+ return fallback;
98
+ }
99
+
100
+ return Math.min(Math.floor(parsed), max);
101
+ };
102
+
103
+ const escapeRegexLiteral = (value: string): string => value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
104
+
105
+ const parseIsoDate = (value: unknown, label: string): Date | undefined => {
106
+ const raw = readString(value);
107
+ if (!raw) {
108
+ return undefined;
109
+ }
110
+
111
+ const parsed = DateTime.fromISO(raw, {zone: "utc"});
112
+ if (!parsed.isValid) {
113
+ throw new APIError({status: 400, title: `Invalid ${label}`});
114
+ }
115
+
116
+ return parsed.toJSDate();
117
+ };
118
+
119
+ const parseSearchQuery = (value: unknown): string | undefined => {
120
+ const raw = readString(value);
121
+ if (!raw) {
122
+ return undefined;
123
+ }
124
+
125
+ if (raw.length > MAX_Q_LENGTH) {
126
+ throw new APIError({status: 400, title: "Search query too long"});
127
+ }
128
+
129
+ return escapeRegexLiteral(raw);
130
+ };
131
+
132
+ const parseStatusFilter = (value: unknown): JobStatus | undefined => {
133
+ const status = readString(value);
134
+ if (!status) {
135
+ return undefined;
136
+ }
137
+
138
+ if (!VALID_JOB_STATUSES.has(status as JobStatus)) {
139
+ throw new APIError({status: 400, title: "Invalid status"});
140
+ }
141
+
142
+ return status as JobStatus;
143
+ };
144
+
145
+ const parseListFilters = (query: Record<string, unknown>): JobsListFilters => ({
146
+ end: parseIsoDate(query.end, "end date"),
147
+ name: readString(query.name),
148
+ q: parseSearchQuery(query.q),
149
+ scheduleId: readString(query.scheduleId),
150
+ start: parseIsoDate(query.start, "start date"),
151
+ status: parseStatusFilter(query.status),
152
+ });
153
+
154
+ const EMPTY_STATUS_COUNTS: Record<JobStatus, number> = {
155
+ cancelled: 0,
156
+ completed: 0,
157
+ dead: 0,
158
+ failed: 0,
159
+ pending: 0,
160
+ running: 0,
161
+ scheduled: 0,
162
+ };
163
+
164
+ const buildStatusCounts = (
165
+ grouped: Array<{_id: JobStatus; count: number}>
166
+ ): {byStatus: Record<JobStatus, number>; total: number} => {
167
+ const byStatus = {...EMPTY_STATUS_COUNTS};
168
+ let total = 0;
169
+
170
+ for (const row of grouped) {
171
+ if (!VALID_JOB_STATUSES.has(row._id)) {
172
+ continue;
173
+ }
174
+ byStatus[row._id] = row.count;
175
+ total += row.count;
176
+ }
177
+
178
+ return {byStatus, total};
179
+ };
180
+
181
+ const buildListMatch = (filters: JobsListFilters): Record<string, unknown> => {
182
+ const match: Record<string, unknown> = {};
183
+
184
+ if (filters.name) {
185
+ match.name = filters.name;
186
+ }
187
+
188
+ if (filters.status) {
189
+ match.status = filters.status;
190
+ }
191
+
192
+ if (filters.scheduleId) {
193
+ if (!mongoose.isValidObjectId(filters.scheduleId)) {
194
+ throw new APIError({status: 400, title: "Invalid scheduleId"});
195
+ }
196
+ match.scheduleId = new mongoose.Types.ObjectId(filters.scheduleId);
197
+ }
198
+
199
+ if (filters.start || filters.end) {
200
+ const created: Record<string, Date> = {};
201
+ if (filters.start) {
202
+ created.$gte = filters.start;
203
+ }
204
+ if (filters.end) {
205
+ created.$lte = filters.end;
206
+ }
207
+ match.created = created;
208
+ }
209
+
210
+ if (filters.q) {
211
+ match.$or = [
212
+ {lastError: {$options: "i", $regex: filters.q}},
213
+ {name: {$options: "i", $regex: filters.q}},
214
+ ];
215
+ }
216
+
217
+ return match;
218
+ };
219
+
220
+ const projectAdminPayload = (
221
+ job: JobDocument,
222
+ redactPayload?: JobsAdminRedactPayload
223
+ ): unknown | undefined => {
224
+ if (job.payloadRedacted) {
225
+ return undefined;
226
+ }
227
+
228
+ if (!redactPayload) {
229
+ return undefined;
230
+ }
231
+
232
+ return redactPayload({
233
+ name: job.name,
234
+ payload: job.payload,
235
+ payloadRedacted: job.payloadRedacted,
236
+ });
237
+ };
238
+
239
+ const serializeJob = (
240
+ job: JobDocument,
241
+ redactPayload?: JobsAdminRedactPayload
242
+ ): Record<string, unknown> => {
243
+ const json = job.toJSON() as Record<string, unknown>;
244
+ const {payload: _storedPayload, ...rest} = json;
245
+ const projectedPayload = projectAdminPayload(job, redactPayload);
246
+
247
+ if (projectedPayload === undefined) {
248
+ return {
249
+ ...rest,
250
+ id: job._id.toString(),
251
+ };
252
+ }
253
+
254
+ return {
255
+ ...rest,
256
+ id: job._id.toString(),
257
+ payload: projectedPayload,
258
+ };
259
+ };
260
+
261
+ const serializeSchedule = (schedule: JobScheduleDocument): Record<string, unknown> => {
262
+ const json = schedule.toJSON() as Record<string, unknown>;
263
+ return {
264
+ ...json,
265
+ id: schedule._id.toString(),
266
+ };
267
+ };
268
+
269
+ const createRequireJobsAdmin =
270
+ (accessControl?: AnyTerrenoAccess) =>
271
+ async (req: Request): Promise<void> => {
272
+ const user = req.user;
273
+ if (!user) {
274
+ throw new UnauthorizedError("Authentication required");
275
+ }
276
+
277
+ if (accessControl) {
278
+ const result = await accessControl.can({
279
+ permissions: {admin: [ADMIN_PAGE_ACTION, "jobs"]},
280
+ user: user as Parameters<AnyTerrenoAccess["can"]>[0]["user"],
281
+ });
282
+
283
+ if (!result.allowed) {
284
+ throw new ForbiddenError({
285
+ detail: result.reason,
286
+ title: "Jobs admin access required",
287
+ });
288
+ }
289
+
290
+ return;
291
+ }
292
+
293
+ if (!(user as {admin?: boolean}).admin) {
294
+ throw new ForbiddenError("Admin access required");
295
+ }
296
+ };
297
+
298
+ const listQueryParameters = (
299
+ builder: ReturnType<typeof createOpenApiBuilder>
300
+ ): ReturnType<typeof createOpenApiBuilder> =>
301
+ builder
302
+ .withQueryParameter("page", {type: "number"}, {required: false})
303
+ .withQueryParameter("limit", {type: "number"}, {required: false})
304
+ .withQueryParameter("name", {type: "string"}, {required: false})
305
+ .withQueryParameter("status", {type: "string"}, {required: false})
306
+ .withQueryParameter("scheduleId", {type: "string"}, {required: false})
307
+ .withQueryParameter("start", {type: "string"}, {required: false})
308
+ .withQueryParameter("end", {type: "string"}, {required: false})
309
+ .withQueryParameter("q", {type: "string"}, {required: false});
310
+
311
+ export const registerJobsAdminRoutes = ({
312
+ app,
313
+ options,
314
+ }: {
315
+ app: Application;
316
+ options: RegisterJobsAdminRouteOptions;
317
+ }): void => {
318
+ const routeOpenApi = options.openApi ? {openApi: options.openApi as OpenApiMiddleware} : {};
319
+ const requireJobsAdmin = createRequireJobsAdmin(options.accessControl);
320
+ const basePath = options.basePath;
321
+ const {jobsService, redactPayload} = options;
322
+
323
+ app.get(
324
+ `${basePath}/schedules`,
325
+ [
326
+ authenticateMiddleware(),
327
+ createOpenApiBuilder(routeOpenApi)
328
+ .withTags(["admin", "jobs"])
329
+ .withSummary("List configured job schedules")
330
+ .withResponse(200, {
331
+ data: {
332
+ items: {
333
+ properties: SCHEDULE_ADMIN_ITEM_SCHEMA,
334
+ type: "object",
335
+ },
336
+ type: "array",
337
+ },
338
+ })
339
+ .build(),
340
+ ],
341
+ asyncHandler(async (req: Request, res: Response) => {
342
+ await requireJobsAdmin(req);
343
+ const schedules = await JobSchedule.find({}).sort("name");
344
+ return res.json({data: schedules.map((schedule) => serializeSchedule(schedule))});
345
+ })
346
+ );
347
+
348
+ app.post(
349
+ `${basePath}/schedules/:name/pause`,
350
+ [
351
+ authenticateMiddleware(),
352
+ createOpenApiBuilder(routeOpenApi)
353
+ .withTags(["admin", "jobs"])
354
+ .withSummary("Pause a job schedule")
355
+ .withPathParameter("name", {type: "string"})
356
+ .withResponse(200, {data: {type: "object"}})
357
+ .build(),
358
+ ],
359
+ asyncHandler(async (req: Request, res: Response) => {
360
+ await requireJobsAdmin(req);
361
+ const name = pathParam(req.params.name);
362
+ if (!name) {
363
+ throw new NotFoundError("Schedule not found");
364
+ }
365
+
366
+ const schedule = await jobsService.pauseSchedule(name);
367
+ return res.json({data: serializeSchedule(schedule)});
368
+ })
369
+ );
370
+
371
+ app.post(
372
+ `${basePath}/schedules/:name/resume`,
373
+ [
374
+ authenticateMiddleware(),
375
+ createOpenApiBuilder(routeOpenApi)
376
+ .withTags(["admin", "jobs"])
377
+ .withSummary("Resume a job schedule")
378
+ .withPathParameter("name", {type: "string"})
379
+ .withResponse(200, {data: {type: "object"}})
380
+ .build(),
381
+ ],
382
+ asyncHandler(async (req: Request, res: Response) => {
383
+ await requireJobsAdmin(req);
384
+ const name = pathParam(req.params.name);
385
+ if (!name) {
386
+ throw new NotFoundError("Schedule not found");
387
+ }
388
+
389
+ const schedule = await jobsService.resumeSchedule(name);
390
+ return res.json({data: serializeSchedule(schedule)});
391
+ })
392
+ );
393
+
394
+ app.get(
395
+ basePath,
396
+ [
397
+ authenticateMiddleware(),
398
+ listQueryParameters(
399
+ createOpenApiBuilder(routeOpenApi)
400
+ .withTags(["admin", "jobs"])
401
+ .withSummary("List background jobs")
402
+ )
403
+ .withResponse(200, {
404
+ data: {
405
+ items: {
406
+ properties: JOB_ADMIN_ITEM_SCHEMA,
407
+ type: "object",
408
+ },
409
+ type: "array",
410
+ },
411
+ limit: {type: "number"},
412
+ more: {type: "boolean"},
413
+ page: {type: "number"},
414
+ total: {type: "number"},
415
+ })
416
+ .build(),
417
+ ],
418
+ asyncHandler(async (req: Request, res: Response) => {
419
+ await requireJobsAdmin(req);
420
+ const filters = parseListFilters(req.query as Record<string, unknown>);
421
+ const page = readPositiveInt(req.query.page, 1, Number.MAX_SAFE_INTEGER);
422
+ const limit = readPositiveInt(req.query.limit, DEFAULT_LIMIT, MAX_LIMIT);
423
+ const skip = (page - 1) * limit;
424
+ const match = buildListMatch(filters);
425
+ const [jobs, total] = await Promise.all([
426
+ Job.find(match).sort("-created").skip(skip).limit(limit),
427
+ Job.countDocuments(match),
428
+ ]);
429
+
430
+ return res.json({
431
+ data: jobs.map((job) => serializeJob(job, redactPayload)),
432
+ limit,
433
+ more: skip + limit < total,
434
+ page,
435
+ total,
436
+ });
437
+ })
438
+ );
439
+
440
+ app.get(
441
+ `${basePath}/stats`,
442
+ [
443
+ authenticateMiddleware(),
444
+ createOpenApiBuilder(routeOpenApi)
445
+ .withTags(["admin", "jobs"])
446
+ .withSummary("Aggregate background job counts by status")
447
+ .withResponse(200, {
448
+ data: {
449
+ properties: {
450
+ byStatus: {type: "object"},
451
+ total: {type: "number"},
452
+ },
453
+ type: "object",
454
+ },
455
+ })
456
+ .build(),
457
+ ],
458
+ asyncHandler(async (req: Request, res: Response) => {
459
+ await requireJobsAdmin(req);
460
+ const grouped = await Job.aggregate<{_id: JobStatus; count: number}>([
461
+ {$group: {_id: "$status", count: {$sum: 1}}},
462
+ ]);
463
+ return res.json({data: buildStatusCounts(grouped)});
464
+ })
465
+ );
466
+
467
+ app.get(
468
+ `${basePath}/:id`,
469
+ [
470
+ authenticateMiddleware(),
471
+ createOpenApiBuilder(routeOpenApi)
472
+ .withTags(["admin", "jobs"])
473
+ .withSummary("Read one background job")
474
+ .withPathParameter("id", {type: "string"})
475
+ .withResponse(200, {data: {type: "object"}})
476
+ .build(),
477
+ ],
478
+ asyncHandler(async (req: Request, res: Response) => {
479
+ await requireJobsAdmin(req);
480
+ const id = pathParam(req.params.id);
481
+ if (!id || !mongoose.isValidObjectId(id)) {
482
+ throw new NotFoundError("Job not found");
483
+ }
484
+
485
+ const job = await Job.findOneOrNone({_id: id});
486
+ if (!job) {
487
+ throw new NotFoundError("Job not found");
488
+ }
489
+
490
+ return res.json({data: serializeJob(job, redactPayload)});
491
+ })
492
+ );
493
+
494
+ app.post(
495
+ `${basePath}/:id/retry`,
496
+ [
497
+ authenticateMiddleware(),
498
+ createOpenApiBuilder(routeOpenApi)
499
+ .withTags(["admin", "jobs"])
500
+ .withSummary("Retry a background job by creating a linked row")
501
+ .withPathParameter("id", {type: "string"})
502
+ .withResponse(200, {data: {type: "object"}})
503
+ .build(),
504
+ ],
505
+ asyncHandler(async (req: Request, res: Response) => {
506
+ await requireJobsAdmin(req);
507
+ const id = pathParam(req.params.id);
508
+ if (!id || !mongoose.isValidObjectId(id)) {
509
+ throw new NotFoundError("Job not found");
510
+ }
511
+
512
+ const {retry} = await jobsService.adminRetryJob(id);
513
+ return res.json({data: serializeJob(retry, redactPayload)});
514
+ })
515
+ );
516
+
517
+ app.post(
518
+ `${basePath}/:id/requeue`,
519
+ [
520
+ authenticateMiddleware(),
521
+ createOpenApiBuilder(routeOpenApi)
522
+ .withTags(["admin", "jobs"])
523
+ .withSummary("Requeue a dead, failed, or cancelled job")
524
+ .withPathParameter("id", {type: "string"})
525
+ .withResponse(200, {data: {type: "object"}})
526
+ .build(),
527
+ ],
528
+ asyncHandler(async (req: Request, res: Response) => {
529
+ await requireJobsAdmin(req);
530
+ const id = pathParam(req.params.id);
531
+ if (!id || !mongoose.isValidObjectId(id)) {
532
+ throw new NotFoundError("Job not found");
533
+ }
534
+
535
+ const job = await jobsService.adminRequeueJob(id);
536
+ return res.json({data: serializeJob(job, redactPayload)});
537
+ })
538
+ );
539
+
540
+ app.post(
541
+ `${basePath}/:id/cancel`,
542
+ [
543
+ authenticateMiddleware(),
544
+ createOpenApiBuilder(routeOpenApi)
545
+ .withTags(["admin", "jobs"])
546
+ .withSummary("Cancel a pending, scheduled, or running job")
547
+ .withPathParameter("id", {type: "string"})
548
+ .withResponse(200, {data: {type: "object"}})
549
+ .build(),
550
+ ],
551
+ asyncHandler(async (req: Request, res: Response) => {
552
+ await requireJobsAdmin(req);
553
+ const id = pathParam(req.params.id);
554
+ if (!id || !mongoose.isValidObjectId(id)) {
555
+ throw new NotFoundError("Job not found");
556
+ }
557
+
558
+ const job = await jobsService.adminCancelJob(id);
559
+ return res.json({data: serializeJob(job, redactPayload)});
560
+ })
561
+ );
562
+ };
@@ -0,0 +1,95 @@
1
+ import {
2
+ APIError,
3
+ asyncHandler,
4
+ ConflictError,
5
+ NotFoundError,
6
+ UnauthorizedError,
7
+ } from "@terreno/api";
8
+ import type {Application, Request} from "express";
9
+
10
+ import {executeJobById, type JobExecutionHost} from "../jobExecutor";
11
+
12
+ export type ExecuteAuthVerifier = (req: Request) => boolean | Promise<boolean>;
13
+
14
+ export interface RegisterJobsExecuteRouteOptions {
15
+ basePath: string;
16
+ executeAuth: ExecuteAuthVerifier;
17
+ host: JobExecutionHost;
18
+ }
19
+
20
+ const JOB_NOT_FOUND_TITLE = "Job not found";
21
+
22
+ const readJobId = (body: unknown): string | undefined => {
23
+ if (!body || typeof body !== "object" || !("jobId" in body)) {
24
+ return undefined;
25
+ }
26
+
27
+ const {jobId} = body as {jobId?: unknown};
28
+ if (typeof jobId !== "string") {
29
+ return undefined;
30
+ }
31
+
32
+ const trimmed = jobId.trim();
33
+ return trimmed.length > 0 ? trimmed : undefined;
34
+ };
35
+
36
+ const serializeJob = (job: {toJSON: () => Record<string, unknown>}): Record<string, unknown> => {
37
+ const json = job.toJSON();
38
+ if (json._id && typeof json._id === "object" && "toString" in json._id) {
39
+ return {
40
+ ...json,
41
+ id: (json._id as {toString: () => string}).toString(),
42
+ };
43
+ }
44
+
45
+ return json;
46
+ };
47
+
48
+ export const registerJobsExecuteRoute = ({
49
+ app,
50
+ options,
51
+ }: {
52
+ app: Application;
53
+ options: RegisterJobsExecuteRouteOptions;
54
+ }): void => {
55
+ const path = `${options.basePath}/execute`;
56
+
57
+ app.post(
58
+ path,
59
+ asyncHandler(async (req, res) => {
60
+ let authorized = false;
61
+ try {
62
+ authorized = Boolean(await options.executeAuth(req));
63
+ } catch {
64
+ authorized = false;
65
+ }
66
+
67
+ if (!authorized) {
68
+ throw new UnauthorizedError("Unauthorized");
69
+ }
70
+
71
+ const jobId = readJobId(req.body);
72
+ if (!jobId) {
73
+ throw new APIError({status: 400, title: "jobId is required"});
74
+ }
75
+
76
+ const outcome = await executeJobById({
77
+ host: options.host,
78
+ jobId,
79
+ signal: AbortSignal.timeout(30 * 60 * 1_000),
80
+ });
81
+
82
+ if (outcome.kind === "not_found") {
83
+ throw new NotFoundError(JOB_NOT_FOUND_TITLE);
84
+ }
85
+
86
+ if (outcome.kind === "conflict") {
87
+ throw new ConflictError(
88
+ outcome.reason === "not_due" ? "Job is not due yet" : "Job is already running"
89
+ );
90
+ }
91
+
92
+ return res.status(200).json({data: serializeJob(outcome.job)});
93
+ })
94
+ );
95
+ };