effect-mq 0.3.2 → 0.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 (42) hide show
  1. package/README.md +102 -14
  2. package/dist/Job.d.ts +131 -9
  3. package/dist/Job.d.ts.map +1 -1
  4. package/dist/Job.js +81 -5
  5. package/dist/Job.js.map +1 -1
  6. package/dist/JobStore.d.ts +63 -2
  7. package/dist/JobStore.d.ts.map +1 -1
  8. package/dist/JobStore.js.map +1 -1
  9. package/dist/MemoryJobStore.d.ts.map +1 -1
  10. package/dist/MemoryJobStore.js +159 -119
  11. package/dist/MemoryJobStore.js.map +1 -1
  12. package/dist/Worker.d.ts +18 -0
  13. package/dist/Worker.d.ts.map +1 -1
  14. package/dist/Worker.js +37 -9
  15. package/dist/Worker.js.map +1 -1
  16. package/dist/drizzle-postgres/DrizzleJobStore.d.ts.map +1 -1
  17. package/dist/drizzle-postgres/DrizzleJobStore.js +239 -49
  18. package/dist/drizzle-postgres/DrizzleJobStore.js.map +1 -1
  19. package/dist/drizzle-postgres/schema.d.ts +2 -0
  20. package/dist/drizzle-postgres/schema.d.ts.map +1 -1
  21. package/dist/drizzle-postgres/schema.js +1 -0
  22. package/dist/drizzle-postgres/schema.js.map +1 -1
  23. package/dist/redis/RedisJobStore.d.ts.map +1 -1
  24. package/dist/redis/RedisJobStore.js +173 -36
  25. package/dist/redis/RedisJobStore.js.map +1 -1
  26. package/dist/redis/scripts.d.ts +24 -1
  27. package/dist/redis/scripts.d.ts.map +1 -1
  28. package/dist/redis/scripts.js +126 -21
  29. package/dist/redis/scripts.js.map +1 -1
  30. package/dist/testing/conformance.d.ts.map +1 -1
  31. package/dist/testing/conformance.js +266 -0
  32. package/dist/testing/conformance.js.map +1 -1
  33. package/package.json +1 -1
  34. package/src/Job.ts +268 -13
  35. package/src/JobStore.ts +77 -2
  36. package/src/MemoryJobStore.ts +102 -53
  37. package/src/Worker.ts +61 -9
  38. package/src/drizzle-postgres/DrizzleJobStore.ts +273 -66
  39. package/src/drizzle-postgres/schema.ts +2 -0
  40. package/src/redis/RedisJobStore.ts +236 -47
  41. package/src/redis/scripts.ts +153 -21
  42. package/src/testing/conformance.ts +350 -0
@@ -113,6 +113,7 @@ type JobRow = {
113
113
  readonly timeoutMs: number | string | null
114
114
  readonly cancelRequested: boolean
115
115
  readonly dedupeKey: string | null
116
+ readonly trace: JobStore.TraceContext | null
116
117
  readonly runAt: Date
117
118
  readonly enqueuedAt: Date
118
119
  readonly processedAt: Date | null
@@ -171,6 +172,7 @@ const toRecord = (row: JobRow): JobStore.JobRecord => ({
171
172
  timeoutMs: row.timeoutMs === null || row.timeoutMs === undefined ? undefined : Number(row.timeoutMs),
172
173
  cancelRequested: row.cancelRequested,
173
174
  dedupeKey: row.dedupeKey ?? undefined,
175
+ trace: row.trace ?? undefined,
174
176
  runAt: row.runAt.getTime(),
175
177
  enqueuedAt: row.enqueuedAt.getTime(),
176
178
  processedAt: row.processedAt?.getTime(),
@@ -220,6 +222,7 @@ export const make = (
220
222
  "timeoutMs",
221
223
  "cancelRequested",
222
224
  "dedupeKey",
225
+ "trace",
223
226
  "runAt",
224
227
  "enqueuedAt",
225
228
  "processedAt",
@@ -490,13 +493,15 @@ export const make = (
490
493
  : sql`'j-' || ${seqExpr}::text`
491
494
  const rows = rowsOf(yield* exec.execute<{ id: string }>(sql`
492
495
  INSERT INTO ${jobs} (id, name, queue, state, priority, payload, metadata,
493
- attempts_max, backoff, keep, timeout_ms, dedupe_key, run_at, enqueued_at${extraColumnNames})
496
+ attempts_max, backoff, keep, timeout_ms, dedupe_key, trace, run_at, enqueued_at${extraColumnNames})
494
497
  VALUES (${idExpr}, ${request.name}, ${request.queue}, ${state}, ${request.priority},
495
498
  ${JSON.stringify(request.payload ?? null)}::jsonb, ${JSON.stringify(request.metadata)}::jsonb,
496
499
  ${request.attemptsMax},
497
500
  ${request.backoff === undefined ? null : JSON.stringify(request.backoff)}::jsonb,
498
501
  ${request.keep === undefined ? null : JSON.stringify(request.keep)}::jsonb,
499
- ${request.timeoutMs ?? null}, ${request.dedupe?.key ?? null}, ${runAt}, ${now}${extraColumnValues(request)})
502
+ ${request.timeoutMs ?? null}, ${request.dedupe?.key ?? null},
503
+ ${request.trace === undefined ? null : JSON.stringify(request.trace)}::jsonb,
504
+ ${runAt}, ${now}${extraColumnValues(request)})
500
505
  ON CONFLICT (id) DO NOTHING
501
506
  RETURNING ${jobs.id} AS id
502
507
  `).pipe(Effect.mapError(storeError("enqueue failed"))))
@@ -572,6 +577,7 @@ export const make = (
572
577
  backoff = ${request.backoff === undefined ? null : JSON.stringify(request.backoff)}::jsonb,
573
578
  keep = ${request.keep === undefined ? null : JSON.stringify(request.keep)}::jsonb,
574
579
  timeout_ms = ${request.timeoutMs ?? null},
580
+ trace = ${request.trace === undefined ? null : JSON.stringify(request.trace)}::jsonb,
575
581
  run_at = ${new Date(now.getTime() + Math.max(0, request.delayMs))}${extraColumnAssignments(request)}
576
582
  WHERE ${jobs.id} = ${entry.jobId} AND ${jobs.state} = 'delayed'
577
583
  RETURNING ${jobs.id} AS id, ${jobs.queue} AS "queue"
@@ -650,22 +656,224 @@ export const make = (
650
656
  AND (${dedupe.windowExpiresAt} IS NULL OR ${dedupe.windowExpiresAt} <= ${now})
651
657
  `).pipe(Effect.asVoid)
652
658
 
659
+ // Shared by cancel and cancelByDedupe.
660
+ const cancelJob = (id: JobStore.JobId) =>
661
+ db.transaction((tx) =>
662
+ Effect.gen(function*() {
663
+ const now = yield* nowDate
664
+ // One guarded statement: waiting/delayed become terminal, active
665
+ // gets the cancel-request flag; anything else is reported by state.
666
+ const rows = rowsOf(yield* tx.execute<
667
+ {
668
+ id: string
669
+ state: string
670
+ processedAt: Date | null
671
+ name: string
672
+ keep: JobStore.KeepPolicy | null
673
+ dedupeKey: string | null
674
+ }
675
+ >(sql`
676
+ UPDATE ${jobs} SET
677
+ state = CASE WHEN ${jobs.state} IN ('waiting', 'delayed') THEN 'cancelled' ELSE ${jobs.state} END,
678
+ finished_at = CASE WHEN ${jobs.state} IN ('waiting', 'delayed') THEN ${now}::timestamptz ELSE ${jobs.finishedAt} END,
679
+ cancel_requested = CASE WHEN ${jobs.state} = 'active' THEN TRUE ELSE ${jobs.cancelRequested} END
680
+ WHERE ${jobs.id} = ${id} AND ${jobs.state} IN ('waiting', 'delayed', 'active')
681
+ RETURNING ${jobs.id} AS id, ${jobs.state} AS state,
682
+ ${jobs.processedAt} AS "processedAt", ${jobs.name} AS "name", ${jobs.keep} AS "keep",
683
+ ${jobs.dedupeKey} AS "dedupeKey"
684
+ `))
685
+ const row = rows[0]
686
+ if (row === undefined) {
687
+ const existing = rowsOf(yield* tx.execute<{ state: JobStore.JobState }>(sql`
688
+ SELECT ${jobs.state} AS state FROM ${jobs} WHERE ${jobs.id} = ${id}
689
+ `))
690
+ const found = existing[0]
691
+ if (found === undefined) {
692
+ return yield* new JobStore.JobNotFoundError({ jobId: id })
693
+ }
694
+ return yield* new JobStore.JobNotCancellableError({ jobId: id, state: found.state })
695
+ }
696
+ if (row.state === "cancelled") {
697
+ yield* insertAttempt(tx, id, "cancelled", row.processedAt, now, undefined)
698
+ yield* releaseDedupe(tx, row.name, row.dedupeKey, id, now)
699
+ yield* applyKeep(tx, { name: row.name, state: "cancelled", keep: row.keep }, now)
700
+ }
701
+ })
702
+ ).pipe(
703
+ Effect.mapError((error) =>
704
+ error instanceof JobStore.JobNotFoundError ||
705
+ error instanceof JobStore.JobNotCancellableError ||
706
+ error instanceof JobStore.JobStoreError
707
+ ? error
708
+ : storeError("cancel failed")(error)
709
+ ),
710
+ Effect.asVoid
711
+ )
712
+
713
+ const enqueueOne = (request: JobStore.EnqueueRequest) =>
714
+ Effect.gen(function*() {
715
+ if (request.dedupe !== undefined) {
716
+ const result = yield* enqueueDeduped(request, request.dedupe)
717
+ if (result.wake) {
718
+ yield* wakeUp("wakeQueue" in result && result.wakeQueue !== undefined ? result.wakeQueue : request.queue)
719
+ }
720
+ return { id: result.id, duplicate: result.duplicate }
721
+ }
722
+ const now = yield* nowDate
723
+ const result = yield* insertJob(db, request, now)
724
+ if (!result.duplicate) {
725
+ yield* wakeUp(request.queue)
726
+ }
727
+ return result
728
+ })
729
+
730
+ // Multi-row insert with every id resolved client-side, so the ON
731
+ // CONFLICT outcome maps back to items unambiguously (RETURNING only
732
+ // yields rows that actually inserted, in no guaranteed order). Auto ids
733
+ // draw from the seq sequence one statement per round; a conflicted auto
734
+ // id (user squatting on "j-<n>", or a colliding generator) re-draws.
735
+ const insertBatch = (requests: ReadonlyArray<JobStore.EnqueueRequest>) =>
736
+ Effect.gen(function*() {
737
+ const now = yield* nowDate
738
+ const generate = options.idGenerator
739
+ const results: Array<JobStore.EnqueueResult | undefined> = requests.map(() => undefined)
740
+ interface PendingItem {
741
+ readonly request: JobStore.EnqueueRequest
742
+ readonly index: number
743
+ id: JobStore.JobId | undefined
744
+ }
745
+ let pending: Array<PendingItem> = requests.map((request, index) => ({ request, index, id: request.id }))
746
+ for (let round = 0; round < 5 && pending.length > 0; round++) {
747
+ const needIds = pending.filter((item) => item.id === undefined)
748
+ if (needIds.length > 0) {
749
+ if (generate !== undefined) {
750
+ for (const item of needIds) {
751
+ const raw = generate(item.request)
752
+ item.id = JobId(Effect.isEffect(raw) ? yield* raw : raw)
753
+ }
754
+ } else {
755
+ const rows = rowsOf(yield* db.execute<{ id: string }>(sql`
756
+ SELECT 'j-' || ${seqExpr}::text AS id FROM generate_series(1, ${needIds.length})
757
+ `).pipe(Effect.mapError(storeError("enqueueMany failed"))))
758
+ for (let i = 0; i < needIds.length; i++) {
759
+ const row = rows[i]
760
+ const item = needIds[i]
761
+ if (row !== undefined && item !== undefined) {
762
+ item.id = JobId(row.id)
763
+ }
764
+ }
765
+ }
766
+ }
767
+ // Intra-batch repeats: only an id's first occurrence inserts. A
768
+ // later explicit repeat is a duplicate; an auto collision re-draws.
769
+ const seen = new Set<string>()
770
+ const toInsert: Array<{ request: JobStore.EnqueueRequest; index: number; id: JobStore.JobId }> = []
771
+ const stillPending: Array<PendingItem> = []
772
+ for (const item of pending) {
773
+ const id = item.id
774
+ if (id === undefined) continue
775
+ if (seen.has(id)) {
776
+ if (item.request.id !== undefined) {
777
+ results[item.index] = { id, duplicate: true }
778
+ } else {
779
+ item.id = undefined
780
+ stillPending.push(item)
781
+ }
782
+ continue
783
+ }
784
+ seen.add(id)
785
+ toInsert.push({ request: item.request, index: item.index, id })
786
+ }
787
+ pending = stillPending
788
+ // One INSERT per chunk keeps the bind-parameter count bounded.
789
+ for (let start = 0; start < toInsert.length; start += 500) {
790
+ const chunk = toInsert.slice(start, start + 500)
791
+ const values = chunk.map(({ id, request }) =>
792
+ sql`(${id}, ${request.name}, ${request.queue}, ${
793
+ request.delayMs > 0 ? "delayed" : "waiting"
794
+ }, ${request.priority},
795
+ ${JSON.stringify(request.payload ?? null)}::jsonb, ${JSON.stringify(request.metadata)}::jsonb,
796
+ ${request.attemptsMax},
797
+ ${request.backoff === undefined ? null : JSON.stringify(request.backoff)}::jsonb,
798
+ ${request.keep === undefined ? null : JSON.stringify(request.keep)}::jsonb,
799
+ ${request.timeoutMs ?? null}, ${request.dedupe?.key ?? null},
800
+ ${request.trace === undefined ? null : JSON.stringify(request.trace)}::jsonb,
801
+ ${new Date(now.getTime() + Math.max(0, request.delayMs))}, ${now}${extraColumnValues(request)})`
802
+ )
803
+ const rows = rowsOf(yield* db.execute<{ id: string }>(sql`
804
+ INSERT INTO ${jobs} (id, name, queue, state, priority, payload, metadata,
805
+ attempts_max, backoff, keep, timeout_ms, dedupe_key, trace, run_at, enqueued_at${extraColumnNames})
806
+ VALUES ${sql.join(values, sql`, `)}
807
+ ON CONFLICT (id) DO NOTHING
808
+ RETURNING ${jobs.id} AS id
809
+ `).pipe(Effect.mapError(storeError("enqueueMany failed"))))
810
+ const inserted = new Set(rows.map((row) => row.id))
811
+ // Wake per committed chunk, immediately: a later chunk's failure
812
+ // (or id exhaustion below) must not strand these durable rows
813
+ // unwoken until the poll interval.
814
+ const freshQueues = new Set<JobStore.QueueName>()
815
+ for (const item of chunk) {
816
+ if (inserted.has(item.id)) {
817
+ results[item.index] = { id: item.id, duplicate: false }
818
+ freshQueues.add(item.request.queue)
819
+ } else if (item.request.id !== undefined) {
820
+ results[item.index] = { id: item.id, duplicate: true }
821
+ } else {
822
+ // Auto/generated id squatted by an existing row: re-draw and
823
+ // re-insert next round. NOTE this lands the item after its
824
+ // batch-mates in seq (FIFO) order — acceptable for a
825
+ // pathological collision, and documented on the contract.
826
+ pending.push({ request: item.request, index: item.index, id: undefined })
827
+ }
828
+ }
829
+ for (const queue of freshQueues) {
830
+ yield* wakeUp(queue)
831
+ }
832
+ }
833
+ }
834
+ const resolved: Array<JobStore.EnqueueResult> = []
835
+ for (const result of results) {
836
+ if (result === undefined) {
837
+ return yield* new JobStore.JobStoreError({
838
+ message: "enqueueMany failed: could not generate unique job ids"
839
+ })
840
+ }
841
+ resolved.push(result)
842
+ }
843
+ return resolved
844
+ })
845
+
653
846
  const store: JobStore.Service = {
654
- enqueue: (request) =>
847
+ enqueue: enqueueOne,
848
+
849
+ enqueueMany: (requests) =>
655
850
  Effect.gen(function*() {
656
- if (request.dedupe !== undefined) {
657
- const result = yield* enqueueDeduped(request, request.dedupe)
658
- if (result.wake) {
659
- yield* wakeUp("wakeQueue" in result && result.wakeQueue !== undefined ? result.wakeQueue : request.queue)
851
+ const results: Array<JobStore.EnqueueResult> = []
852
+ let batch: Array<JobStore.EnqueueRequest> = []
853
+ const flush = () =>
854
+ Effect.gen(function*() {
855
+ if (batch.length === 0) return
856
+ const items = batch
857
+ batch = []
858
+ // No spread: a six-figure batch would blow the engine's
859
+ // argument-count limit after the rows already committed.
860
+ for (const result of yield* insertBatch(items)) {
861
+ results.push(result)
862
+ }
863
+ })
864
+ for (const request of requests) {
865
+ // Dedup items run through the transactional single-enqueue path
866
+ // in order; runs of plain items between them batch into one
867
+ // INSERT with multi-row VALUES.
868
+ if (request.dedupe !== undefined) {
869
+ yield* flush()
870
+ results.push(yield* enqueueOne(request))
871
+ } else {
872
+ batch.push(request)
660
873
  }
661
- return { id: result.id, duplicate: result.duplicate }
662
- }
663
- const now = yield* nowDate
664
- const result = yield* insertJob(db, request, now)
665
- if (!result.duplicate) {
666
- yield* wakeUp(request.queue)
667
874
  }
668
- return result
875
+ yield* flush()
876
+ return results
669
877
  }),
670
878
 
671
879
  claim: (claimOptions) =>
@@ -709,6 +917,7 @@ export const make = (
709
917
  ${jobs.stalledCount} AS "stalledCount", ${jobs.backoff} AS "backoff",
710
918
  ${jobs.keep} AS "keep", ${jobs.timeoutMs} AS "timeoutMs",
711
919
  ${jobs.cancelRequested} AS "cancelRequested", ${jobs.dedupeKey} AS "dedupeKey",
920
+ ${jobs.trace} AS "trace",
712
921
  ${jobs.runAt} AS "runAt", ${jobs.enqueuedAt} AS "enqueuedAt",
713
922
  ${jobs.processedAt} AS "processedAt", ${jobs.finishedAt} AS "finishedAt",
714
923
  ${jobs.exit} AS "exit", ${jobs.failedReason} AS "failedReason"
@@ -1064,59 +1273,24 @@ export const make = (
1064
1273
  yield* wakeUp(JobStore.QueueName(rows[0]?.queue ?? ""))
1065
1274
  }),
1066
1275
 
1067
- cancel: (id) =>
1068
- db.transaction((tx) =>
1069
- Effect.gen(function*() {
1070
- const now = yield* nowDate
1071
- // One guarded statement: waiting/delayed become terminal, active
1072
- // gets the cancel-request flag; anything else is reported by state.
1073
- const rows = rowsOf(yield* tx.execute<
1074
- {
1075
- id: string
1076
- state: string
1077
- processedAt: Date | null
1078
- name: string
1079
- keep: JobStore.KeepPolicy | null
1080
- dedupeKey: string | null
1081
- }
1082
- >(sql`
1083
- UPDATE ${jobs} SET
1084
- state = CASE WHEN ${jobs.state} IN ('waiting', 'delayed') THEN 'cancelled' ELSE ${jobs.state} END,
1085
- finished_at = CASE WHEN ${jobs.state} IN ('waiting', 'delayed') THEN ${now}::timestamptz ELSE ${jobs.finishedAt} END,
1086
- cancel_requested = CASE WHEN ${jobs.state} = 'active' THEN TRUE ELSE ${jobs.cancelRequested} END
1087
- WHERE ${jobs.id} = ${id} AND ${jobs.state} IN ('waiting', 'delayed', 'active')
1088
- RETURNING ${jobs.id} AS id, ${jobs.state} AS state,
1089
- ${jobs.processedAt} AS "processedAt", ${jobs.name} AS "name", ${jobs.keep} AS "keep",
1090
- ${jobs.dedupeKey} AS "dedupeKey"
1091
- `))
1092
- const row = rows[0]
1093
- if (row === undefined) {
1094
- const existing = rowsOf(yield* tx.execute<{ state: JobStore.JobState }>(sql`
1095
- SELECT ${jobs.state} AS state FROM ${jobs} WHERE ${jobs.id} = ${id}
1096
- `))
1097
- const found = existing[0]
1098
- if (found === undefined) {
1099
- return yield* new JobStore.JobNotFoundError({ jobId: id })
1100
- }
1101
- return yield* new JobStore.JobNotCancellableError({ jobId: id, state: found.state })
1102
- }
1103
- if (row.state === "cancelled") {
1104
- yield* insertAttempt(tx, id, "cancelled", row.processedAt, now, undefined)
1105
- yield* releaseDedupe(tx, row.name, row.dedupeKey, id, now)
1106
- yield* applyKeep(tx, { name: row.name, state: "cancelled", keep: row.keep }, now)
1107
- }
1108
- })
1109
- ).pipe(
1110
- Effect.mapError((error) =>
1111
- error instanceof JobStore.JobNotFoundError ||
1112
- error instanceof JobStore.JobNotCancellableError ||
1113
- error instanceof JobStore.JobStoreError
1114
- ? error
1115
- : storeError("cancel failed")(error)
1116
- ),
1117
- Effect.asVoid
1118
- ),
1276
+ cancel: (id) => cancelJob(id),
1119
1277
 
1278
+ cancelByDedupe: (name, key) =>
1279
+ Effect.gen(function*() {
1280
+ const rows = rowsOf(yield* db.execute<{ jobId: string }>(sql`
1281
+ SELECT ${dedupe.jobId} AS "jobId" FROM ${dedupe}
1282
+ WHERE ${dedupe.name} = ${name} AND ${dedupe.key} = ${key}
1283
+ `).pipe(Effect.mapError(storeError("cancelByDedupe failed"))))
1284
+ const jobId = rows[0]?.jobId
1285
+ // "" is the in-transaction placeholder; treat like no entry.
1286
+ if (jobId === undefined || jobId === "") return false
1287
+ return yield* cancelJob(JobId(jobId)).pipe(
1288
+ Effect.as(true),
1289
+ // Idempotent: a vanished or already-terminal keyed job is
1290
+ // "nothing pending", not an error.
1291
+ Effect.catchTag(["JobNotFoundError", "JobNotCancellableError"], () => Effect.succeed(false))
1292
+ )
1293
+ }),
1120
1294
  promote: (id) =>
1121
1295
  Effect.gen(function*() {
1122
1296
  const now = yield* nowDate
@@ -1251,6 +1425,39 @@ export const make = (
1251
1425
  Effect.asVoid
1252
1426
  ),
1253
1427
 
1428
+ tickSchedule: (key, expectedRunAt, nextRunAt, request) =>
1429
+ Effect.gen(function*() {
1430
+ if (request.id === undefined) {
1431
+ return yield* new JobStore.JobStoreError({
1432
+ message: "tickSchedule requires an explicit request.id"
1433
+ })
1434
+ }
1435
+ // One transaction: the nextRunAt CAS claims the slot and the job
1436
+ // INSERT commits with it, so a stale sweeper can never re-fire an
1437
+ // occurrence — even after retention pruned the previous slot's job.
1438
+ const fired = yield* db.transaction((tx) =>
1439
+ Effect.gen(function*() {
1440
+ const claimed = rowsOf(yield* tx.execute<{ key: string }>(sql`
1441
+ UPDATE ${schedules} SET next_run_at = ${new Date(nextRunAt)}
1442
+ WHERE ${schedules.key} = ${key} AND ${schedules.nextRunAt} = ${new Date(expectedRunAt)}
1443
+ RETURNING ${schedules.key} AS key
1444
+ `))
1445
+ if (claimed.length === 0) return false
1446
+ const now = yield* nowDate
1447
+ const result = yield* insertJob(tx, request, now)
1448
+ // A pre-existing slot row (pre-0.4 crash between enqueue and
1449
+ // advance) still advances the schedule but fires nothing new.
1450
+ return !result.duplicate
1451
+ })
1452
+ ).pipe(Effect.mapError((error) =>
1453
+ error instanceof JobStore.JobStoreError ? error : storeError("tickSchedule failed")(error)
1454
+ ))
1455
+ if (fired) {
1456
+ yield* wakeUp(request.queue)
1457
+ }
1458
+ return fired
1459
+ }),
1460
+
1254
1461
  counts: (queue) =>
1255
1462
  db.execute<{ state: JobStore.JobState; count: number }>(sql`
1256
1463
  SELECT ${jobs.state} AS "state", count(*)::int AS "count" FROM ${jobs}
@@ -55,6 +55,7 @@ type JobState = JobStore.JobState
55
55
  type BackoffPolicy = JobStore.BackoffPolicy
56
56
  type KeepPolicy = JobStore.KeepPolicy
57
57
  type AttemptOutcome = JobStore.AttemptRecord["outcome"]
58
+ type TraceContext = JobStore.TraceContext
58
59
 
59
60
  /**
60
61
  * Table-factory options: `extraConfig` receives the table's columns (exactly
@@ -87,6 +88,7 @@ const jobsColumns = <JobName extends string>() => ({
87
88
  timeoutMs: bigint("timeout_ms", { mode: "number" }),
88
89
  cancelRequested: boolean("cancel_requested").notNull().default(false),
89
90
  dedupeKey: text("dedupe_key"),
91
+ trace: jsonb("trace").$type<TraceContext>(),
90
92
  runAt: timestamp("run_at", { withTimezone: true, mode: "date" }).notNull(),
91
93
  enqueuedAt: timestamp("enqueued_at", { withTimezone: true, mode: "date" }).notNull(),
92
94
  processedAt: timestamp("processed_at", { withTimezone: true, mode: "date" }),