effect-mq 0.1.0 → 0.3.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 (67) hide show
  1. package/README.md +305 -25
  2. package/dist/Job.d.ts +118 -5
  3. package/dist/Job.d.ts.map +1 -1
  4. package/dist/Job.js +119 -4
  5. package/dist/Job.js.map +1 -1
  6. package/dist/JobStore.d.ts +260 -9
  7. package/dist/JobStore.d.ts.map +1 -1
  8. package/dist/JobStore.js +115 -1
  9. package/dist/JobStore.js.map +1 -1
  10. package/dist/MemoryJobStore.d.ts +38 -6
  11. package/dist/MemoryJobStore.d.ts.map +1 -1
  12. package/dist/MemoryJobStore.js +351 -47
  13. package/dist/MemoryJobStore.js.map +1 -1
  14. package/dist/Worker.d.ts +5 -1
  15. package/dist/Worker.d.ts.map +1 -1
  16. package/dist/Worker.js +117 -10
  17. package/dist/Worker.js.map +1 -1
  18. package/dist/{drizzle → drizzle-postgres}/DrizzleJobStore.d.ts +35 -2
  19. package/dist/drizzle-postgres/DrizzleJobStore.d.ts.map +1 -0
  20. package/dist/drizzle-postgres/DrizzleJobStore.js +941 -0
  21. package/dist/drizzle-postgres/DrizzleJobStore.js.map +1 -0
  22. package/dist/drizzle-postgres/index.d.ts.map +1 -0
  23. package/dist/drizzle-postgres/index.js.map +1 -0
  24. package/dist/drizzle-postgres/schema.d.ts +670 -0
  25. package/dist/drizzle-postgres/schema.d.ts.map +1 -0
  26. package/dist/drizzle-postgres/schema.js +150 -0
  27. package/dist/drizzle-postgres/schema.js.map +1 -0
  28. package/dist/redis/RedisJobStore.d.ts +58 -0
  29. package/dist/redis/RedisJobStore.d.ts.map +1 -0
  30. package/dist/redis/RedisJobStore.js +424 -0
  31. package/dist/redis/RedisJobStore.js.map +1 -0
  32. package/dist/redis/index.d.ts +9 -0
  33. package/dist/redis/index.d.ts.map +1 -0
  34. package/dist/redis/index.js +9 -0
  35. package/dist/redis/index.js.map +1 -0
  36. package/dist/redis/scripts.d.ts +181 -0
  37. package/dist/redis/scripts.d.ts.map +1 -0
  38. package/dist/redis/scripts.js +940 -0
  39. package/dist/redis/scripts.js.map +1 -0
  40. package/dist/testing/conformance.d.ts.map +1 -1
  41. package/dist/testing/conformance.js +502 -8
  42. package/dist/testing/conformance.js.map +1 -1
  43. package/package.json +8 -4
  44. package/src/Job.ts +301 -10
  45. package/src/JobStore.ts +373 -9
  46. package/src/MemoryJobStore.ts +440 -53
  47. package/src/Worker.ts +153 -10
  48. package/src/drizzle-postgres/DrizzleJobStore.ts +1311 -0
  49. package/src/drizzle-postgres/schema.ts +279 -0
  50. package/src/redis/RedisJobStore.ts +652 -0
  51. package/src/redis/index.ts +8 -0
  52. package/src/redis/scripts.ts +1055 -0
  53. package/src/testing/conformance.ts +665 -8
  54. package/dist/drizzle/DrizzleJobStore.d.ts.map +0 -1
  55. package/dist/drizzle/DrizzleJobStore.js +0 -426
  56. package/dist/drizzle/DrizzleJobStore.js.map +0 -1
  57. package/dist/drizzle/index.d.ts.map +0 -1
  58. package/dist/drizzle/index.js.map +0 -1
  59. package/dist/drizzle/schema.d.ts +0 -464
  60. package/dist/drizzle/schema.d.ts.map +0 -1
  61. package/dist/drizzle/schema.js +0 -68
  62. package/dist/drizzle/schema.js.map +0 -1
  63. package/src/drizzle/DrizzleJobStore.ts +0 -599
  64. package/src/drizzle/schema.ts +0 -116
  65. /package/dist/{drizzle → drizzle-postgres}/index.d.ts +0 -0
  66. /package/dist/{drizzle → drizzle-postgres}/index.js +0 -0
  67. /package/src/{drizzle → drizzle-postgres}/index.ts +0 -0
@@ -0,0 +1,1311 @@
1
+ /**
2
+ * A Postgres `JobStore` running through drizzle's Effect driver
3
+ * (`drizzle-orm/effect-postgres`, which is built on `@effect/sql-pg` —
4
+ * Node and Bun compatible).
5
+ *
6
+ * - Claims use `FOR UPDATE SKIP LOCKED`; acks are lock-token guarded.
7
+ * - ALL time comes from the Effect `Clock` as bind parameters (never SQL
8
+ * `now()`), so the conformance suite runs against real Postgres under
9
+ * `TestClock`.
10
+ * - Wake-ups use LISTEN/NOTIFY through the shared `PgClient` (with the
11
+ * worker's `pollInterval` as the fallback), so cross-process workers wake
12
+ * promptly.
13
+ *
14
+ * TODO: a standalone non-drizzle Postgres driver on plain `@effect/sql-pg`
15
+ * (same table layout), and an adapter for promise-based drizzle databases.
16
+ *
17
+ * @since 0.1.0
18
+ */
19
+ import * as JobStore from "../JobStore.ts"
20
+ import type { PgClient } from "@effect/sql-pg"
21
+ import { asc, eq, getTableColumns, sql } from "drizzle-orm"
22
+ import * as PgDrizzle from "drizzle-orm/effect-postgres"
23
+ import { getTableConfig } from "drizzle-orm/pg-core"
24
+ import { Clock, type Context, Deferred, Duration, Effect, Layer, Option, type Scope, Stream } from "effect"
25
+ import type { MqDedupeTable, MqJobAttemptsTable, MqJobsTable, MqQueueControlTable, MqSchedulesTable } from "./schema.ts"
26
+
27
+ const { JobId } = JobStore
28
+
29
+ /**
30
+ * @since 0.1.0
31
+ */
32
+ export interface DrizzleJobStoreOptions<StoreId = JobStore.JobStore> {
33
+ /** The jobs table instance (from `mqJobs`). */
34
+ readonly jobs: MqJobsTable
35
+ /** The run-ledger table instance (from `mqJobAttempts`). */
36
+ readonly attempts: MqJobAttemptsTable
37
+ /** The schedules table instance (from `mqSchedules`). */
38
+ readonly schedules: MqSchedulesTable
39
+ /** The queue pause/resume flags table (from `mqQueueControl`). */
40
+ readonly queues: MqQueueControlTable
41
+ /** The dedup-key registry table (from `mqDedupe`). */
42
+ readonly dedupe: MqDedupeTable
43
+ /**
44
+ * Values for columns added via `mqJobs({ extend })`, evaluated at enqueue
45
+ * (and on dedupe `replace`). Keys are the extended columns' TS names.
46
+ * Default: each extended column fills from `request.metadata[<TS name>]`,
47
+ * NULL when absent.
48
+ */
49
+ readonly extraValues?:
50
+ | ((request: JobStore.EnqueueRequest) => Readonly<Record<string, ExtraColumnValue>>)
51
+ | undefined
52
+ /** Bind to a `JobStore.named(...)` key; default: the default `JobStore`. */
53
+ readonly store?: Context.Key<StoreId, JobStore.Service> | undefined
54
+ /**
55
+ * Store-level retention ceiling: terminal records older than this are
56
+ * removed by a periodic sweep — one duration for all terminal states or a
57
+ * per-state split (`{ completed: "1 day", failed: "30 days" }`). The sweep
58
+ * also honours stricter per-job `keep.age` rules.
59
+ */
60
+ readonly historyTtl?: JobStore.HistoryTtlInput | undefined
61
+ /** History sweep cadence (default 1 minute). */
62
+ readonly historySweepInterval?: Duration.Input | undefined
63
+ /**
64
+ * Generator for store-assigned job ids (e.g. `() => \`job_${ulid()}\``).
65
+ * Default: `j-<seq>` from the jobs sequence. See `JobStore.IdGenerator`.
66
+ */
67
+ readonly idGenerator?: JobStore.IdGenerator | undefined
68
+ /**
69
+ * Probe the tables at startup and fail fast when the schema is missing
70
+ * (default true). Migrations are owned by your drizzle-kit pipeline.
71
+ */
72
+ readonly validate?: boolean | undefined
73
+ }
74
+
75
+ type Db = PgDrizzle.EffectPgDatabase & { readonly $client: PgClient.PgClient }
76
+
77
+ /**
78
+ * A value the driver can bind directly into an extended column.
79
+ *
80
+ * @since 0.3.0
81
+ */
82
+ export type ExtraColumnValue = string | number | boolean | Date | null
83
+
84
+ const storeError = (message: string) => (cause: unknown) =>
85
+ new JobStore.JobStoreError({ message, cause })
86
+
87
+ /**
88
+ * drizzle's Effect driver types raw `execute` results as row arrays, but at
89
+ * runtime the raw path yields the node-postgres `QueryResult` envelope
90
+ * (`{ rows }`) while query-builder paths really do yield arrays. Accept both.
91
+ */
92
+ interface RowEnvelope<T> {
93
+ readonly rows: ReadonlyArray<T>
94
+ }
95
+
96
+ const rowsOf = <T>(result: ReadonlyArray<T> | RowEnvelope<T>): ReadonlyArray<T> =>
97
+ "rows" in result ? result.rows : result
98
+
99
+ type JobRow = {
100
+ readonly id: string
101
+ readonly name: string
102
+ readonly queue: string
103
+ readonly state: JobStore.JobState
104
+ readonly priority: number
105
+ readonly seq: number | string
106
+ readonly payload: unknown
107
+ readonly metadata: Record<string, string>
108
+ readonly attemptsMax: number
109
+ readonly attemptsMade: number
110
+ readonly stalledCount: number
111
+ readonly backoff: JobStore.BackoffPolicy | null
112
+ readonly keep: JobStore.KeepPolicy | null
113
+ readonly timeoutMs: number | string | null
114
+ readonly cancelRequested: boolean
115
+ readonly dedupeKey: string | null
116
+ readonly runAt: Date
117
+ readonly enqueuedAt: Date
118
+ readonly processedAt: Date | null
119
+ readonly finishedAt: Date | null
120
+ readonly exit: unknown
121
+ readonly failedReason: string | null
122
+ }
123
+
124
+ type ScheduleRow = {
125
+ readonly key: string
126
+ readonly jobName: string
127
+ readonly queue: string
128
+ readonly cron: string | null
129
+ readonly tz: string | null
130
+ readonly everyMs: number | string | null
131
+ readonly payload: unknown
132
+ readonly metadata: Record<string, string>
133
+ readonly priority: number
134
+ readonly attemptsMax: number
135
+ readonly backoff: JobStore.BackoffPolicy | null
136
+ readonly keep: JobStore.KeepPolicy | null
137
+ readonly timeoutMs: number | string | null
138
+ readonly nextRunAt: Date
139
+ }
140
+
141
+ const toSchedule = (row: ScheduleRow): JobStore.ScheduleRecord => ({
142
+ key: JobStore.ScheduleKey(row.key),
143
+ jobName: row.jobName,
144
+ queue: JobStore.QueueName(row.queue),
145
+ cron: row.cron ?? undefined,
146
+ tz: row.tz ?? undefined,
147
+ everyMs: row.everyMs === null ? undefined : Number(row.everyMs),
148
+ payload: row.payload,
149
+ metadata: row.metadata ?? {},
150
+ priority: row.priority,
151
+ attemptsMax: row.attemptsMax,
152
+ backoff: row.backoff ?? undefined,
153
+ keep: row.keep ?? undefined,
154
+ timeoutMs: row.timeoutMs === null ? undefined : Number(row.timeoutMs),
155
+ nextRunAt: row.nextRunAt.getTime()
156
+ })
157
+
158
+ const toRecord = (row: JobRow): JobStore.JobRecord => ({
159
+ id: JobId(row.id),
160
+ name: row.name,
161
+ queue: JobStore.QueueName(row.queue),
162
+ payload: row.payload,
163
+ metadata: row.metadata ?? {},
164
+ state: row.state,
165
+ priority: row.priority,
166
+ attemptsMax: row.attemptsMax,
167
+ attemptsMade: row.attemptsMade,
168
+ stalledCount: row.stalledCount,
169
+ backoff: row.backoff ?? undefined,
170
+ keep: row.keep ?? undefined,
171
+ timeoutMs: row.timeoutMs === null || row.timeoutMs === undefined ? undefined : Number(row.timeoutMs),
172
+ cancelRequested: row.cancelRequested,
173
+ dedupeKey: row.dedupeKey ?? undefined,
174
+ runAt: row.runAt.getTime(),
175
+ enqueuedAt: row.enqueuedAt.getTime(),
176
+ processedAt: row.processedAt?.getTime(),
177
+ finishedAt: row.finishedAt?.getTime(),
178
+ exit: row.exit ?? undefined,
179
+ failedReason: row.failedReason ?? undefined
180
+ })
181
+
182
+ /**
183
+ * Build the store implementation. Requires `PgClient` and a `Scope` (for the
184
+ * LISTEN subscription).
185
+ *
186
+ * @since 0.1.0
187
+ */
188
+ export const make = (
189
+ options: DrizzleJobStoreOptions<any>
190
+ ): Effect.Effect<JobStore.Service, JobStore.JobStoreError, PgClient.PgClient | Scope.Scope> =>
191
+ Effect.gen(function*() {
192
+ const db: Db = yield* PgDrizzle.makeWithDefaults()
193
+ const client = db.$client
194
+ const jobs = options.jobs
195
+ const attempts = options.attempts
196
+ const schedules = options.schedules
197
+ const queues = options.queues
198
+ const dedupe = options.dedupe
199
+ const jobsName = getTableConfig(jobs).name
200
+ const attemptsName = getTableConfig(attempts).name
201
+ const wakeChannel = `effect_mq_wake_${jobsName}`
202
+
203
+ // Columns the user added via `mqJobs({ extend })`: everything beyond the
204
+ // factory's own set. They are written at enqueue (and on dedupe replace)
205
+ // from `extraValues` or the metadata entry with the same TS key.
206
+ const BASE_JOB_COLUMNS = new Set([
207
+ "id",
208
+ "name",
209
+ "queue",
210
+ "state",
211
+ "priority",
212
+ "seq",
213
+ "payload",
214
+ "metadata",
215
+ "attemptsMax",
216
+ "attemptsMade",
217
+ "stalledCount",
218
+ "backoff",
219
+ "keep",
220
+ "timeoutMs",
221
+ "cancelRequested",
222
+ "dedupeKey",
223
+ "runAt",
224
+ "enqueuedAt",
225
+ "processedAt",
226
+ "finishedAt",
227
+ "exit",
228
+ "failedReason",
229
+ "lockToken",
230
+ "lockExpiresAt"
231
+ ])
232
+ const extendedColumns = Object.entries(getTableColumns(jobs))
233
+ .filter(([key]) => !BASE_JOB_COLUMNS.has(key))
234
+ .map(([key, column]) => ({ key, name: column.name }))
235
+ const extraColumnNames = extendedColumns.length === 0
236
+ ? sql``
237
+ : sql.join(extendedColumns.map((column) => sql`, ${sql.identifier(column.name)}`))
238
+ const extraColumnValues = (request: JobStore.EnqueueRequest) => {
239
+ if (extendedColumns.length === 0) return sql``
240
+ const mapped = options.extraValues?.(request) ?? {}
241
+ return sql.join(extendedColumns.map((column) =>
242
+ sql`, ${Object.hasOwn(mapped, column.key) ? mapped[column.key] : request.metadata[column.key] ?? null}`
243
+ ))
244
+ }
245
+ const extraColumnAssignments = (request: JobStore.EnqueueRequest) => {
246
+ if (extendedColumns.length === 0) return sql``
247
+ const mapped = options.extraValues?.(request) ?? {}
248
+ return sql.join(extendedColumns.map((column) =>
249
+ sql`, ${sql.identifier(column.name)} = ${
250
+ Object.hasOwn(mapped, column.key) ? mapped[column.key] : request.metadata[column.key] ?? null
251
+ }`
252
+ ))
253
+ }
254
+
255
+ if (options.validate ?? true) {
256
+ yield* Effect.all([
257
+ db.select({ id: jobs.id }).from(jobs).limit(0),
258
+ db.select({ jobId: attempts.jobId }).from(attempts).limit(0),
259
+ db.select({ key: schedules.key }).from(schedules).limit(0),
260
+ db.select({ queue: queues.queue }).from(queues).limit(0),
261
+ db.select({ key: dedupe.key }).from(dedupe).limit(0)
262
+ ]).pipe(
263
+ Effect.mapError(storeError(
264
+ `effect-mq: tables "${jobsName}"/"${attemptsName}" are missing or mismatched — ` +
265
+ `re-export the effect-mq/drizzle schema factories from your drizzle schema and run your migrations (drizzle-kit generate)`
266
+ ))
267
+ )
268
+ }
269
+
270
+ // Wake plumbing: a queue-filtered waiter registry (same protocol as the
271
+ // memory driver), fed by (a) local store operations and (b) cross-process
272
+ // LISTEN notifications whose payload names the queue ("*" broadcasts).
273
+ // Filtering matters at scale: without it every enqueue wakes every idle
274
+ // taker of every queue on the store.
275
+ let wakeVersion = 0
276
+ let lastBroadcast = 0
277
+ const lastWake = new Map<JobStore.QueueName, number>()
278
+ interface Waiter {
279
+ readonly queues: ReadonlySet<JobStore.QueueName>
280
+ readonly deferred: Deferred.Deferred<void>
281
+ }
282
+ const waiters = new Set<Waiter>()
283
+ const lastWakeFor = (queue: JobStore.QueueName) => Math.max(lastWake.get(queue) ?? 0, lastBroadcast)
284
+ const signalWake = (queue?: JobStore.QueueName) => {
285
+ wakeVersion += 1
286
+ if (queue === undefined) {
287
+ lastBroadcast = wakeVersion
288
+ } else {
289
+ lastWake.set(queue, wakeVersion)
290
+ }
291
+ // Snapshot-and-clear BEFORE resolving: doneUnsafe resumes waiting
292
+ // fibers synchronously, and a woken taker that re-parks registers a
293
+ // NEW waiter — resolving inside the live Set iteration would visit it
294
+ // and livelock.
295
+ const toWake: Array<Waiter> = []
296
+ for (const waiter of waiters) {
297
+ if (queue === undefined || waiter.queues.has(queue)) {
298
+ waiters.delete(waiter)
299
+ toWake.push(waiter)
300
+ }
301
+ }
302
+ for (const waiter of toWake) {
303
+ Deferred.doneUnsafe(waiter.deferred, Effect.void)
304
+ }
305
+ }
306
+ // Resubscribe forever: if the LISTEN stream ends or fails, wake-ups
307
+ // degrade to the worker's pollInterval until the next attempt succeeds.
308
+ yield* client.listen(wakeChannel).pipe(
309
+ Stream.runForEach((payload) =>
310
+ Effect.sync(() => signalWake(payload === "*" ? undefined : JobStore.QueueName(payload)))
311
+ ),
312
+ Effect.catchCause((cause) =>
313
+ Effect.logWarning(
314
+ "effect-mq: LISTEN subscription failed; wake-ups degraded to polling until resubscribe",
315
+ cause
316
+ )
317
+ ),
318
+ Effect.andThen(Effect.sleep("1 second")),
319
+ Effect.forever,
320
+ Effect.forkScoped
321
+ )
322
+ if (options.historyTtl !== undefined) {
323
+ const ttlByState = JobStore.normalizeHistoryTtl(options.historyTtl)
324
+ const sweepMs = Duration.toMillis(options.historySweepInterval ?? "1 minute")
325
+ yield* Effect.gen(function*() {
326
+ yield* Effect.sleep(sweepMs)
327
+ const now = yield* nowDate
328
+ // Per-state ceilings, refined by stricter per-row keep ages — a quiet
329
+ // job name is pruned on the timer, not only when its group is acked.
330
+ for (const state of ["completed", "failed", "cancelled"] as const) {
331
+ const ttl = ttlByState[state]
332
+ yield* db.execute(sql`
333
+ DELETE FROM ${jobs}
334
+ WHERE ${jobs.state} = ${state} AND (
335
+ ${ttl !== undefined ? sql`${jobs.finishedAt} <= ${new Date(now.getTime() - ttl)}` : sql`FALSE`}
336
+ OR (
337
+ COALESCE(
338
+ ${jobs.keep}->${state}->>'ageMs',
339
+ CASE WHEN ${jobs.keep} ?| array['completed', 'failed', 'cancelled'] THEN NULL
340
+ ELSE ${jobs.keep}->>'ageMs' END
341
+ ) IS NOT NULL
342
+ AND ${jobs.finishedAt} <= ${now}::timestamptz
343
+ - make_interval(secs => (COALESCE(
344
+ ${jobs.keep}->${state}->>'ageMs',
345
+ CASE WHEN ${jobs.keep} ?| array['completed', 'failed', 'cancelled'] THEN NULL
346
+ ELSE ${jobs.keep}->>'ageMs' END
347
+ )::double precision) / 1000.0))
348
+ )
349
+ `)
350
+ }
351
+ // Dead dedup rows: expired windows, or pointers at vanished jobs.
352
+ yield* db.execute(sql`
353
+ DELETE FROM ${dedupe}
354
+ WHERE (${dedupe.windowExpiresAt} IS NOT NULL AND ${dedupe.windowExpiresAt} <= ${now})
355
+ OR (${dedupe.windowExpiresAt} IS NULL AND NOT EXISTS (
356
+ SELECT 1 FROM ${jobs} WHERE ${jobs.id} = ${dedupe.jobId}
357
+ AND ${jobs.state} IN ('waiting', 'delayed', 'active')
358
+ ))
359
+ `)
360
+ }).pipe(
361
+ Effect.catchCause((cause) => Effect.logWarning("effect-mq: history sweep failed", cause)),
362
+ Effect.forever,
363
+ Effect.forkScoped
364
+ )
365
+ }
366
+
367
+ // Local mutation wake-up: bump synchronously, then best-effort NOTIFY so
368
+ // workers in other processes wake promptly too. The payload names the
369
+ // queue (and must be non-empty: @effect/sql-pg drops falsy payloads).
370
+ const wakeUp = (queue?: JobStore.QueueName): Effect.Effect<void> =>
371
+ Effect.suspend(() => {
372
+ signalWake(queue)
373
+ return client.notify(wakeChannel, queue !== undefined && queue.length > 0 ? queue : "*").pipe(Effect.ignore)
374
+ })
375
+
376
+ const nowDate = Effect.map(Clock.currentTimeMillis, (ms) => new Date(ms))
377
+
378
+ // quote_ident handles quoted/mixed-case table names safely.
379
+ const seqExpr = sql`nextval(pg_get_serial_sequence(quote_ident(${jobsName}), 'seq'))`
380
+
381
+ const insertAttempt = (
382
+ tx: Pick<Db, "execute">,
383
+ jobId: string,
384
+ outcome: JobStore.AttemptRecord["outcome"],
385
+ startedAt: Date | null,
386
+ finishedAt: Date,
387
+ exit: JobStore.AttemptRecord["exit"]
388
+ ) =>
389
+ tx.execute(sql`
390
+ INSERT INTO ${attempts} (job_id, attempt, outcome, started_at, finished_at, exit)
391
+ SELECT ${jobId}, COALESCE(MAX(${attempts.attempt}), 0) + 1, ${outcome}, ${startedAt}, ${finishedAt},
392
+ ${exit === undefined ? null : JSON.stringify(exit)}::jsonb
393
+ FROM ${attempts} WHERE ${attempts.jobId} = ${jobId}
394
+ `)
395
+
396
+ // Retention: drop terminal peers (same name + state) beyond count/age.
397
+ const keepPolicyFor = (
398
+ keep: JobStore.KeepPolicy | null | undefined,
399
+ state: string
400
+ ): JobStore.KeepStatePolicy | undefined => {
401
+ if (keep === null || keep === undefined) return undefined
402
+ const policy = state === "completed"
403
+ ? keep.completed
404
+ : state === "failed"
405
+ ? keep.failed
406
+ : state === "cancelled"
407
+ ? keep.cancelled
408
+ : undefined
409
+ if (policy !== undefined) return policy
410
+ // Rows persisted by 0.2.x carry the flat {count, ageMs} shape — honour
411
+ // it as an all-states policy so upgrades keep pruning.
412
+ if (
413
+ keep.completed === undefined && keep.failed === undefined && keep.cancelled === undefined &&
414
+ ("count" in keep || "ageMs" in keep)
415
+ ) {
416
+ // SAFETY: the flat legacy shape carries KeepStatePolicy fields.
417
+ return keep as JobStore.KeepStatePolicy
418
+ }
419
+ return undefined
420
+ }
421
+
422
+ const applyKeep = (
423
+ tx: Pick<Db, "execute">,
424
+ row: { name: string; state: string; keep: JobStore.KeepPolicy | null },
425
+ now: Date
426
+ ) =>
427
+ Effect.gen(function*() {
428
+ const keep = keepPolicyFor(row.keep, row.state)
429
+ if (keep === undefined) return
430
+ if (keep.ageMs !== undefined) {
431
+ yield* tx.execute(sql`
432
+ DELETE FROM ${jobs}
433
+ WHERE ${jobs.name} = ${row.name} AND ${jobs.state} = ${row.state}
434
+ AND ${jobs.finishedAt} <= ${new Date(now.getTime() - keep.ageMs)}
435
+ `)
436
+ }
437
+ if (keep.count !== undefined) {
438
+ yield* tx.execute(sql`
439
+ DELETE FROM ${jobs}
440
+ WHERE ${jobs.name} = ${row.name} AND ${jobs.state} = ${row.state}
441
+ AND ${jobs.id} NOT IN (
442
+ SELECT ${jobs.id} FROM ${jobs}
443
+ WHERE ${jobs.name} = ${row.name} AND ${jobs.state} = ${row.state}
444
+ ORDER BY ${jobs.finishedAt} DESC, ${jobs.seq} DESC
445
+ LIMIT ${keep.count}
446
+ )
447
+ `)
448
+ }
449
+ })
450
+
451
+ // Distinguish JobNotFound vs LockLost after a guarded UPDATE hit 0 rows.
452
+ const explainMiss = (
453
+ id: JobStore.JobId
454
+ ): Effect.Effect<never, JobStore.JobStoreError | JobStore.JobNotFoundError | JobStore.LockLostError> =>
455
+ db.select({ id: jobs.id }).from(jobs).where(eq(jobs.id, id)).pipe(
456
+ Effect.mapError(storeError("failed to inspect job")),
457
+ Effect.flatMap((rows) =>
458
+ Effect.fail<JobStore.JobNotFoundError | JobStore.LockLostError>(
459
+ rows.length === 0
460
+ ? new JobStore.JobNotFoundError({ jobId: id })
461
+ : new JobStore.LockLostError({ jobId: id })
462
+ )
463
+ )
464
+ )
465
+
466
+ // The shared INSERT: store-assigned ids come from the configured
467
+ // generator (or the seq sequence); loop on the (unlikely) collision with
468
+ // an existing id — ON CONFLICT DO NOTHING makes the retry safe. Returns
469
+ // the result or undefined when the caller-supplied id already exists.
470
+ const insertJob = (
471
+ exec: Pick<Db, "execute">,
472
+ request: JobStore.EnqueueRequest,
473
+ now: Date
474
+ ) =>
475
+ Effect.gen(function*() {
476
+ const runAt = new Date(now.getTime() + Math.max(0, request.delayMs))
477
+ const state = request.delayMs > 0 ? "delayed" : "waiting"
478
+ const generate = options.idGenerator
479
+ for (let i = 0; i < 5; i++) {
480
+ const generated = request.id === undefined && generate !== undefined
481
+ ? yield* Effect.suspend(() => {
482
+ const raw = generate(request)
483
+ return Effect.isEffect(raw) ? raw : Effect.succeed(raw)
484
+ })
485
+ : undefined
486
+ const idExpr = request.id !== undefined
487
+ ? sql`${request.id}`
488
+ : generated !== undefined
489
+ ? sql`${generated}`
490
+ : sql`'j-' || ${seqExpr}::text`
491
+ const rows = rowsOf(yield* exec.execute<{ id: string }>(sql`
492
+ INSERT INTO ${jobs} (id, name, queue, state, priority, payload, metadata,
493
+ attempts_max, backoff, keep, timeout_ms, dedupe_key, run_at, enqueued_at${extraColumnNames})
494
+ VALUES (${idExpr}, ${request.name}, ${request.queue}, ${state}, ${request.priority},
495
+ ${JSON.stringify(request.payload ?? null)}::jsonb, ${JSON.stringify(request.metadata)}::jsonb,
496
+ ${request.attemptsMax},
497
+ ${request.backoff === undefined ? null : JSON.stringify(request.backoff)}::jsonb,
498
+ ${request.keep === undefined ? null : JSON.stringify(request.keep)}::jsonb,
499
+ ${request.timeoutMs ?? null}, ${request.dedupe?.key ?? null}, ${runAt}, ${now}${extraColumnValues(request)})
500
+ ON CONFLICT (id) DO NOTHING
501
+ RETURNING ${jobs.id} AS id
502
+ `).pipe(Effect.mapError(storeError("enqueue failed"))))
503
+ const inserted = rows[0]
504
+ if (inserted !== undefined) {
505
+ return { id: JobId(inserted.id), duplicate: false }
506
+ }
507
+ if (request.id !== undefined) {
508
+ return { id: request.id, duplicate: true }
509
+ }
510
+ // generated id collided with an existing user id; try again
511
+ }
512
+ return yield* new JobStore.JobStoreError({
513
+ message: "enqueue failed: could not generate a unique job id"
514
+ })
515
+ })
516
+
517
+ // Enqueue with a dedup policy: one transaction locks the (name, key) row
518
+ // and applies the decision tree (replace-while-delayed, throttle window,
519
+ // pending dedup) before falling through to a fresh insert.
520
+ const enqueueDeduped = (request: JobStore.EnqueueRequest, policy: JobStore.DedupePolicy) =>
521
+ db.transaction((tx) =>
522
+ Effect.gen(function*() {
523
+ const now = yield* nowDate
524
+ // The explicit-id duplicate check precedes the dedup tree, matching
525
+ // the memory and redis drivers.
526
+ if (request.id !== undefined) {
527
+ const existing = rowsOf(yield* tx.execute<{ id: string }>(sql`
528
+ SELECT ${jobs.id} AS id FROM ${jobs} WHERE ${jobs.id} = ${request.id}
529
+ `))
530
+ if (existing.length > 0) {
531
+ return { id: request.id, duplicate: true, wake: false }
532
+ }
533
+ }
534
+ // A SELECT FOR UPDATE on a missing row locks nothing, so two
535
+ // concurrent first-enqueues would both insert. The no-op upsert
536
+ // always takes the row lock: a fresh placeholder (job_id = '')
537
+ // reads as "no entry" and falls through to the insert below.
538
+ const rows = rowsOf(yield* tx.execute<{ jobId: string; windowExpiresAt: Date | null }>(sql`
539
+ INSERT INTO ${dedupe} (name, key, job_id, window_expires_at)
540
+ VALUES (${request.name}, ${policy.key}, '', NULL)
541
+ ON CONFLICT (name, key) DO UPDATE SET name = EXCLUDED.name
542
+ RETURNING ${dedupe.jobId} AS "jobId", ${dedupe.windowExpiresAt} AS "windowExpiresAt"
543
+ `))
544
+ const entry = rows[0]
545
+ if (entry !== undefined && entry.jobId !== "") {
546
+ // Plain read (no FOR UPDATE): locking the job row here would
547
+ // invert the jobs-then-dedupe lock order every terminal
548
+ // transition uses and deadlock under load. The replace branch
549
+ // compensates with a state-conditional UPDATE.
550
+ const keyed = rowsOf(yield* tx.execute<{ state: JobStore.JobState }>(sql`
551
+ SELECT ${jobs.state} AS state FROM ${jobs} WHERE ${jobs.id} = ${entry.jobId}
552
+ `))
553
+ const keyedState = keyed[0]?.state
554
+ const windowLive = entry.windowExpiresAt !== null &&
555
+ entry.windowExpiresAt.getTime() > now.getTime()
556
+ const bumpWindow = policy.extend && policy.ttlMs !== undefined
557
+ ? tx.execute(sql`
558
+ UPDATE ${dedupe} SET window_expires_at = ${new Date(now.getTime() + policy.ttlMs)}
559
+ WHERE ${dedupe.name} = ${request.name} AND ${dedupe.key} = ${policy.key}
560
+ `).pipe(Effect.asVoid)
561
+ : Effect.void
562
+ // Latest-wins while the keyed job is still delayed. The UPDATE
563
+ // re-checks the state so a concurrent claim degrades this to a
564
+ // plain dedup instead of rewriting an active job.
565
+ if (policy.replace && keyedState === "delayed") {
566
+ const replaced = rowsOf(yield* tx.execute<{ id: string; queue: string }>(sql`
567
+ UPDATE ${jobs} SET
568
+ payload = ${JSON.stringify(request.payload ?? null)}::jsonb,
569
+ metadata = ${JSON.stringify(request.metadata)}::jsonb,
570
+ priority = ${request.priority},
571
+ attempts_max = ${request.attemptsMax},
572
+ backoff = ${request.backoff === undefined ? null : JSON.stringify(request.backoff)}::jsonb,
573
+ keep = ${request.keep === undefined ? null : JSON.stringify(request.keep)}::jsonb,
574
+ timeout_ms = ${request.timeoutMs ?? null},
575
+ run_at = ${new Date(now.getTime() + Math.max(0, request.delayMs))}${extraColumnAssignments(request)}
576
+ WHERE ${jobs.id} = ${entry.jobId} AND ${jobs.state} = 'delayed'
577
+ RETURNING ${jobs.id} AS id, ${jobs.queue} AS "queue"
578
+ `))
579
+ if (replaced.length > 0) {
580
+ // A landed replace re-arms the ttl window (the entry must
581
+ // outlive the chain it is deduplicating).
582
+ if (policy.ttlMs !== undefined) {
583
+ yield* tx.execute(sql`
584
+ UPDATE ${dedupe} SET window_expires_at = ${new Date(now.getTime() + policy.ttlMs)}
585
+ WHERE ${dedupe.name} = ${request.name} AND ${dedupe.key} = ${policy.key}
586
+ `)
587
+ }
588
+ // The replace does not move the job between queues — wake
589
+ // the queue that actually holds the now-rescheduled job.
590
+ return {
591
+ id: JobId(entry.jobId),
592
+ duplicate: true,
593
+ wake: true,
594
+ wakeQueue: JobStore.QueueName(replaced[0]?.queue ?? request.queue)
595
+ }
596
+ }
597
+ return { id: JobId(entry.jobId), duplicate: true, wake: false }
598
+ }
599
+ if (windowLive) {
600
+ yield* bumpWindow
601
+ return { id: JobId(entry.jobId), duplicate: true, wake: false }
602
+ }
603
+ const pending = keyedState !== undefined && keyedState !== "completed" &&
604
+ keyedState !== "failed" && keyedState !== "cancelled"
605
+ if (entry.windowExpiresAt === null && pending) {
606
+ return { id: JobId(entry.jobId), duplicate: true, wake: false }
607
+ }
608
+ // Dead entry: the new job takes over the key below.
609
+ }
610
+ const result = yield* insertJob(tx, request, now)
611
+ if (!result.duplicate) {
612
+ yield* tx.execute(sql`
613
+ INSERT INTO ${dedupe} (name, key, job_id, window_expires_at)
614
+ VALUES (${request.name}, ${policy.key}, ${result.id},
615
+ ${policy.ttlMs === undefined ? null : new Date(now.getTime() + policy.ttlMs)})
616
+ ON CONFLICT (name, key) DO UPDATE SET
617
+ job_id = EXCLUDED.job_id, window_expires_at = EXCLUDED.window_expires_at
618
+ `)
619
+ }
620
+ return { ...result, wake: !result.duplicate }
621
+ })
622
+ ).pipe(
623
+ // Residual lock-order inversions (replace vs cancel of the same
624
+ // delayed job) surface as Postgres deadlocks (40P01); one side is
625
+ // killed and safe to retry.
626
+ Effect.retry({
627
+ times: 3,
628
+ while: (error) => String(error).includes("40P01") || String(error).includes("deadlock detected")
629
+ }),
630
+ Effect.mapError((error) =>
631
+ error instanceof JobStore.JobStoreError ? error : storeError("enqueue failed")(error)
632
+ )
633
+ )
634
+
635
+ // A job leaving the pending states frees its pending-mode dedup row; live
636
+ // throttle windows deliberately outlast the job.
637
+ const releaseDedupe = (
638
+ exec: Pick<Db, "execute">,
639
+ name: string,
640
+ dedupeKey: string | null,
641
+ jobId: string,
642
+ now: Date
643
+ ) =>
644
+ dedupeKey === null
645
+ ? Effect.void
646
+ : exec.execute(sql`
647
+ DELETE FROM ${dedupe}
648
+ WHERE ${dedupe.name} = ${name} AND ${dedupe.key} = ${dedupeKey}
649
+ AND ${dedupe.jobId} = ${jobId}
650
+ AND (${dedupe.windowExpiresAt} IS NULL OR ${dedupe.windowExpiresAt} <= ${now})
651
+ `).pipe(Effect.asVoid)
652
+
653
+ const store: JobStore.Service = {
654
+ enqueue: (request) =>
655
+ 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)
660
+ }
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
+ }
668
+ return result
669
+ }),
670
+
671
+ claim: (claimOptions) =>
672
+ Effect.suspend(() => {
673
+ // Snapshot BEFORE the transaction: any wake that fires while the
674
+ // claim's statements run must make awaitWake(token) return
675
+ // immediately (spurious wake-ups are allowed; lost ones are not).
676
+ const observedWake = wakeVersion
677
+ return db.transaction((tx) =>
678
+ Effect.gen(function*() {
679
+ const now = yield* nowDate
680
+ // Promote due delayed jobs first (separate statement: CTEs share
681
+ // a snapshot, so an UPDATE CTE would be invisible to the claim).
682
+ yield* tx.execute(sql`
683
+ UPDATE ${jobs} SET state = 'waiting'
684
+ WHERE ${jobs.queue} = ${claimOptions.queue} AND ${jobs.state} = 'delayed'
685
+ AND ${jobs.runAt} <= ${now}
686
+ `)
687
+ const pausedRows = rowsOf(yield* tx.execute<{ paused: boolean }>(sql`
688
+ SELECT ${queues.paused} AS "paused" FROM ${queues}
689
+ WHERE ${queues.queue} = ${claimOptions.queue}
690
+ `))
691
+ const isPaused = pausedRows[0]?.paused === true
692
+ const claimed = isPaused ? [] : rowsOf(yield* tx.execute<JobRow>(sql`
693
+ WITH candidate AS (
694
+ SELECT ${jobs.id} AS id FROM ${jobs}
695
+ WHERE ${jobs.queue} = ${claimOptions.queue} AND ${jobs.state} = 'waiting'
696
+ AND ${jobs.name} = ANY(${sql.param([...claimOptions.names])})
697
+ ORDER BY ${jobs.priority} DESC, ${jobs.seq} ASC
698
+ FOR UPDATE SKIP LOCKED
699
+ LIMIT 1
700
+ )
701
+ UPDATE ${jobs} SET state = 'active', lock_token = ${claimOptions.token},
702
+ lock_expires_at = ${new Date(now.getTime() + claimOptions.lockDurationMs)},
703
+ processed_at = ${now}
704
+ FROM candidate WHERE ${jobs.id} = candidate.id
705
+ RETURNING ${jobs.id} AS "id", ${jobs.name} AS "name", ${jobs.queue} AS "queue",
706
+ ${jobs.state} AS "state", ${jobs.priority} AS "priority", ${jobs.seq} AS "seq",
707
+ ${jobs.payload} AS "payload", ${jobs.metadata} AS "metadata",
708
+ ${jobs.attemptsMax} AS "attemptsMax", ${jobs.attemptsMade} AS "attemptsMade",
709
+ ${jobs.stalledCount} AS "stalledCount", ${jobs.backoff} AS "backoff",
710
+ ${jobs.keep} AS "keep", ${jobs.timeoutMs} AS "timeoutMs",
711
+ ${jobs.cancelRequested} AS "cancelRequested", ${jobs.dedupeKey} AS "dedupeKey",
712
+ ${jobs.runAt} AS "runAt", ${jobs.enqueuedAt} AS "enqueuedAt",
713
+ ${jobs.processedAt} AS "processedAt", ${jobs.finishedAt} AS "finishedAt",
714
+ ${jobs.exit} AS "exit", ${jobs.failedReason} AS "failedReason"
715
+ `))
716
+ const row = claimed[0]
717
+ if (row !== undefined) {
718
+ const result: JobStore.ClaimResult = { _tag: "Claimed", job: toRecord(row) }
719
+ return result
720
+ }
721
+ const next = rowsOf(yield* tx.execute<{ next: Date | null }>(sql`
722
+ SELECT MIN(${jobs.runAt}) AS next FROM ${jobs}
723
+ WHERE ${jobs.queue} = ${claimOptions.queue} AND ${jobs.state} = 'delayed'
724
+ AND ${jobs.name} = ANY(${sql.param([...claimOptions.names])})
725
+ `))
726
+ const empty: JobStore.ClaimResult = {
727
+ _tag: "Empty",
728
+ nextRunAt: next[0]?.next?.getTime(),
729
+ wakeToken: observedWake
730
+ }
731
+ return empty
732
+ })
733
+ )
734
+ }).pipe(Effect.mapError((error) =>
735
+ error instanceof JobStore.JobStoreError ? error : storeError("claim failed")(error)
736
+ )),
737
+
738
+ ack: (id, token, outcome) =>
739
+ db.transaction((tx) =>
740
+ Effect.gen(function*() {
741
+ const now = yield* nowDate
742
+ const update = outcome._tag === "Complete"
743
+ ? sql`state = 'completed', cancel_requested = FALSE, exit = ${JSON.stringify(outcome.exit ?? null)}::jsonb, finished_at = ${now}`
744
+ : outcome._tag === "Fail"
745
+ ? sql`state = 'failed', cancel_requested = FALSE, exit = ${JSON.stringify(outcome.exit ?? null)}::jsonb, finished_at = ${now}`
746
+ : outcome._tag === "Cancelled"
747
+ ? sql`state = 'cancelled', cancel_requested = FALSE, finished_at = ${now}`
748
+ // A cancel that raced this natural failure wins over revival
749
+ // (mirrors release/recoverStalled).
750
+ : sql`state = CASE WHEN ${jobs.cancelRequested} THEN 'cancelled'
751
+ ELSE ${outcome.delayMs > 0 ? "delayed" : "waiting"} END,
752
+ finished_at = CASE WHEN ${jobs.cancelRequested} THEN ${now}::timestamptz ELSE NULL END,
753
+ run_at = CASE WHEN ${jobs.cancelRequested} THEN ${jobs.runAt}
754
+ ELSE ${new Date(now.getTime() + Math.max(0, outcome.delayMs))}::timestamptz END,
755
+ seq = CASE WHEN ${jobs.cancelRequested} THEN ${jobs.seq} ELSE ${seqExpr} END,
756
+ cancel_requested = FALSE`
757
+ const rows = rowsOf(yield* tx.execute<
758
+ {
759
+ processedAt: Date | null
760
+ name: string
761
+ state: string
762
+ keep: JobStore.KeepPolicy | null
763
+ dedupeKey: string | null
764
+ queue: string
765
+ }
766
+ >(sql`
767
+ UPDATE ${jobs} SET ${update},
768
+ attempts_made = ${jobs.attemptsMade} + 1, lock_token = NULL, lock_expires_at = NULL
769
+ WHERE ${jobs.id} = ${id} AND ${jobs.state} = 'active' AND ${jobs.lockToken} = ${token}
770
+ RETURNING ${jobs.processedAt} AS "processedAt", ${jobs.name} AS "name",
771
+ ${jobs.state} AS "state", ${jobs.keep} AS "keep", ${jobs.dedupeKey} AS "dedupeKey",
772
+ ${jobs.queue} AS "queue"
773
+ `))
774
+ const row = rows[0]
775
+ if (row === undefined) {
776
+ return yield* explainMiss(id)
777
+ }
778
+ const cancelledRetry = outcome._tag === "Retry" && row.state === "cancelled"
779
+ const ledgerOutcome = outcome._tag === "Complete"
780
+ ? "completed" as const
781
+ : outcome._tag === "Fail"
782
+ ? "failed" as const
783
+ : outcome._tag === "Cancelled" || cancelledRetry
784
+ ? "cancelled" as const
785
+ : "retried" as const
786
+ yield* insertAttempt(
787
+ tx,
788
+ id,
789
+ ledgerOutcome,
790
+ row.processedAt,
791
+ now,
792
+ outcome._tag === "Cancelled" || cancelledRetry ? undefined : outcome.exit
793
+ )
794
+ if (outcome._tag !== "Retry" || cancelledRetry) {
795
+ yield* releaseDedupe(tx, row.name, row.dedupeKey, id, now)
796
+ yield* applyKeep(tx, row, now)
797
+ }
798
+ return outcome._tag === "Retry" && !cancelledRetry
799
+ ? JobStore.QueueName(row.queue)
800
+ : undefined
801
+ })
802
+ ).pipe(
803
+ Effect.mapError((error) =>
804
+ error instanceof JobStore.JobNotFoundError || error instanceof JobStore.LockLostError ||
805
+ error instanceof JobStore.JobStoreError
806
+ ? error
807
+ : storeError("ack failed")(error)
808
+ ),
809
+ Effect.tap((queue) => queue !== undefined ? wakeUp(queue) : Effect.void),
810
+ Effect.asVoid
811
+ ),
812
+
813
+ release: (id, token) =>
814
+ Effect.gen(function*() {
815
+ const released = yield* db.transaction((tx) =>
816
+ Effect.gen(function*() {
817
+ const now = yield* nowDate
818
+ // A cancel that arrived while the worker was shutting down is
819
+ // honoured instead of reviving the job.
820
+ const rows = rowsOf(yield* tx.execute<
821
+ {
822
+ id: string
823
+ cancelled: boolean
824
+ processedAt: Date | null
825
+ name: string
826
+ keep: JobStore.KeepPolicy | null
827
+ dedupeKey: string | null
828
+ queue: string
829
+ }
830
+ >(sql`
831
+ UPDATE ${jobs} SET
832
+ state = CASE WHEN ${jobs.cancelRequested} THEN 'cancelled' ELSE 'waiting' END,
833
+ finished_at = CASE WHEN ${jobs.cancelRequested} THEN ${now}::timestamptz ELSE NULL END,
834
+ cancel_requested = FALSE,
835
+ lock_token = NULL, lock_expires_at = NULL
836
+ WHERE ${jobs.id} = ${id} AND ${jobs.state} = 'active' AND ${jobs.lockToken} = ${token}
837
+ RETURNING ${jobs.id} AS id, (${jobs.state} = 'cancelled') AS cancelled,
838
+ ${jobs.processedAt} AS "processedAt", ${jobs.name} AS "name", ${jobs.keep} AS "keep",
839
+ ${jobs.dedupeKey} AS "dedupeKey", ${jobs.queue} AS "queue"
840
+ `))
841
+ const row = rows[0]
842
+ if (row === undefined) return undefined
843
+ if (row.cancelled) {
844
+ yield* insertAttempt(tx, id, "cancelled", row.processedAt, now, undefined)
845
+ yield* releaseDedupe(tx, row.name, row.dedupeKey, id, now)
846
+ yield* applyKeep(tx, { name: row.name, state: "cancelled", keep: row.keep }, now)
847
+ }
848
+ return { cancelled: row.cancelled, queue: JobStore.QueueName(row.queue) }
849
+ })
850
+ ).pipe(Effect.mapError((error) =>
851
+ error instanceof JobStore.JobStoreError ? error : storeError("release failed")(error)
852
+ ))
853
+ if (released === undefined) {
854
+ return yield* explainMiss(id)
855
+ }
856
+ if (!released.cancelled) {
857
+ yield* wakeUp(released.queue)
858
+ }
859
+ }),
860
+
861
+ extendLocks: (locks, durationMs) =>
862
+ Effect.gen(function*() {
863
+ if (locks.length === 0) {
864
+ const empty: JobStore.ExtendLocksResult = { lost: [], cancelRequested: [] }
865
+ return empty
866
+ }
867
+ const now = yield* nowDate
868
+ const rows = rowsOf(yield* db.execute<{ id: string; status: "lost" | "cancel" }>(sql`
869
+ WITH input AS (
870
+ SELECT ids.job_id, toks.token
871
+ FROM unnest(${sql.param(locks.map((lock) => lock.id))}::text[]) WITH ORDINALITY AS ids(job_id, ord)
872
+ JOIN unnest(${sql.param(locks.map((lock) => lock.token))}::text[]) WITH ORDINALITY AS toks(token, ord) USING (ord)
873
+ ),
874
+ updated AS (
875
+ UPDATE ${jobs} SET lock_expires_at = ${new Date(now.getTime() + durationMs)}
876
+ FROM input
877
+ WHERE ${jobs.id} = input.job_id AND ${jobs.state} = 'active'
878
+ AND ${jobs.lockToken} = input.token AND ${jobs.cancelRequested} = FALSE
879
+ RETURNING ${jobs.id} AS id
880
+ )
881
+ SELECT input.job_id AS id,
882
+ CASE WHEN ${jobs.id} IS NOT NULL AND ${jobs.state} = 'active'
883
+ AND ${jobs.lockToken} = input.token AND ${jobs.cancelRequested} THEN 'cancel'
884
+ ELSE 'lost' END AS status
885
+ FROM input
886
+ LEFT JOIN updated ON updated.id = input.job_id
887
+ LEFT JOIN ${jobs} ON ${jobs.id} = input.job_id
888
+ WHERE updated.id IS NULL
889
+ `).pipe(Effect.mapError(storeError("extendLocks failed"))))
890
+ const result: JobStore.ExtendLocksResult = {
891
+ lost: rows.filter((row) => row.status === "lost").map((row) => JobId(row.id)),
892
+ cancelRequested: rows.filter((row) => row.status === "cancel").map((row) => JobId(row.id))
893
+ }
894
+ return result
895
+ }),
896
+
897
+ recoverStalled: (recoverOptions) =>
898
+ db.transaction((tx) =>
899
+ Effect.gen(function*() {
900
+ const now = yield* nowDate
901
+ // A stalled job whose worker died before honouring a cancel
902
+ // request is finished as cancelled rather than revived.
903
+ const rows = rowsOf(yield* tx.execute<
904
+ {
905
+ id: string
906
+ state: string
907
+ processedAt: Date | null
908
+ name: string
909
+ keep: JobStore.KeepPolicy | null
910
+ dedupeKey: string | null
911
+ }
912
+ >(sql`
913
+ UPDATE ${jobs} SET
914
+ stalled_count = CASE WHEN ${jobs.cancelRequested} THEN ${jobs.stalledCount}
915
+ ELSE ${jobs.stalledCount} + 1 END,
916
+ lock_token = NULL, lock_expires_at = NULL,
917
+ state = CASE
918
+ WHEN ${jobs.cancelRequested} THEN 'cancelled'
919
+ WHEN ${jobs.stalledCount} + 1 > ${recoverOptions.maxStalledCount}::int THEN 'failed'
920
+ ELSE 'waiting' END,
921
+ finished_at = CASE
922
+ WHEN ${jobs.cancelRequested} OR ${jobs.stalledCount} + 1 > ${recoverOptions.maxStalledCount}::int
923
+ THEN ${now}::timestamptz ELSE NULL END,
924
+ failed_reason = CASE
925
+ WHEN ${jobs.cancelRequested} THEN NULL
926
+ WHEN ${jobs.stalledCount} + 1 > ${recoverOptions.maxStalledCount}::int
927
+ THEN 'job stalled more than allowable limit' ELSE NULL END,
928
+ cancel_requested = FALSE
929
+ WHERE ${jobs.state} = 'active' AND ${jobs.lockExpiresAt} <= ${now}::timestamptz
930
+ RETURNING ${jobs.id} AS "id", ${jobs.state} AS "state", ${jobs.processedAt} AS "processedAt",
931
+ ${jobs.name} AS "name", ${jobs.keep} AS "keep", ${jobs.dedupeKey} AS "dedupeKey"
932
+ `))
933
+ const recovered: Array<{ id: JobStore.JobId; failed: boolean }> = []
934
+ for (const row of rows) {
935
+ yield* insertAttempt(
936
+ tx,
937
+ row.id,
938
+ row.state === "cancelled" ? "cancelled" : "stalled",
939
+ row.processedAt,
940
+ now,
941
+ undefined
942
+ )
943
+ if (row.state === "cancelled" || row.state === "failed") {
944
+ yield* releaseDedupe(tx, row.name, row.dedupeKey, row.id, now)
945
+ }
946
+ if (row.state === "cancelled") {
947
+ yield* applyKeep(tx, { name: row.name, state: "cancelled", keep: row.keep }, now)
948
+ } else {
949
+ recovered.push({ id: JobId(row.id), failed: row.state === "failed" })
950
+ }
951
+ }
952
+ return recovered
953
+ })
954
+ ).pipe(
955
+ Effect.mapError((error) =>
956
+ error instanceof JobStore.JobStoreError ? error : storeError("recoverStalled failed")(error)
957
+ ),
958
+ Effect.tap((recovered) =>
959
+ recovered.some((entry) => !entry.failed) ? wakeUp() : Effect.void
960
+ )
961
+ ),
962
+
963
+ awaitWake: (queues, wakeToken) =>
964
+ Effect.suspend(() => {
965
+ if (queues.some((queue) => lastWakeFor(queue) > wakeToken)) return Effect.void
966
+ const waiter: Waiter = { queues: new Set(queues), deferred: Deferred.makeUnsafe<void>() }
967
+ waiters.add(waiter)
968
+ return Deferred.await(waiter.deferred).pipe(
969
+ Effect.ensuring(Effect.sync(() => waiters.delete(waiter)))
970
+ )
971
+ }),
972
+
973
+ getJob: (id) =>
974
+ db.select().from(jobs).where(eq(jobs.id, id)).pipe(
975
+ Effect.mapError(storeError("getJob failed")),
976
+ Effect.map((rows) => {
977
+ const row = rows[0]
978
+ return row === undefined ? Option.none() : Option.some(toRecord(row))
979
+ })
980
+ ),
981
+
982
+ getAttempts: (id) =>
983
+ db.select().from(attempts).where(eq(attempts.jobId, id)).orderBy(asc(attempts.attempt)).pipe(
984
+ Effect.mapError(storeError("getAttempts failed")),
985
+ Effect.map((rows) =>
986
+ rows.map((row): JobStore.AttemptRecord => ({
987
+ attempt: row.attempt,
988
+ startedAt: row.startedAt?.getTime(),
989
+ finishedAt: row.finishedAt.getTime(),
990
+ outcome: row.outcome,
991
+ exit: row.exit ?? undefined
992
+ }))
993
+ )
994
+ ),
995
+
996
+ list: (listOptions) =>
997
+ Effect.gen(function*() {
998
+ const limit = Math.max(1, listOptions.limit ?? 50)
999
+ const conditions = [sql`TRUE`]
1000
+ if (listOptions.queue !== undefined) conditions.push(sql`${jobs.queue} = ${listOptions.queue}`)
1001
+ if (listOptions.name !== undefined) conditions.push(sql`${jobs.name} = ${listOptions.name}`)
1002
+ if (listOptions.states !== undefined) {
1003
+ // NB: an empty array matches nothing (ANY('{}') is false), same
1004
+ // as the memory driver.
1005
+ conditions.push(sql`${jobs.state} = ANY(${sql.param([...listOptions.states])})`)
1006
+ }
1007
+ if (listOptions.metadata !== undefined && Object.keys(listOptions.metadata).length > 0) {
1008
+ conditions.push(sql`${jobs.metadata} @> ${JSON.stringify(listOptions.metadata)}::jsonb`)
1009
+ }
1010
+ if (listOptions.cursor !== undefined) {
1011
+ const split = listOptions.cursor.indexOf(":")
1012
+ const cursorAt = new Date(Number(listOptions.cursor.slice(0, split)))
1013
+ const cursorId = listOptions.cursor.slice(split + 1)
1014
+ conditions.push(
1015
+ sql`(${jobs.enqueuedAt}, ${jobs.id}) < (${cursorAt}, ${cursorId})`
1016
+ )
1017
+ }
1018
+ const rows = rowsOf(yield* db.execute<JobRow>(sql`
1019
+ SELECT ${jobs.id} AS "id", ${jobs.name} AS "name", ${jobs.queue} AS "queue",
1020
+ ${jobs.state} AS "state", ${jobs.priority} AS "priority", ${jobs.seq} AS "seq",
1021
+ ${jobs.payload} AS "payload", ${jobs.metadata} AS "metadata",
1022
+ ${jobs.attemptsMax} AS "attemptsMax", ${jobs.attemptsMade} AS "attemptsMade",
1023
+ ${jobs.stalledCount} AS "stalledCount", ${jobs.backoff} AS "backoff",
1024
+ ${jobs.keep} AS "keep", ${jobs.timeoutMs} AS "timeoutMs",
1025
+ ${jobs.cancelRequested} AS "cancelRequested",
1026
+ ${jobs.runAt} AS "runAt", ${jobs.enqueuedAt} AS "enqueuedAt",
1027
+ ${jobs.processedAt} AS "processedAt", ${jobs.finishedAt} AS "finishedAt",
1028
+ ${jobs.exit} AS "exit", ${jobs.failedReason} AS "failedReason"
1029
+ FROM ${jobs}
1030
+ WHERE ${sql.join(conditions, sql` AND `)}
1031
+ ORDER BY ${jobs.enqueuedAt} DESC, ${jobs.id} DESC
1032
+ LIMIT ${limit + 1}
1033
+ `).pipe(Effect.mapError(storeError("list failed"))))
1034
+ const items = rows.slice(0, limit).map(toRecord)
1035
+ const last = items[items.length - 1]
1036
+ return {
1037
+ items,
1038
+ cursor: rows.length > limit && last !== undefined
1039
+ ? `${last.enqueuedAt}:${last.id}`
1040
+ : undefined
1041
+ }
1042
+ }),
1043
+
1044
+ retry: (id) =>
1045
+ Effect.gen(function*() {
1046
+ const now = yield* nowDate
1047
+ const rows = rowsOf(yield* db.execute<{ id: string; queue: string }>(sql`
1048
+ UPDATE ${jobs} SET state = 'waiting', attempts_made = 0, stalled_count = 0,
1049
+ cancel_requested = FALSE,
1050
+ exit = NULL, failed_reason = NULL, finished_at = NULL, processed_at = NULL,
1051
+ run_at = ${now}, seq = ${seqExpr}
1052
+ WHERE ${jobs.id} = ${id} AND ${jobs.state} = 'failed'
1053
+ RETURNING ${jobs.id} AS id, ${jobs.queue} AS "queue"
1054
+ `).pipe(Effect.mapError(storeError("retry failed"))))
1055
+ if (rows.length === 0) {
1056
+ const existing = yield* db.select({ state: jobs.state }).from(jobs)
1057
+ .where(eq(jobs.id, id)).pipe(Effect.mapError(storeError("retry failed")))
1058
+ const found = existing[0]
1059
+ if (found === undefined) {
1060
+ return yield* new JobStore.JobNotFoundError({ jobId: id })
1061
+ }
1062
+ return yield* new JobStore.JobNotRetryableError({ jobId: id, state: found.state })
1063
+ }
1064
+ yield* wakeUp(JobStore.QueueName(rows[0]?.queue ?? ""))
1065
+ }),
1066
+
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
+ ),
1119
+
1120
+ promote: (id) =>
1121
+ Effect.gen(function*() {
1122
+ const now = yield* nowDate
1123
+ const rows = rowsOf(yield* db.execute<{ id: string; queue: string }>(sql`
1124
+ UPDATE ${jobs} SET state = 'waiting', run_at = ${now}
1125
+ WHERE ${jobs.id} = ${id} AND ${jobs.state} = 'delayed'
1126
+ RETURNING ${jobs.id} AS id, ${jobs.queue} AS "queue"
1127
+ `).pipe(Effect.mapError(storeError("promote failed"))))
1128
+ if (rows.length === 0) {
1129
+ const existing = rowsOf(yield* db.execute<{ state: JobStore.JobState }>(sql`
1130
+ SELECT ${jobs.state} AS state FROM ${jobs} WHERE ${jobs.id} = ${id}
1131
+ `).pipe(Effect.mapError(storeError("promote failed"))))
1132
+ const found = existing[0]
1133
+ if (found === undefined) {
1134
+ return yield* new JobStore.JobNotFoundError({ jobId: id })
1135
+ }
1136
+ return yield* new JobStore.JobNotPromotableError({ jobId: id, state: found.state })
1137
+ }
1138
+ yield* wakeUp(JobStore.QueueName(rows[0]?.queue ?? ""))
1139
+ }),
1140
+
1141
+ pause: (queue) =>
1142
+ db.execute(sql`
1143
+ INSERT INTO ${queues} (queue, paused) VALUES (${queue}, TRUE)
1144
+ ON CONFLICT (queue) DO UPDATE SET paused = TRUE
1145
+ `).pipe(
1146
+ Effect.mapError(storeError("pause failed")),
1147
+ Effect.asVoid
1148
+ ),
1149
+
1150
+ resume: (queue) =>
1151
+ db.execute(sql`
1152
+ UPDATE ${queues} SET paused = FALSE WHERE ${queues.queue} = ${queue}
1153
+ `).pipe(
1154
+ Effect.mapError(storeError("resume failed")),
1155
+ Effect.andThen(wakeUp(queue))
1156
+ ),
1157
+
1158
+ pausedQueues: () =>
1159
+ db.execute<{ queue: string }>(sql`
1160
+ SELECT ${queues.queue} AS queue FROM ${queues} WHERE ${queues.paused} = TRUE
1161
+ `).pipe(
1162
+ Effect.mapError(storeError("pausedQueues failed")),
1163
+ Effect.map((result) => rowsOf(result).map((row) => JobStore.QueueName(row.queue)))
1164
+ ),
1165
+
1166
+ upsertSchedule: (schedule) =>
1167
+ db.execute(sql`
1168
+ INSERT INTO ${schedules} (key, job_name, queue, cron, tz, every_ms, payload, metadata,
1169
+ priority, attempts_max, backoff, keep, timeout_ms, next_run_at)
1170
+ VALUES (${schedule.key}, ${schedule.jobName}, ${schedule.queue},
1171
+ ${schedule.cron ?? null}, ${schedule.tz ?? null}, ${schedule.everyMs ?? null},
1172
+ ${JSON.stringify(schedule.payload ?? null)}::jsonb, ${JSON.stringify(schedule.metadata)}::jsonb,
1173
+ ${schedule.priority}, ${schedule.attemptsMax},
1174
+ ${schedule.backoff === undefined ? null : JSON.stringify(schedule.backoff)}::jsonb,
1175
+ ${schedule.keep === undefined ? null : JSON.stringify(schedule.keep)}::jsonb,
1176
+ ${schedule.timeoutMs ?? null}, ${new Date(schedule.nextRunAt)})
1177
+ ON CONFLICT (key) DO UPDATE SET
1178
+ job_name = EXCLUDED.job_name, queue = EXCLUDED.queue, cron = EXCLUDED.cron,
1179
+ tz = EXCLUDED.tz, every_ms = EXCLUDED.every_ms, payload = EXCLUDED.payload,
1180
+ metadata = EXCLUDED.metadata, priority = EXCLUDED.priority,
1181
+ attempts_max = EXCLUDED.attempts_max, backoff = EXCLUDED.backoff,
1182
+ keep = EXCLUDED.keep, timeout_ms = EXCLUDED.timeout_ms,
1183
+ next_run_at = CASE
1184
+ WHEN ${schedules.cron} IS NOT DISTINCT FROM EXCLUDED.cron
1185
+ AND ${schedules.tz} IS NOT DISTINCT FROM EXCLUDED.tz
1186
+ AND ${schedules.everyMs} IS NOT DISTINCT FROM EXCLUDED.every_ms
1187
+ THEN ${schedules.nextRunAt}
1188
+ ELSE EXCLUDED.next_run_at END
1189
+ `).pipe(
1190
+ Effect.mapError(storeError("upsertSchedule failed")),
1191
+ Effect.andThen(wakeUp(schedule.queue))
1192
+ ),
1193
+
1194
+ removeSchedule: (key) =>
1195
+ db.execute<{ key: string }>(sql`
1196
+ DELETE FROM ${schedules} WHERE ${schedules.key} = ${key}
1197
+ RETURNING ${schedules.key} AS key
1198
+ `).pipe(
1199
+ Effect.mapError(storeError("removeSchedule failed")),
1200
+ Effect.map((result) => rowsOf(result).length > 0)
1201
+ ),
1202
+
1203
+ listSchedules: (listOptions) =>
1204
+ Effect.gen(function*() {
1205
+ const conditions = [sql`TRUE`]
1206
+ if (listOptions?.jobName !== undefined) {
1207
+ conditions.push(sql`${schedules.jobName} = ${listOptions.jobName}`)
1208
+ }
1209
+ if (listOptions?.queue !== undefined) {
1210
+ conditions.push(sql`${schedules.queue} = ${listOptions.queue}`)
1211
+ }
1212
+ const rows = rowsOf(yield* db.execute<ScheduleRow>(sql`
1213
+ SELECT ${schedules.key} AS "key", ${schedules.jobName} AS "jobName",
1214
+ ${schedules.queue} AS "queue", ${schedules.cron} AS "cron", ${schedules.tz} AS "tz",
1215
+ ${schedules.everyMs} AS "everyMs", ${schedules.payload} AS "payload",
1216
+ ${schedules.metadata} AS "metadata", ${schedules.priority} AS "priority",
1217
+ ${schedules.attemptsMax} AS "attemptsMax", ${schedules.backoff} AS "backoff",
1218
+ ${schedules.keep} AS "keep", ${schedules.timeoutMs} AS "timeoutMs",
1219
+ ${schedules.nextRunAt} AS "nextRunAt"
1220
+ FROM ${schedules}
1221
+ WHERE ${sql.join(conditions, sql` AND `)}
1222
+ ORDER BY ${schedules.key}
1223
+ `).pipe(Effect.mapError(storeError("listSchedules failed"))))
1224
+ return rows.map(toSchedule)
1225
+ }),
1226
+
1227
+ dueSchedules: () =>
1228
+ Effect.gen(function*() {
1229
+ const now = yield* nowDate
1230
+ const rows = rowsOf(yield* db.execute<ScheduleRow>(sql`
1231
+ SELECT ${schedules.key} AS "key", ${schedules.jobName} AS "jobName",
1232
+ ${schedules.queue} AS "queue", ${schedules.cron} AS "cron", ${schedules.tz} AS "tz",
1233
+ ${schedules.everyMs} AS "everyMs", ${schedules.payload} AS "payload",
1234
+ ${schedules.metadata} AS "metadata", ${schedules.priority} AS "priority",
1235
+ ${schedules.attemptsMax} AS "attemptsMax", ${schedules.backoff} AS "backoff",
1236
+ ${schedules.keep} AS "keep", ${schedules.timeoutMs} AS "timeoutMs",
1237
+ ${schedules.nextRunAt} AS "nextRunAt"
1238
+ FROM ${schedules}
1239
+ WHERE ${schedules.nextRunAt} <= ${now}
1240
+ ORDER BY ${schedules.nextRunAt} ASC
1241
+ `).pipe(Effect.mapError(storeError("dueSchedules failed"))))
1242
+ return rows.map(toSchedule)
1243
+ }),
1244
+
1245
+ advanceSchedule: (key, expectedRunAt, nextRunAt) =>
1246
+ db.execute(sql`
1247
+ UPDATE ${schedules} SET next_run_at = ${new Date(nextRunAt)}
1248
+ WHERE ${schedules.key} = ${key} AND ${schedules.nextRunAt} = ${new Date(expectedRunAt)}
1249
+ `).pipe(
1250
+ Effect.mapError(storeError("advanceSchedule failed")),
1251
+ Effect.asVoid
1252
+ ),
1253
+
1254
+ counts: (queue) =>
1255
+ db.execute<{ state: JobStore.JobState; count: number }>(sql`
1256
+ SELECT ${jobs.state} AS "state", count(*)::int AS "count" FROM ${jobs}
1257
+ ${queue === undefined ? sql`` : sql`WHERE ${jobs.queue} = ${queue}`}
1258
+ GROUP BY ${jobs.state}
1259
+ `).pipe(
1260
+ Effect.mapError(storeError("counts failed")),
1261
+ Effect.map((result) => {
1262
+ const rows = rowsOf(result)
1263
+ const counts = {
1264
+ waiting: 0,
1265
+ delayed: 0,
1266
+ active: 0,
1267
+ completed: 0,
1268
+ failed: 0,
1269
+ cancelled: 0
1270
+ } satisfies Record<JobStore.JobState, number>
1271
+ for (const row of rows) counts[row.state] = row.count
1272
+ return counts
1273
+ })
1274
+ ),
1275
+
1276
+ remove: (id) =>
1277
+ db.execute(sql`
1278
+ DELETE FROM ${jobs}
1279
+ WHERE ${jobs.id} = ${id} AND ${jobs.state} <> 'active'
1280
+ RETURNING ${jobs.id} AS id
1281
+ `).pipe(
1282
+ Effect.mapError(storeError("remove failed")),
1283
+ Effect.map((result) => rowsOf(result).length > 0)
1284
+ )
1285
+ }
1286
+
1287
+ return store
1288
+ })
1289
+
1290
+ /**
1291
+ * A Postgres-backed `JobStore` layer over your drizzle tables. Requires
1292
+ * `PgClient` (from `@effect/sql-pg`).
1293
+ *
1294
+ * ```ts
1295
+ * const StoreLive = DrizzleJobStore.layer({ jobs, attempts, store: Durable }).pipe(
1296
+ * Layer.provide(PgClient.layer({ url: Redacted.make(DATABASE_URL) }))
1297
+ * )
1298
+ * ```
1299
+ *
1300
+ * @since 0.1.0
1301
+ */
1302
+ export const layer = <StoreId = JobStore.JobStore>(
1303
+ options: DrizzleJobStoreOptions<StoreId>
1304
+ ): Layer.Layer<StoreId, JobStore.JobStoreError, PgClient.PgClient> =>
1305
+ Layer.effect(
1306
+ // SAFETY: when `options.store` is omitted the public signature fixes
1307
+ // `StoreId` to its default `JobStore.JobStore`, so the default key is the
1308
+ // right `Context.Key<StoreId>`; when it is present the cast is an identity.
1309
+ (options.store ?? JobStore.JobStore) as Context.Key<StoreId, JobStore.Service>,
1310
+ make(options)
1311
+ )