@q32/core 0.1.20 → 0.1.22

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/jobs.js CHANGED
@@ -1,6 +1,157 @@
1
1
  import { parseJsonColumn, stringifyJsonColumn } from "./d1.js";
2
2
  import { createId } from "./ids.js";
3
3
  import { isFutureIso, nowIso } from "./time.js";
4
+ export class JobDeadline {
5
+ budgetMs;
6
+ startedAt = Date.now();
7
+ constructor(budgetMs = 50_000) {
8
+ this.budgetMs = budgetMs;
9
+ }
10
+ shouldYield() {
11
+ return Date.now() - this.startedAt >= this.budgetMs;
12
+ }
13
+ }
14
+ export class DurableJobDispatcher {
15
+ options;
16
+ constructor(options) {
17
+ this.options = options;
18
+ }
19
+ async run(jobId) {
20
+ const job = await this.options.jobs.claim(jobId);
21
+ if (!job)
22
+ return { kind: "done", result: null };
23
+ const handler = this.options.handlers[job.jobType];
24
+ if (!handler) {
25
+ await this.options.jobs.fail(job.jobId, `No handler registered for job type: ${job.jobType}`);
26
+ throw new Error(`No handler registered for job type: ${job.jobType}`);
27
+ }
28
+ const events = new DurableJobEvents(job, this.options.events);
29
+ await events.info({ eventName: "job.started", status: "started", message: "Job started." });
30
+ try {
31
+ if (await this.shouldStop(job.jobId)) {
32
+ await this.options.jobs.stop(job.jobId);
33
+ await events.warn({ eventName: "job.stopped", status: "skipped", message: "Job stopped before handler ran." });
34
+ return { kind: "stopped", result: null };
35
+ }
36
+ const output = await handler({
37
+ job,
38
+ jobs: this.options.jobs,
39
+ services: this.options.services,
40
+ events,
41
+ shouldStop: () => this.shouldStop(job.jobId),
42
+ });
43
+ const normalized = normalizeJobResult(output);
44
+ if (normalized.kind === "requeue") {
45
+ await this.options.jobs.requeue(job.jobId, {
46
+ payload: normalized.payload,
47
+ result: normalized.result ?? null,
48
+ availableAt: normalized.availableAt ?? null,
49
+ });
50
+ await events.info({ eventName: "job.requeued", status: "started", message: "Job requeued." });
51
+ }
52
+ else if (normalized.kind === "stopped") {
53
+ await this.options.jobs.stop(job.jobId, normalized.result ?? null);
54
+ await events.warn({ eventName: "job.stopped", status: "skipped", message: "Job stopped." });
55
+ }
56
+ else {
57
+ await this.options.jobs.succeed(job.jobId, normalized.result ?? null);
58
+ await events.info({ eventName: "job.succeeded", status: "ok", message: "Job succeeded." });
59
+ }
60
+ return normalized;
61
+ }
62
+ catch (error) {
63
+ const latest = await this.options.jobs.get(job.jobId);
64
+ if (latest && latest.attemptCount < latest.maxAttempts) {
65
+ await this.options.jobs.requeue(job.jobId, {
66
+ result: { retryError: errorMessage(error) },
67
+ availableAt: this.options.retryDelay?.({ job: latest, error }) ?? null,
68
+ lastError: errorMessage(error),
69
+ });
70
+ await events.warn({ eventName: "job.retrying", status: "warning", message: "Job failed and will retry.", error });
71
+ return { kind: "requeue", result: null };
72
+ }
73
+ await this.options.jobs.fail(job.jobId, error);
74
+ await events.error({ eventName: "job.failed", status: "error", message: "Job failed.", error });
75
+ throw error;
76
+ }
77
+ }
78
+ async shouldStop(jobId) {
79
+ const latest = await this.options.jobs.get(jobId);
80
+ return latest?.status === "stopping" || latest?.status === "stopped";
81
+ }
82
+ }
83
+ export async function runParentJobOrchestration(context, input) {
84
+ const existing = await context.jobs.listChildren(context.job.jobId);
85
+ const failureMode = input.failureMode ?? "fail_fast";
86
+ const queuedSiblingPolicy = input.queuedSiblingPolicy ?? "fail";
87
+ if (existing.length === 0) {
88
+ if (input.children.length === 0) {
89
+ return {
90
+ kind: "done",
91
+ result: {
92
+ stage: "complete",
93
+ failureMode,
94
+ queuedSiblingPolicy,
95
+ childJobIds: [],
96
+ summary: emptyChildrenSummary(context.job.jobId),
97
+ },
98
+ };
99
+ }
100
+ const children = [];
101
+ for (const child of input.children) {
102
+ const queued = await context.jobs.enqueue({
103
+ ...child,
104
+ parentJobId: child.parentJobId ?? context.job.jobId,
105
+ });
106
+ children.push(queued);
107
+ await input.onChildQueued?.(queued);
108
+ }
109
+ await context.events.info({
110
+ eventName: "job.children_queued",
111
+ status: "started",
112
+ message: "Child jobs queued.",
113
+ metrics: { queued: input.children.length },
114
+ });
115
+ return requeueParent(context.job.payload, children, summarizeJobs(context.job.jobId, children), input);
116
+ }
117
+ const summary = await context.jobs.summarizeChildren(context.job.jobId);
118
+ if (failureMode === "fail_fast" && summary.failed > 0) {
119
+ const failedReason = (await context.jobs.listChildren(context.job.jobId)).find((child) => child.status === "failed" && child.lastError)
120
+ ?.lastError ?? `Child job failed for parent ${context.job.jobId}.`;
121
+ if (queuedSiblingPolicy === "stop") {
122
+ await context.jobs.markQueuedChildrenTerminal(context.job.jobId, "stopped", failedReason);
123
+ }
124
+ else {
125
+ await context.jobs.markQueuedChildrenTerminal(context.job.jobId, "failed", failedReason);
126
+ }
127
+ await context.jobs.requestStopActiveChildren(context.job.jobId);
128
+ await context.events.warn({
129
+ eventName: "job.children_failed",
130
+ status: "warning",
131
+ message: "One or more child jobs failed.",
132
+ metrics: summary,
133
+ });
134
+ const updated = await context.jobs.summarizeChildren(context.job.jobId);
135
+ if (!updated.allTerminal) {
136
+ const children = await context.jobs.listChildren(context.job.jobId);
137
+ return requeueParent(context.job.payload, children, updated, input);
138
+ }
139
+ throw new Error(failedReason);
140
+ }
141
+ const children = await context.jobs.listChildren(context.job.jobId);
142
+ if (!summary.allTerminal)
143
+ return requeueParent(context.job.payload, children, summary, input);
144
+ return {
145
+ kind: "done",
146
+ result: {
147
+ stage: "complete",
148
+ failureMode,
149
+ queuedSiblingPolicy,
150
+ childJobIds: children.map((child) => child.jobId),
151
+ summary,
152
+ },
153
+ };
154
+ }
4
155
  export class D1JobStore {
5
156
  db;
6
157
  options;
@@ -14,51 +165,49 @@ export class D1JobStore {
14
165
  async enqueue(input) {
15
166
  const jobId = createId(this.options.idPrefix ?? "job");
16
167
  const now = nowIso();
168
+ const enqueuePolicy = input.enqueuePolicy ?? "enqueue";
169
+ if (enqueuePolicy === "dedupe_active" && input.lockKey) {
170
+ const existing = await this.getActiveForLockKey(input.lockKey);
171
+ if (existing)
172
+ return existing;
173
+ }
17
174
  await this.db
18
175
  .prepare(`INSERT INTO ${this.tableName} (
19
- job_id, job_type, status, payload_json, result_json, lock_key,
176
+ job_id, job_type, status, org_id, site_id, payload_json, result_json, metadata_json,
177
+ parent_job_id, lock_key, concurrency_key, concurrency_limit, enqueue_policy,
20
178
  attempt_count, max_attempts, available_at, created_at, updated_at
21
- ) VALUES (?, ?, 'queued', ?, NULL, ?, 0, ?, ?, ?, ?)`)
22
- .bind(jobId, input.jobType, stringifyJsonColumn(input.payload ?? {}), input.lockKey ?? null, Math.max(1, Math.floor(input.maxAttempts ?? 3)), input.availableAt ?? null, now, now)
179
+ ) VALUES (?, ?, 'queued', ?, ?, ?, NULL, ?, ?, ?, ?, ?, ?, 0, ?, ?, ?, ?)`)
180
+ .bind(jobId, input.jobType, input.orgId ?? null, input.siteId ?? null, stringifyJsonColumn(input.payload ?? {}), stringifyJsonColumn(input.metadata ?? {}), input.parentJobId ?? null, input.lockKey ?? null, input.concurrencyKey ?? null, normalizeConcurrencyLimit(input.concurrencyLimit), enqueuePolicy, Math.max(1, Math.floor(input.maxAttempts ?? this.options.defaultMaxAttempts ?? 3)), input.availableAt ?? null, now, now)
23
181
  .run();
24
182
  const job = await this.get(jobId);
25
183
  if (!job)
26
184
  throw new Error(`Failed to load enqueued job: ${jobId}`);
185
+ await this.emitStatusEvent(job, "queued", "Job queued.");
27
186
  return job;
28
187
  }
29
188
  async get(jobId) {
30
189
  const row = await this.db
31
- .prepare(`SELECT
32
- job_id AS jobId,
33
- job_type AS jobType,
34
- status,
35
- payload_json AS payloadJson,
36
- result_json AS resultJson,
37
- lock_key AS lockKey,
38
- attempt_count AS attemptCount,
39
- max_attempts AS maxAttempts,
40
- available_at AS availableAt,
41
- started_at AS startedAt,
42
- completed_at AS completedAt,
43
- last_error AS lastError,
44
- created_at AS createdAt,
45
- updated_at AS updatedAt
46
- FROM ${this.tableName}
47
- WHERE job_id = ?
48
- LIMIT 1`)
190
+ .prepare(`${this.selectSql()} WHERE job_id = ? LIMIT 1`)
49
191
  .bind(jobId)
50
192
  .first();
51
193
  return row ? rowToJob(row) : null;
52
194
  }
195
+ async getJob(jobId) {
196
+ return this.get(jobId);
197
+ }
198
+ async enqueueChild(parentJobId, input) {
199
+ return this.enqueue({ ...input, parentJobId });
200
+ }
201
+ async enqueueChildren(parentJobId, inputs) {
202
+ const children = [];
203
+ for (const input of inputs)
204
+ children.push(await this.enqueueChild(parentJobId, input));
205
+ return children;
206
+ }
53
207
  async listQueued(limit = 25) {
54
208
  const now = nowIso();
55
209
  const rows = await this.db
56
- .prepare(`SELECT
57
- job_id AS jobId, job_type AS jobType, status, payload_json AS payloadJson,
58
- result_json AS resultJson, lock_key AS lockKey, attempt_count AS attemptCount,
59
- max_attempts AS maxAttempts, available_at AS availableAt, started_at AS startedAt,
60
- completed_at AS completedAt, last_error AS lastError, created_at AS createdAt, updated_at AS updatedAt
61
- FROM ${this.tableName}
210
+ .prepare(`${this.selectSql()}
62
211
  WHERE status = 'queued' AND (available_at IS NULL OR available_at <= ?)
63
212
  ORDER BY created_at ASC
64
213
  LIMIT ?`)
@@ -66,60 +215,75 @@ export class D1JobStore {
66
215
  .all();
67
216
  return (rows.results ?? []).map(rowToJob);
68
217
  }
218
+ async getLatestForSite(input) {
219
+ const row = await this.db
220
+ .prepare(`${this.selectSql()}
221
+ WHERE site_id = ?
222
+ ${input.jobType ? "AND job_type = ?" : ""}
223
+ ORDER BY created_at DESC
224
+ LIMIT 1`)
225
+ .bind(...[input.siteId, ...(input.jobType ? [input.jobType] : [])])
226
+ .first();
227
+ return row ? rowToJob(row) : null;
228
+ }
229
+ async getActiveForSite(input) {
230
+ const row = await this.db
231
+ .prepare(`${this.selectSql()}
232
+ WHERE site_id = ?
233
+ AND status IN ('queued', 'running', 'stopping')
234
+ ${input.jobType ? "AND job_type = ?" : ""}
235
+ ORDER BY created_at DESC
236
+ LIMIT 1`)
237
+ .bind(...[input.siteId, ...(input.jobType ? [input.jobType] : [])])
238
+ .first();
239
+ return row ? rowToJob(row) : null;
240
+ }
241
+ async getLatestForSiteByPayload(input) {
242
+ return this.getForSiteByPayload({ ...input, activeOnly: false });
243
+ }
244
+ async getActiveForSiteByPayload(input) {
245
+ return this.getForSiteByPayload({ ...input, activeOnly: true });
246
+ }
69
247
  async claim(jobId) {
70
248
  const current = await this.get(jobId);
71
- if (!current || current.status === "succeeded" || current.status === "failed")
249
+ if (!current || isTerminalJobStatus(current.status) || current.status === "stopping")
72
250
  return null;
251
+ if (current.status === "running") {
252
+ if (!isStaleRunningJob(current, this.options.staleRunningAfterSeconds))
253
+ return null;
254
+ await this.markStaleRunningQueued(jobId);
255
+ }
73
256
  if (current.status === "queued" && isFutureIso(current.availableAt))
74
257
  return null;
75
258
  if (current.attemptCount >= current.maxAttempts) {
76
259
  await this.fail(jobId, "Job exhausted all attempts.");
77
260
  return null;
78
261
  }
262
+ if (current.concurrencyKey && current.status === "queued") {
263
+ const limit = current.concurrencyLimit ?? 1;
264
+ const running = await this.countRunningForConcurrencyKey(current.concurrencyKey);
265
+ if (running >= limit)
266
+ return null;
267
+ }
79
268
  const now = nowIso();
80
269
  const result = await this.db
81
270
  .prepare(`UPDATE ${this.tableName}
82
271
  SET status = 'running',
83
272
  attempt_count = attempt_count + 1,
84
- started_at = COALESCE(started_at, ?),
273
+ started_at = ?,
85
274
  updated_at = ?,
86
275
  last_error = NULL
87
276
  WHERE job_id = ?
88
- AND status IN ('queued', 'running')
277
+ AND status = 'queued'
89
278
  AND (available_at IS NULL OR available_at <= ?)`)
90
279
  .bind(now, now, jobId, now)
91
280
  .run();
92
281
  if (!Number(result.meta.changes ?? 0))
93
282
  return null;
94
- return this.get(jobId);
95
- }
96
- async succeed(jobId, result = null) {
97
- const now = nowIso();
98
- await this.db
99
- .prepare(`UPDATE ${this.tableName}
100
- SET status = 'succeeded', result_json = ?, completed_at = ?, updated_at = ?
101
- WHERE job_id = ?`)
102
- .bind(stringifyJsonColumn(result), now, now, jobId)
103
- .run();
104
- }
105
- async requeue(jobId, result = null, availableAt = null) {
106
- const now = nowIso();
107
- await this.db
108
- .prepare(`UPDATE ${this.tableName}
109
- SET status = 'queued', result_json = ?, available_at = ?, updated_at = ?
110
- WHERE job_id = ?`)
111
- .bind(stringifyJsonColumn(result), availableAt, now, jobId)
112
- .run();
113
- }
114
- async fail(jobId, error) {
115
- const now = nowIso();
116
- const message = error instanceof Error ? error.message : String(error);
117
- await this.db
118
- .prepare(`UPDATE ${this.tableName}
119
- SET status = 'failed', last_error = ?, completed_at = ?, updated_at = ?
120
- WHERE job_id = ?`)
121
- .bind(message, now, now, jobId)
122
- .run();
283
+ const job = await this.get(jobId);
284
+ if (job)
285
+ await this.emitStatusEvent(job, "running", "Job started.");
286
+ return job;
123
287
  }
124
288
  async run(jobId, handler) {
125
289
  const job = await this.claim(jobId);
@@ -129,7 +293,14 @@ export class D1JobStore {
129
293
  const output = await handler(job);
130
294
  const normalized = normalizeJobResult(output);
131
295
  if (normalized.kind === "requeue") {
132
- await this.requeue(jobId, normalized.result ?? null, normalized.availableAt ?? null);
296
+ await this.requeue(jobId, {
297
+ payload: normalized.payload,
298
+ result: normalized.result ?? null,
299
+ availableAt: normalized.availableAt ?? null,
300
+ });
301
+ }
302
+ else if (normalized.kind === "stopped") {
303
+ await this.stop(jobId, normalized.result ?? null);
133
304
  }
134
305
  else {
135
306
  await this.succeed(jobId, normalized.result ?? null);
@@ -139,13 +310,699 @@ export class D1JobStore {
139
310
  catch (error) {
140
311
  const latest = await this.get(jobId);
141
312
  if (latest && latest.attemptCount < latest.maxAttempts) {
142
- await this.requeue(jobId, { retryError: error instanceof Error ? error.message : String(error) });
313
+ await this.requeue(jobId, { result: { retryError: errorMessage(error) }, lastError: errorMessage(error) });
143
314
  return { kind: "requeue", result: null };
144
315
  }
145
316
  await this.fail(jobId, error);
146
317
  throw error;
147
318
  }
148
319
  }
320
+ async succeed(jobId, result = null) {
321
+ await this.complete(jobId, "succeeded", result, null);
322
+ }
323
+ async stop(jobId, result = null) {
324
+ await this.complete(jobId, "stopped", result, null);
325
+ }
326
+ async requeue(jobId, inputOrResult = {}, legacyAvailableAt) {
327
+ const input = isRequeueInput(inputOrResult)
328
+ ? inputOrResult
329
+ : { result: inputOrResult, availableAt: legacyAvailableAt ?? null };
330
+ const now = nowIso();
331
+ const payloadSql = input.payload === undefined ? "" : "payload_json = ?,";
332
+ const bindings = input.payload === undefined ? [] : [stringifyJsonColumn(input.payload)];
333
+ await this.db
334
+ .prepare(`UPDATE ${this.tableName}
335
+ SET status = 'queued',
336
+ ${payloadSql}
337
+ result_json = ?,
338
+ available_at = ?,
339
+ last_error = ?,
340
+ updated_at = ?
341
+ WHERE job_id = ?`)
342
+ .bind(...bindings, stringifyJsonColumn(input.result ?? null), input.availableAt ?? null, input.lastError ?? null, now, jobId)
343
+ .run();
344
+ const job = await this.get(jobId);
345
+ if (job) {
346
+ await this.emitStatusEvent(job, "queued", "Job requeued.", {
347
+ availableAt: input.availableAt ?? null,
348
+ ...(input.lastError ? { lastError: input.lastError } : {}),
349
+ });
350
+ }
351
+ }
352
+ async fail(jobId, error) {
353
+ await this.complete(jobId, "failed", null, errorMessage(error));
354
+ }
355
+ async requestStop(jobId) {
356
+ const now = nowIso();
357
+ const queuedStopStatus = this.options.queuedStopStatus ?? "stopped";
358
+ await this.db
359
+ .prepare(`UPDATE ${this.tableName}
360
+ SET status = CASE WHEN status = 'queued' THEN ? ELSE 'stopping' END,
361
+ completed_at = CASE WHEN status = 'queued' AND ? = 'stopped' THEN ? ELSE completed_at END,
362
+ updated_at = ?
363
+ WHERE job_id = ? AND status IN ('queued', 'running')`)
364
+ .bind(queuedStopStatus, queuedStopStatus, now, now, jobId)
365
+ .run();
366
+ const job = await this.get(jobId);
367
+ if (job)
368
+ await this.emitStatusEvent(job, job.status, "Job stop requested.");
369
+ }
370
+ async listChildren(parentJobId) {
371
+ const rows = await this.db
372
+ .prepare(`${this.selectSql()} WHERE parent_job_id = ? ORDER BY created_at ASC`)
373
+ .bind(parentJobId)
374
+ .all();
375
+ return (rows.results ?? []).map(rowToJob);
376
+ }
377
+ async getJobTree(jobId) {
378
+ const root = await this.get(jobId);
379
+ if (!root)
380
+ return null;
381
+ const rows = await this.db
382
+ .prepare(`WITH RECURSIVE tree(job_id, depth) AS (
383
+ SELECT job_id, 0 FROM ${this.tableName} WHERE job_id = ?
384
+ UNION ALL
385
+ SELECT jobs.job_id, tree.depth + 1
386
+ FROM ${this.tableName} jobs
387
+ JOIN tree ON jobs.parent_job_id = tree.job_id
388
+ )
389
+ SELECT
390
+ jobs.job_id AS jobId,
391
+ jobs.job_type AS jobType,
392
+ jobs.status,
393
+ jobs.org_id AS orgId,
394
+ jobs.site_id AS siteId,
395
+ jobs.payload_json AS payloadJson,
396
+ jobs.result_json AS resultJson,
397
+ jobs.metadata_json AS metadataJson,
398
+ jobs.parent_job_id AS parentJobId,
399
+ jobs.lock_key AS lockKey,
400
+ jobs.concurrency_key AS concurrencyKey,
401
+ jobs.concurrency_limit AS concurrencyLimit,
402
+ jobs.enqueue_policy AS enqueuePolicy,
403
+ jobs.attempt_count AS attemptCount,
404
+ jobs.max_attempts AS maxAttempts,
405
+ jobs.available_at AS availableAt,
406
+ jobs.started_at AS startedAt,
407
+ jobs.completed_at AS completedAt,
408
+ jobs.last_error AS lastError,
409
+ jobs.created_at AS createdAt,
410
+ jobs.updated_at AS updatedAt,
411
+ tree.depth AS depth
412
+ FROM ${this.tableName} jobs
413
+ JOIN tree ON jobs.job_id = tree.job_id
414
+ WHERE jobs.job_id <> ?
415
+ ORDER BY tree.depth ASC, jobs.created_at ASC`)
416
+ .bind(jobId, jobId)
417
+ .all();
418
+ const children = (rows.results ?? []).map((row) => ({ ...rowToJob(row), depth: Number(row.depth) }));
419
+ const allJobs = [root, ...children];
420
+ return {
421
+ root,
422
+ children,
423
+ summary: summarizeJobs(root.jobId, children),
424
+ rollupStatus: rollupJobTreeStatus(allJobs),
425
+ rowStates: [
426
+ jobEntityState(root, 0),
427
+ ...children.map((child) => jobEntityState(child, child.depth)),
428
+ ],
429
+ activityEvents: await this.listJobActivityEvents(allJobs.map((job) => job.jobId)),
430
+ latestError: latestJobError(allJobs),
431
+ };
432
+ }
433
+ async summarizeChildren(parentJobId) {
434
+ return summarizeJobs(parentJobId, await this.listChildren(parentJobId));
435
+ }
436
+ async markQueuedChildrenTerminal(parentJobId, status, error) {
437
+ const now = nowIso();
438
+ const result = await this.db
439
+ .prepare(`UPDATE ${this.tableName}
440
+ SET status = ?, last_error = ?, completed_at = ?, updated_at = ?
441
+ WHERE parent_job_id = ? AND status = 'queued'`)
442
+ .bind(status, status === "failed" && error ? errorMessage(error) : null, now, now, parentJobId)
443
+ .run();
444
+ const changed = Number(result.meta.changes ?? 0);
445
+ if (changed > 0 && this.options.onStatusEvent) {
446
+ for (const child of await this.listChildren(parentJobId)) {
447
+ if (child.status === status) {
448
+ await this.emitStatusEvent(child, status, status === "failed" ? errorMessage(error ?? "Child job failed.") : "Job stopped.", error ? { reason: errorMessage(error) } : null);
449
+ }
450
+ }
451
+ }
452
+ return changed;
453
+ }
454
+ async requestStopActiveChildren(parentJobId) {
455
+ const now = nowIso();
456
+ const statuses = this.options.requestStopQueuedChildren ? "'queued', 'running'" : "'running'";
457
+ const result = await this.db
458
+ .prepare(`UPDATE ${this.tableName}
459
+ SET status = 'stopping', updated_at = ?
460
+ WHERE parent_job_id = ? AND status IN (${statuses})`)
461
+ .bind(now, parentJobId)
462
+ .run();
463
+ const changed = Number(result.meta.changes ?? 0);
464
+ if (changed > 0 && this.options.onStatusEvent) {
465
+ for (const child of await this.listChildren(parentJobId)) {
466
+ if (child.status === "stopping")
467
+ await this.emitStatusEvent(child, "stopping", "Job stop requested.");
468
+ }
469
+ }
470
+ return changed;
471
+ }
472
+ async failQueuedChildren(input) {
473
+ return this.markQueuedChildrenTerminal(input.parentJobId, "failed", input.reason ?? "Child job failed.");
474
+ }
475
+ async stopQueuedChildren(input) {
476
+ return this.markQueuedChildrenTerminal(input.parentJobId, "stopped", input.reason ?? "Child job stopped.");
477
+ }
478
+ async getActiveForLockKey(lockKey) {
479
+ const row = await this.db
480
+ .prepare(`${this.selectSql()} WHERE lock_key = ? AND status IN ('queued', 'running', 'stopping') ORDER BY created_at ASC LIMIT 1`)
481
+ .bind(lockKey)
482
+ .first();
483
+ return row ? rowToJob(row) : null;
484
+ }
485
+ async countRunningForConcurrencyKey(concurrencyKey) {
486
+ const row = await this.db
487
+ .prepare(`SELECT COUNT(*) AS count
488
+ FROM ${this.tableName}
489
+ WHERE concurrency_key = ? AND status IN ('running', 'stopping')`)
490
+ .bind(concurrencyKey)
491
+ .first();
492
+ return Number(row?.count ?? 0);
493
+ }
494
+ async getForSiteByPayload(input) {
495
+ const row = await this.db
496
+ .prepare(`${this.selectSql()}
497
+ WHERE site_id = ?
498
+ ${input.jobType ? "AND job_type = ?" : ""}
499
+ ${input.activeOnly ? "AND status IN ('queued', 'running', 'stopping')" : ""}
500
+ AND json_extract(payload_json, ?) = ?
501
+ ORDER BY created_at DESC
502
+ LIMIT 1`)
503
+ .bind(...[
504
+ input.siteId,
505
+ ...(input.jobType ? [input.jobType] : []),
506
+ `$.${input.key}`,
507
+ input.value,
508
+ ])
509
+ .first();
510
+ return row ? rowToJob(row) : null;
511
+ }
512
+ async listJobActivityEvents(jobIds) {
513
+ if (jobIds.length === 0)
514
+ return [];
515
+ const placeholders = jobIds.map(() => "?").join(", ");
516
+ try {
517
+ const rows = await this.db
518
+ .prepare(`SELECT
519
+ ops_event_id AS opsEventId,
520
+ job_id AS jobId,
521
+ level,
522
+ event_type AS eventType,
523
+ message,
524
+ metadata_json AS metadataJson,
525
+ created_at AS createdAt
526
+ FROM ${this.options.opsEventsTableName ?? "ops_events"}
527
+ WHERE job_id IN (${placeholders})
528
+ ORDER BY created_at ASC
529
+ LIMIT 200`)
530
+ .bind(...jobIds)
531
+ .all();
532
+ return (rows.results ?? []).map(rowToActivityEvent);
533
+ }
534
+ catch {
535
+ return [];
536
+ }
537
+ }
538
+ async complete(jobId, status, result, lastError) {
539
+ const now = nowIso();
540
+ await this.db
541
+ .prepare(`UPDATE ${this.tableName}
542
+ SET status = ?, result_json = ?, last_error = ?, completed_at = ?, updated_at = ?
543
+ WHERE job_id = ?`)
544
+ .bind(status, stringifyJsonColumn(result), lastError, now, now, jobId)
545
+ .run();
546
+ const job = await this.get(jobId);
547
+ if (job) {
548
+ await this.emitStatusEvent(job, status, status === "succeeded" ? "Job succeeded." : status === "stopped" ? "Job stopped." : (lastError ?? "Job failed."));
549
+ }
550
+ }
551
+ async markStaleRunningQueued(jobId) {
552
+ const now = nowIso();
553
+ await this.db
554
+ .prepare(`UPDATE ${this.tableName}
555
+ SET status = 'queued',
556
+ available_at = ?,
557
+ last_error = ?,
558
+ updated_at = ?
559
+ WHERE job_id = ? AND status = 'running'`)
560
+ .bind(now, "Job reclaimed after stale running lease.", now, jobId)
561
+ .run();
562
+ const job = await this.get(jobId);
563
+ if (job) {
564
+ await this.emitStatusEvent(job, "queued", "Stale running job requeued.", {
565
+ previousStatus: "running",
566
+ });
567
+ }
568
+ }
569
+ async emitStatusEvent(job, status, message, metadata, error) {
570
+ await this.options.onStatusEvent?.({ job, status, message, metadata: metadata ?? null, error });
571
+ }
572
+ selectSql() {
573
+ return `SELECT
574
+ job_id AS jobId,
575
+ job_type AS jobType,
576
+ status,
577
+ org_id AS orgId,
578
+ site_id AS siteId,
579
+ payload_json AS payloadJson,
580
+ result_json AS resultJson,
581
+ metadata_json AS metadataJson,
582
+ parent_job_id AS parentJobId,
583
+ lock_key AS lockKey,
584
+ concurrency_key AS concurrencyKey,
585
+ concurrency_limit AS concurrencyLimit,
586
+ enqueue_policy AS enqueuePolicy,
587
+ attempt_count AS attemptCount,
588
+ max_attempts AS maxAttempts,
589
+ available_at AS availableAt,
590
+ started_at AS startedAt,
591
+ completed_at AS completedAt,
592
+ last_error AS lastError,
593
+ created_at AS createdAt,
594
+ updated_at AS updatedAt
595
+ FROM ${this.tableName}`;
596
+ }
597
+ }
598
+ const DEFAULT_POSTGRES_JOB_COLUMNS = {
599
+ jobId: "job_id",
600
+ orgId: "org_id",
601
+ siteId: "site_id",
602
+ queueName: "queue_name",
603
+ jobType: "job_type",
604
+ status: "status",
605
+ payloadJson: "payload_json",
606
+ resultJson: "result_json",
607
+ metadataJson: "metadata_json",
608
+ parentJobId: "parent_job_id",
609
+ lockKey: "lock_key",
610
+ concurrencyKey: "concurrency_key",
611
+ concurrencyLimit: "concurrency_limit",
612
+ enqueuePolicy: "enqueue_policy",
613
+ attemptCount: "attempt_count",
614
+ maxAttempts: "max_attempts",
615
+ availableAt: "available_at",
616
+ startedAt: "started_at",
617
+ completedAt: "completed_at",
618
+ lastError: "last_error",
619
+ createdAt: "created_at",
620
+ updatedAt: "updated_at",
621
+ };
622
+ export class PostgresJobStore {
623
+ sql;
624
+ options;
625
+ tableName;
626
+ columns;
627
+ constructor(sql, options = {}) {
628
+ this.sql = sql;
629
+ this.options = options;
630
+ this.tableName = quotePgIdent(options.tableName ?? "jobs");
631
+ this.columns = { ...DEFAULT_POSTGRES_JOB_COLUMNS, ...(options.columns ?? {}) };
632
+ }
633
+ async enqueue(input) {
634
+ if (input.enqueuePolicy === "dedupe_active" && input.lockKey) {
635
+ const existing = await this.getActiveForLockKey(input.lockKey);
636
+ if (existing)
637
+ return existing;
638
+ }
639
+ const jobId = createId(this.options.idPrefix ?? "job");
640
+ const queueName = stringValue(input.metadata?.queueName) ?? queueNameFromPayload(input.payload) ?? this.options.queueName ?? null;
641
+ const insertColumnKeys = [
642
+ "jobId",
643
+ "orgId",
644
+ "siteId",
645
+ "queueName",
646
+ "jobType",
647
+ "status",
648
+ "payloadJson",
649
+ "resultJson",
650
+ "metadataJson",
651
+ "parentJobId",
652
+ "lockKey",
653
+ "concurrencyKey",
654
+ "concurrencyLimit",
655
+ "enqueuePolicy",
656
+ "maxAttempts",
657
+ "availableAt",
658
+ ];
659
+ const insertColumns = insertColumnKeys.filter((key) => this.columns[key] !== null);
660
+ const insertValues = {
661
+ jobId,
662
+ orgId: input.orgId ?? null,
663
+ siteId: input.siteId ?? null,
664
+ queueName,
665
+ jobType: input.jobType,
666
+ status: this.toDbStatus("queued"),
667
+ payloadJson: jsonParam(input.payload ?? {}),
668
+ resultJson: jsonParam(this.options.initialResult ?? null),
669
+ metadataJson: jsonParam(input.metadata ?? {}),
670
+ parentJobId: input.parentJobId ?? null,
671
+ lockKey: input.lockKey ?? null,
672
+ concurrencyKey: input.concurrencyKey ?? null,
673
+ concurrencyLimit: normalizeConcurrencyLimit(input.concurrencyLimit),
674
+ enqueuePolicy: input.enqueuePolicy ?? "enqueue",
675
+ attemptCount: 0,
676
+ maxAttempts: Math.max(1, Math.floor(input.maxAttempts ?? this.options.defaultMaxAttempts ?? 3)),
677
+ availableAt: input.availableAt ?? null,
678
+ startedAt: null,
679
+ completedAt: null,
680
+ lastError: null,
681
+ createdAt: null,
682
+ updatedAt: null,
683
+ };
684
+ const values = insertColumns.map((key) => insertValues[key]);
685
+ await this.sql.unsafe(`INSERT INTO ${this.tableName} (
686
+ ${insertColumns.map((key) => this.col(key)).join(", ")}
687
+ ) VALUES (${insertColumns.map((key, index) => this.insertPlaceholder(key, index + 1)).join(", ")})`, values);
688
+ const job = await this.get(jobId);
689
+ if (!job)
690
+ throw new Error(`Failed to load enqueued job: ${jobId}`);
691
+ return job;
692
+ }
693
+ async get(jobId) {
694
+ const rows = await this.sql.unsafe(`${this.selectSql()} WHERE ${this.col("jobId")} = $1 LIMIT 1`, [jobId]);
695
+ return rows[0] ? this.rowToJob(rows[0]) : null;
696
+ }
697
+ async claim(jobId) {
698
+ const queueClause = this.options.queueName ? `AND ${this.col("queueName")} = $2` : "";
699
+ const values = this.options.queueName ? [jobId, this.options.queueName] : [jobId];
700
+ const rows = await this.sql.unsafe(`UPDATE ${this.tableName}
701
+ SET ${this.col("status")} = '${this.toDbStatus("running")}',
702
+ ${this.col("attemptCount")} = ${this.col("attemptCount")} + 1,
703
+ ${this.setSql("startedAt", "now(),")}
704
+ ${this.col("updatedAt")} = now(),
705
+ ${this.setSql("lastError", "NULL")}
706
+ WHERE ${this.col("jobId")} = $1
707
+ ${queueClause}
708
+ AND ${this.col("status")} = '${this.toDbStatus("queued")}'
709
+ AND ${this.col("availableAt")} <= now()
710
+ AND (
711
+ ${this.nullableColSql("concurrencyKey", "NULL")} IS NULL
712
+ OR (
713
+ SELECT COUNT(*)
714
+ FROM ${this.tableName} active
715
+ WHERE active.${this.colRaw("concurrencyKey")} = ${this.tableName}.${this.colRaw("concurrencyKey")}
716
+ AND active.${this.colRaw("status")} IN ('${this.toDbStatus("running")}', '${this.toDbStatus("stopping")}')
717
+ AND active.${this.colRaw("jobId")} <> ${this.tableName}.${this.colRaw("jobId")}
718
+ ) < COALESCE(${this.nullableColSql("concurrencyLimit", "1")}, 1)
719
+ )
720
+ RETURNING ${this.returningSql()}`, values);
721
+ return rows[0] ? this.rowToJob(rows[0]) : null;
722
+ }
723
+ async succeed(jobId, result = null) {
724
+ await this.complete(jobId, "succeeded", result, null);
725
+ }
726
+ async requeue(jobId, input = {}) {
727
+ const payloadSet = input.payload === undefined ? "" : `${this.col("payloadJson")} = $5::jsonb,`;
728
+ const values = [
729
+ jsonParam(input.result ?? null),
730
+ input.availableAt ?? null,
731
+ input.lastError ?? null,
732
+ jobId,
733
+ ...(input.payload === undefined ? [] : [jsonParam(input.payload)]),
734
+ ];
735
+ await this.sql.unsafe(`UPDATE ${this.tableName}
736
+ SET ${this.col("status")} = '${this.toDbStatus("queued")}',
737
+ ${payloadSet}
738
+ ${this.col("resultJson")} = $1::jsonb,
739
+ ${this.col("availableAt")} = COALESCE($2::timestamptz, now()),
740
+ ${this.setSql("lastError", "$3,")}
741
+ ${this.col("updatedAt")} = now()
742
+ WHERE ${this.col("jobId")} = $4`, values);
743
+ }
744
+ async fail(jobId, error) {
745
+ await this.complete(jobId, "failed", null, errorMessage(error));
746
+ }
747
+ async stop(jobId, result = null) {
748
+ await this.complete(jobId, "stopped", result, null);
749
+ }
750
+ async requestStop(jobId) {
751
+ await this.sql.unsafe(`UPDATE ${this.tableName}
752
+ SET ${this.col("status")} = CASE
753
+ WHEN ${this.col("status")} = '${this.toDbStatus("queued")}' THEN '${this.toDbStatus("stopped")}'
754
+ ELSE '${this.toDbStatus("stopping")}'
755
+ END,
756
+ ${this.setSql("completedAt", `CASE
757
+ WHEN ${this.col("status")} = '${this.toDbStatus("queued")}' THEN now()
758
+ ELSE ${this.col("completedAt")}
759
+ END,`)}
760
+ ${this.col("updatedAt")} = now()
761
+ WHERE ${this.col("jobId")} = $1
762
+ AND ${this.col("status")} IN ('${this.toDbStatus("queued")}', '${this.toDbStatus("running")}')`, [jobId]);
763
+ }
764
+ async listChildren(parentJobId) {
765
+ const rows = await this.sql.unsafe(`${this.selectSql()} WHERE ${this.col("parentJobId")} = $1 ORDER BY ${this.col("createdAt")} ASC`, [parentJobId]);
766
+ return rows.map((row) => this.rowToJob(row));
767
+ }
768
+ async summarizeChildren(parentJobId) {
769
+ return summarizeJobs(parentJobId, await this.listChildren(parentJobId));
770
+ }
771
+ async markQueuedChildrenTerminal(parentJobId, status, error) {
772
+ const rows = await this.sql.unsafe(`WITH updated AS (
773
+ UPDATE ${this.tableName}
774
+ SET ${this.col("status")} = $1,
775
+ ${this.setSql("lastError", "$2,")}
776
+ ${this.setSql("completedAt", "now(),")}
777
+ ${this.col("updatedAt")} = now()
778
+ WHERE ${this.col("parentJobId")} = $3
779
+ AND ${this.col("status")} = '${this.toDbStatus("queued")}'
780
+ RETURNING ${this.col("jobId")}
781
+ )
782
+ SELECT COUNT(*)::int AS changed FROM updated`, [this.toDbStatus(status), status === "failed" && error ? errorMessage(error) : null, parentJobId]);
783
+ return Number(rows[0]?.changed ?? 0);
784
+ }
785
+ async requestStopActiveChildren(parentJobId) {
786
+ const rows = await this.sql.unsafe(`WITH updated AS (
787
+ UPDATE ${this.tableName}
788
+ SET ${this.col("status")} = '${this.toDbStatus("stopping")}',
789
+ ${this.col("updatedAt")} = now()
790
+ WHERE ${this.col("parentJobId")} = $1
791
+ AND ${this.col("status")} = '${this.toDbStatus("running")}'
792
+ RETURNING ${this.col("jobId")}
793
+ )
794
+ SELECT COUNT(*)::int AS changed FROM updated`, [parentJobId]);
795
+ return Number(rows[0]?.changed ?? 0);
796
+ }
797
+ async getActiveForLockKey(lockKey) {
798
+ const rows = await this.sql.unsafe(`${this.selectSql()}
799
+ WHERE ${this.col("lockKey")} = $1
800
+ AND ${this.col("status")} IN ('${this.toDbStatus("queued")}', '${this.toDbStatus("running")}', '${this.toDbStatus("stopping")}')
801
+ ORDER BY ${this.col("createdAt")} DESC
802
+ LIMIT 1`, [lockKey]);
803
+ return rows[0] ? this.rowToJob(rows[0]) : null;
804
+ }
805
+ async complete(jobId, status, result, lastError) {
806
+ await this.sql.unsafe(`UPDATE ${this.tableName}
807
+ SET ${this.col("status")} = $1,
808
+ ${this.col("resultJson")} = $2::jsonb,
809
+ ${this.setSql("lastError", "$3,")}
810
+ ${this.setSql("completedAt", "now(),")}
811
+ ${this.col("updatedAt")} = now()
812
+ WHERE ${this.col("jobId")} = $4`, [this.toDbStatus(status), jsonParam(result), lastError, jobId]);
813
+ }
814
+ selectSql() {
815
+ return `SELECT ${this.returningSql()} FROM ${this.tableName}`;
816
+ }
817
+ returningSql() {
818
+ return [
819
+ ["jobId", "jobId"],
820
+ ["jobType", "jobType"],
821
+ ["status", "status"],
822
+ ["orgId", "orgId"],
823
+ ["siteId", "siteId"],
824
+ ["payloadJson", "payloadJson"],
825
+ ["resultJson", "resultJson"],
826
+ ["metadataJson", "metadataJson"],
827
+ ["parentJobId", "parentJobId"],
828
+ ["lockKey", "lockKey"],
829
+ ["concurrencyKey", "concurrencyKey"],
830
+ ["concurrencyLimit", "concurrencyLimit"],
831
+ ["enqueuePolicy", "enqueuePolicy"],
832
+ ["attemptCount", "attemptCount"],
833
+ ["maxAttempts", "maxAttempts"],
834
+ ["availableAt", "availableAt"],
835
+ ["startedAt", "startedAt"],
836
+ ["completedAt", "completedAt"],
837
+ ["lastError", "lastError"],
838
+ ["createdAt", "createdAt"],
839
+ ["updatedAt", "updatedAt"],
840
+ ]
841
+ .map(([key, alias]) => `${this.selectExpr(key)} AS "${alias}"`)
842
+ .join(", ");
843
+ }
844
+ rowToJob(row) {
845
+ return rowToJob({
846
+ ...row,
847
+ status: this.fromDbStatus(String(row.status)),
848
+ payloadJson: jsonText(row.payloadJson) ?? "{}",
849
+ resultJson: jsonText(row.resultJson),
850
+ metadataJson: jsonText(row.metadataJson),
851
+ availableAt: dateText(row.availableAt),
852
+ startedAt: dateText(row.startedAt),
853
+ completedAt: dateText(row.completedAt),
854
+ createdAt: dateText(row.createdAt) ?? "",
855
+ updatedAt: dateText(row.updatedAt) ?? "",
856
+ });
857
+ }
858
+ toDbStatus(status) {
859
+ return this.options.statusMap?.[status] ?? status;
860
+ }
861
+ fromDbStatus(status) {
862
+ return this.options.reverseStatusMap?.[status] ?? status;
863
+ }
864
+ col(key) {
865
+ const column = this.columns[key];
866
+ if (!column)
867
+ throw new Error(`Postgres job column is not configured: ${String(key)}`);
868
+ return quotePgIdent(column);
869
+ }
870
+ colRaw(key) {
871
+ const column = this.columns[key];
872
+ if (!column)
873
+ throw new Error(`Postgres job column is not configured: ${String(key)}`);
874
+ return column;
875
+ }
876
+ insertPlaceholder(key, index) {
877
+ if (key === "payloadJson" || key === "resultJson" || key === "metadataJson")
878
+ return `$${index}::jsonb`;
879
+ if (key === "availableAt" || key === "startedAt" || key === "completedAt")
880
+ return `COALESCE($${index}::timestamptz, now())`;
881
+ return `$${index}`;
882
+ }
883
+ nullableColSql(key, fallback) {
884
+ return this.columns[key] ? this.col(key) : fallback;
885
+ }
886
+ selectExpr(key) {
887
+ if (this.columns[key])
888
+ return this.col(key);
889
+ if (key === "metadataJson")
890
+ return `'{}'::jsonb`;
891
+ if (key === "resultJson")
892
+ return `'null'::jsonb`;
893
+ if (key === "enqueuePolicy")
894
+ return `'enqueue'`;
895
+ if (key === "concurrencyLimit" || key === "attemptCount" || key === "maxAttempts")
896
+ return "0";
897
+ return "NULL";
898
+ }
899
+ setSql(key, assignment) {
900
+ return this.columns[key] ? `${this.col(key)} = ${assignment}` : "";
901
+ }
902
+ }
903
+ export class PipelineManager {
904
+ options;
905
+ constructor(options) {
906
+ this.options = options;
907
+ }
908
+ getNextStep(step) {
909
+ const index = this.options.steps.indexOf(step);
910
+ if (index === -1)
911
+ throw new Error(`Unknown pipeline step: ${step}`);
912
+ return this.options.steps[index + 1] ?? null;
913
+ }
914
+ async run(job) {
915
+ if (job.pipeline !== this.options.pipeline)
916
+ throw new Error(`Unexpected pipeline: ${job.pipeline}`);
917
+ const stepIndex = this.options.steps.indexOf(job.step);
918
+ if (stepIndex === -1)
919
+ throw new Error(`Unknown pipeline step: ${job.step}`);
920
+ const defaultNextStep = this.options.steps[stepIndex + 1] ?? null;
921
+ const result = await this.options.handlers[job.step]({
922
+ job,
923
+ stepIndex,
924
+ isFinalStep: defaultNextStep === null,
925
+ });
926
+ const nextState = result?.state ?? job.state;
927
+ const explicitNextStep = result?.nextStep;
928
+ const nextStep = explicitNextStep === undefined
929
+ ? defaultNextStep
930
+ : explicitNextStep === null
931
+ ? null
932
+ : explicitNextStep.step;
933
+ const enqueuedState = explicitNextStep && explicitNextStep !== null ? (explicitNextStep.state ?? nextState) : nextState;
934
+ if (nextStep) {
935
+ const continuationId = this.options.makeContinuationId?.();
936
+ const nextJob = {
937
+ pipeline: job.pipeline,
938
+ runId: job.runId,
939
+ step: nextStep,
940
+ ...(continuationId ? { continuationId } : {}),
941
+ state: enqueuedState,
942
+ };
943
+ if (result?.nextStepDelaySeconds === undefined)
944
+ await this.options.enqueue(nextJob);
945
+ else
946
+ await this.options.enqueue(nextJob, { delaySeconds: result.nextStepDelaySeconds });
947
+ }
948
+ const completion = result?.completion ??
949
+ (nextStep === null && defaultNextStep === null
950
+ ? {
951
+ status: "completed",
952
+ targetsTotal: 0,
953
+ targetsSucceeded: 0,
954
+ targetsFailed: 0,
955
+ message: `${job.pipeline} pipeline completed`,
956
+ }
957
+ : null);
958
+ return { nextStep, state: enqueuedState, completion };
959
+ }
960
+ }
961
+ export function createJobPublisher(options) {
962
+ return {
963
+ async put(message, sendOptions = {}) {
964
+ if (options.inline) {
965
+ if (!options.handleInline)
966
+ throw new Error("Inline job publisher requires handleInline.");
967
+ await options.onQueued?.(message, { ...sendOptions, mode: "inline" });
968
+ await options.handleInline(message);
969
+ return;
970
+ }
971
+ if (!options.queue)
972
+ throw new Error("Job queue is not configured.");
973
+ await options.onQueued?.(message, { ...sendOptions, mode: "queue" });
974
+ await options.queue.send(message, sendOptions);
975
+ },
976
+ };
977
+ }
978
+ export async function enqueueJobOperation(jobs, input) {
979
+ const keys = jobOperationKeys(input);
980
+ return jobs.enqueue({
981
+ jobType: input.jobType,
982
+ orgId: input.orgId ?? null,
983
+ siteId: input.siteId ?? null,
984
+ parentJobId: input.parentJobId ?? null,
985
+ payload: jobOperationPayload(input),
986
+ lockKey: keys.lockKey,
987
+ concurrencyKey: keys.concurrencyKey,
988
+ concurrencyLimit: keys.concurrencyLimit,
989
+ enqueuePolicy: input.dedupeActive === false ? "enqueue" : "dedupe_active",
990
+ maxAttempts: input.maxAttempts ?? undefined,
991
+ availableAt: input.availableAt ?? null,
992
+ });
993
+ }
994
+ export function jobOperationKeys(input) {
995
+ const scope = keyPart(input.siteId ? `site:${input.siteId}` : "global");
996
+ const operation = keyPart(input.operationKey);
997
+ const entityKey = input.entity ? jobOperationEntityKey(input.entity) : null;
998
+ const concurrencyScope = keyPart(input.concurrencyScope ?? input.operationKey);
999
+ return {
1000
+ lockKey: input.dedupeActive === false
1001
+ ? null
1002
+ : ["jobop", scope, operation, entityKey ? keyPart(entityKey) : "root"].join(":"),
1003
+ concurrencyKey: ["jobop", scope, concurrencyScope].join(":"),
1004
+ concurrencyLimit: normalizeOperationConcurrencyLimit(input.concurrencyLimit),
1005
+ };
149
1006
  }
150
1007
  export const D1_JOBS_SCHEMA = `
151
1008
  CREATE TABLE IF NOT EXISTS jobs (
@@ -154,7 +1011,14 @@ CREATE TABLE IF NOT EXISTS jobs (
154
1011
  status TEXT NOT NULL,
155
1012
  payload_json TEXT NOT NULL DEFAULT '{}',
156
1013
  result_json TEXT,
1014
+ org_id TEXT,
1015
+ site_id TEXT,
1016
+ metadata_json TEXT NOT NULL DEFAULT '{}',
1017
+ parent_job_id TEXT,
157
1018
  lock_key TEXT,
1019
+ concurrency_key TEXT,
1020
+ concurrency_limit INTEGER,
1021
+ enqueue_policy TEXT NOT NULL DEFAULT 'enqueue',
158
1022
  attempt_count INTEGER NOT NULL DEFAULT 0,
159
1023
  max_attempts INTEGER NOT NULL DEFAULT 3,
160
1024
  available_at TEXT,
@@ -166,8 +1030,13 @@ CREATE TABLE IF NOT EXISTS jobs (
166
1030
  );
167
1031
  CREATE INDEX IF NOT EXISTS jobs_status_available_idx ON jobs(status, available_at, created_at);
168
1032
  CREATE INDEX IF NOT EXISTS jobs_type_status_idx ON jobs(job_type, status, created_at);
1033
+ CREATE INDEX IF NOT EXISTS jobs_org_status_idx ON jobs(org_id, status, created_at);
1034
+ CREATE INDEX IF NOT EXISTS jobs_site_status_idx ON jobs(site_id, status, created_at);
1035
+ CREATE INDEX IF NOT EXISTS jobs_parent_status_idx ON jobs(parent_job_id, status, created_at);
1036
+ CREATE INDEX IF NOT EXISTS jobs_concurrency_active_idx ON jobs(concurrency_key, status)
1037
+ WHERE concurrency_key IS NOT NULL AND status IN ('running', 'stopping');
169
1038
  CREATE UNIQUE INDEX IF NOT EXISTS jobs_active_lock_key_idx ON jobs(lock_key)
170
- WHERE lock_key IS NOT NULL AND status IN ('queued', 'running');
1039
+ WHERE lock_key IS NOT NULL AND status IN ('queued', 'running', 'stopping');
171
1040
  `;
172
1041
  function rowToJob(row) {
173
1042
  return {
@@ -176,7 +1045,14 @@ function rowToJob(row) {
176
1045
  status: row.status,
177
1046
  payload: parseJsonColumn(row.payloadJson, {}),
178
1047
  result: parseJsonColumn(row.resultJson, null),
1048
+ orgId: row.orgId ?? null,
1049
+ siteId: row.siteId ?? null,
1050
+ parentJobId: row.parentJobId ?? null,
179
1051
  lockKey: row.lockKey,
1052
+ concurrencyKey: row.concurrencyKey ?? null,
1053
+ concurrencyLimit: row.concurrencyLimit === undefined || row.concurrencyLimit === null ? null : Number(row.concurrencyLimit),
1054
+ enqueuePolicy: row.enqueuePolicy ?? "enqueue",
1055
+ metadata: parseJsonColumn(row.metadataJson, {}),
180
1056
  attemptCount: Number(row.attemptCount),
181
1057
  maxAttempts: Number(row.maxAttempts),
182
1058
  availableAt: row.availableAt,
@@ -192,4 +1068,200 @@ function normalizeJobResult(output) {
192
1068
  return output;
193
1069
  return { kind: "done", result: (output ?? null) };
194
1070
  }
1071
+ function quotePgIdent(value) {
1072
+ if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(value))
1073
+ throw new Error(`Invalid Postgres identifier: ${value}`);
1074
+ return `"${value}"`;
1075
+ }
1076
+ function jsonText(value) {
1077
+ if (value === null || value === undefined)
1078
+ return null;
1079
+ return typeof value === "string" ? value : JSON.stringify(value);
1080
+ }
1081
+ function jsonParam(value) {
1082
+ return JSON.stringify(value ?? null);
1083
+ }
1084
+ function queueNameFromPayload(value) {
1085
+ if (!value || typeof value !== "object" || Array.isArray(value))
1086
+ return null;
1087
+ return stringValue(value.queueName);
1088
+ }
1089
+ function dateText(value) {
1090
+ if (value === null || value === undefined)
1091
+ return null;
1092
+ if (value instanceof Date)
1093
+ return value.toISOString();
1094
+ if (typeof value === "string")
1095
+ return value;
1096
+ return String(value);
1097
+ }
1098
+ function rowToActivityEvent(row) {
1099
+ const level = row.level === "warn" || row.level === "error" ? row.level : "info";
1100
+ return {
1101
+ opsEventId: row.opsEventId,
1102
+ jobId: row.jobId,
1103
+ level,
1104
+ eventType: row.eventType,
1105
+ message: row.message,
1106
+ metadata: parseJsonColumn(row.metadataJson, null),
1107
+ createdAt: row.createdAt,
1108
+ };
1109
+ }
1110
+ function summarizeJobs(parentJobId, jobs) {
1111
+ const summary = {
1112
+ parentJobId,
1113
+ total: jobs.length,
1114
+ queued: 0,
1115
+ running: 0,
1116
+ stopping: 0,
1117
+ succeeded: 0,
1118
+ failed: 0,
1119
+ stopped: 0,
1120
+ active: 0,
1121
+ terminal: 0,
1122
+ allTerminal: jobs.length > 0,
1123
+ };
1124
+ for (const job of jobs) {
1125
+ summary[job.status] += 1;
1126
+ if (isTerminalJobStatus(job.status))
1127
+ summary.terminal += 1;
1128
+ else
1129
+ summary.active += 1;
1130
+ }
1131
+ summary.allTerminal = summary.total > 0 && summary.terminal === summary.total;
1132
+ return summary;
1133
+ }
1134
+ function rollupJobTreeStatus(jobs) {
1135
+ if (jobs.some((job) => job.status === "failed"))
1136
+ return "failed";
1137
+ if (jobs.some((job) => job.status === "stopped"))
1138
+ return "stopped";
1139
+ if (jobs.some((job) => job.status === "running" || job.status === "stopping"))
1140
+ return "running";
1141
+ if (jobs.some((job) => job.status === "queued"))
1142
+ return "queued";
1143
+ return "succeeded";
1144
+ }
1145
+ function latestJobError(jobs) {
1146
+ return ([...jobs]
1147
+ .sort((a, b) => b.updatedAt.localeCompare(a.updatedAt))
1148
+ .find((job) => job.lastError && job.lastError.trim().length > 0)?.lastError ?? null);
1149
+ }
1150
+ function jobEntityState(job, depth) {
1151
+ const payload = isRecord(job.payload) ? job.payload : {};
1152
+ const entityType = stringValue(payload.entityType);
1153
+ const entityId = stringValue(payload.entityId);
1154
+ const entityKey = stringValue(payload.entityKey) ?? (entityType && entityId ? `${entityType}:${entityId}` : job.jobId);
1155
+ return {
1156
+ jobId: job.jobId,
1157
+ jobType: job.jobType,
1158
+ status: job.status,
1159
+ entityKey,
1160
+ entityType,
1161
+ entityId,
1162
+ label: stringValue(payload.entityLabel),
1163
+ lastError: job.lastError,
1164
+ result: job.result,
1165
+ depth,
1166
+ };
1167
+ }
1168
+ function emptyChildrenSummary(parentJobId) {
1169
+ return summarizeJobs(parentJobId, []);
1170
+ }
1171
+ function requeueParent(payload, children, summary, input) {
1172
+ const pollSeconds = Math.max(1, Math.floor(input.pollSeconds ?? 10));
1173
+ const now = input.now?.() ?? new Date();
1174
+ return {
1175
+ kind: "requeue",
1176
+ payload,
1177
+ result: {
1178
+ stage: "waiting_children",
1179
+ failureMode: input.failureMode ?? "fail_fast",
1180
+ queuedSiblingPolicy: input.queuedSiblingPolicy ?? "fail",
1181
+ childJobIds: children.map((child) => child.jobId),
1182
+ summary,
1183
+ },
1184
+ availableAt: new Date(now.getTime() + pollSeconds * 1000).toISOString(),
1185
+ };
1186
+ }
1187
+ function isTerminalJobStatus(status) {
1188
+ return status === "succeeded" || status === "failed" || status === "stopped";
1189
+ }
1190
+ function normalizeConcurrencyLimit(value) {
1191
+ if (value === null || value === undefined)
1192
+ return null;
1193
+ return Math.max(1, Math.floor(value));
1194
+ }
1195
+ function jobOperationPayload(input) {
1196
+ return {
1197
+ ...(input.payload ?? {}),
1198
+ operationKey: input.operationKey,
1199
+ ...(input.entity
1200
+ ? {
1201
+ entityType: input.entity.entityType,
1202
+ entityId: input.entity.entityId,
1203
+ entityKey: jobOperationEntityKey(input.entity),
1204
+ ...(input.entity.entityLabel ? { entityLabel: input.entity.entityLabel } : {}),
1205
+ }
1206
+ : {}),
1207
+ };
1208
+ }
1209
+ function jobOperationEntityKey(entity) {
1210
+ return entity.entityKey?.trim() || `${entity.entityType}:${entity.entityId}`;
1211
+ }
1212
+ function keyPart(value) {
1213
+ return value
1214
+ .trim()
1215
+ .toLowerCase()
1216
+ .replace(/[^a-z0-9:_-]+/g, "-")
1217
+ .replace(/-+/g, "-")
1218
+ .replace(/^-|-$/g, "");
1219
+ }
1220
+ function normalizeOperationConcurrencyLimit(value) {
1221
+ if (!Number.isFinite(value) || !value)
1222
+ return 1;
1223
+ return Math.max(1, Math.floor(value));
1224
+ }
1225
+ function isRecord(value) {
1226
+ return !!value && typeof value === "object" && !Array.isArray(value);
1227
+ }
1228
+ function stringValue(value) {
1229
+ return typeof value === "string" && value.trim().length > 0 ? value : null;
1230
+ }
1231
+ function isRequeueInput(value) {
1232
+ return (!!value &&
1233
+ typeof value === "object" &&
1234
+ ("payload" in value || "result" in value || "availableAt" in value || "lastError" in value));
1235
+ }
1236
+ function isStaleRunningJob(job, staleRunningAfterSeconds) {
1237
+ if (!staleRunningAfterSeconds || staleRunningAfterSeconds <= 0 || !job.startedAt)
1238
+ return false;
1239
+ const startedAt = Date.parse(job.startedAt);
1240
+ if (!Number.isFinite(startedAt))
1241
+ return false;
1242
+ return Date.now() - startedAt >= staleRunningAfterSeconds * 1000;
1243
+ }
1244
+ function errorMessage(error) {
1245
+ return error instanceof Error ? error.message : String(error);
1246
+ }
1247
+ class DurableJobEvents {
1248
+ job;
1249
+ sink;
1250
+ constructor(job, sink) {
1251
+ this.job = job;
1252
+ this.sink = sink;
1253
+ }
1254
+ async info(input) {
1255
+ await this.emit({ ...input, severity: "info" });
1256
+ }
1257
+ async warn(input) {
1258
+ await this.emit({ ...input, severity: "warn" });
1259
+ }
1260
+ async error(input) {
1261
+ await this.emit({ ...input, severity: "error" });
1262
+ }
1263
+ async emit(input) {
1264
+ await this.sink?.(this.job, input);
1265
+ }
1266
+ }
195
1267
  //# sourceMappingURL=jobs.js.map