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,941 @@
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.js";
20
+ import { asc, eq, getTableColumns, sql } from "drizzle-orm";
21
+ import * as PgDrizzle from "drizzle-orm/effect-postgres";
22
+ import { getTableConfig } from "drizzle-orm/pg-core";
23
+ import { Clock, Deferred, Duration, Effect, Layer, Option, Stream } from "effect";
24
+ const { JobId } = JobStore;
25
+ const storeError = (message) => (cause) => new JobStore.JobStoreError({ message, cause });
26
+ const rowsOf = (result) => "rows" in result ? result.rows : result;
27
+ const toSchedule = (row) => ({
28
+ key: JobStore.ScheduleKey(row.key),
29
+ jobName: row.jobName,
30
+ queue: JobStore.QueueName(row.queue),
31
+ cron: row.cron ?? undefined,
32
+ tz: row.tz ?? undefined,
33
+ everyMs: row.everyMs === null ? undefined : Number(row.everyMs),
34
+ payload: row.payload,
35
+ metadata: row.metadata ?? {},
36
+ priority: row.priority,
37
+ attemptsMax: row.attemptsMax,
38
+ backoff: row.backoff ?? undefined,
39
+ keep: row.keep ?? undefined,
40
+ timeoutMs: row.timeoutMs === null ? undefined : Number(row.timeoutMs),
41
+ nextRunAt: row.nextRunAt.getTime()
42
+ });
43
+ const toRecord = (row) => ({
44
+ id: JobId(row.id),
45
+ name: row.name,
46
+ queue: JobStore.QueueName(row.queue),
47
+ payload: row.payload,
48
+ metadata: row.metadata ?? {},
49
+ state: row.state,
50
+ priority: row.priority,
51
+ attemptsMax: row.attemptsMax,
52
+ attemptsMade: row.attemptsMade,
53
+ stalledCount: row.stalledCount,
54
+ backoff: row.backoff ?? undefined,
55
+ keep: row.keep ?? undefined,
56
+ timeoutMs: row.timeoutMs === null || row.timeoutMs === undefined ? undefined : Number(row.timeoutMs),
57
+ cancelRequested: row.cancelRequested,
58
+ dedupeKey: row.dedupeKey ?? undefined,
59
+ runAt: row.runAt.getTime(),
60
+ enqueuedAt: row.enqueuedAt.getTime(),
61
+ processedAt: row.processedAt?.getTime(),
62
+ finishedAt: row.finishedAt?.getTime(),
63
+ exit: row.exit ?? undefined,
64
+ failedReason: row.failedReason ?? undefined
65
+ });
66
+ /**
67
+ * Build the store implementation. Requires `PgClient` and a `Scope` (for the
68
+ * LISTEN subscription).
69
+ *
70
+ * @since 0.1.0
71
+ */
72
+ export const make = (options) => Effect.gen(function* () {
73
+ const db = yield* PgDrizzle.makeWithDefaults();
74
+ const client = db.$client;
75
+ const jobs = options.jobs;
76
+ const attempts = options.attempts;
77
+ const schedules = options.schedules;
78
+ const queues = options.queues;
79
+ const dedupe = options.dedupe;
80
+ const jobsName = getTableConfig(jobs).name;
81
+ const attemptsName = getTableConfig(attempts).name;
82
+ const wakeChannel = `effect_mq_wake_${jobsName}`;
83
+ // Columns the user added via `mqJobs({ extend })`: everything beyond the
84
+ // factory's own set. They are written at enqueue (and on dedupe replace)
85
+ // from `extraValues` or the metadata entry with the same TS key.
86
+ const BASE_JOB_COLUMNS = new Set([
87
+ "id",
88
+ "name",
89
+ "queue",
90
+ "state",
91
+ "priority",
92
+ "seq",
93
+ "payload",
94
+ "metadata",
95
+ "attemptsMax",
96
+ "attemptsMade",
97
+ "stalledCount",
98
+ "backoff",
99
+ "keep",
100
+ "timeoutMs",
101
+ "cancelRequested",
102
+ "dedupeKey",
103
+ "runAt",
104
+ "enqueuedAt",
105
+ "processedAt",
106
+ "finishedAt",
107
+ "exit",
108
+ "failedReason",
109
+ "lockToken",
110
+ "lockExpiresAt"
111
+ ]);
112
+ const extendedColumns = Object.entries(getTableColumns(jobs))
113
+ .filter(([key]) => !BASE_JOB_COLUMNS.has(key))
114
+ .map(([key, column]) => ({ key, name: column.name }));
115
+ const extraColumnNames = extendedColumns.length === 0
116
+ ? sql ``
117
+ : sql.join(extendedColumns.map((column) => sql `, ${sql.identifier(column.name)}`));
118
+ const extraColumnValues = (request) => {
119
+ if (extendedColumns.length === 0)
120
+ return sql ``;
121
+ const mapped = options.extraValues?.(request) ?? {};
122
+ return sql.join(extendedColumns.map((column) => sql `, ${Object.hasOwn(mapped, column.key) ? mapped[column.key] : request.metadata[column.key] ?? null}`));
123
+ };
124
+ const extraColumnAssignments = (request) => {
125
+ if (extendedColumns.length === 0)
126
+ return sql ``;
127
+ const mapped = options.extraValues?.(request) ?? {};
128
+ return sql.join(extendedColumns.map((column) => sql `, ${sql.identifier(column.name)} = ${Object.hasOwn(mapped, column.key) ? mapped[column.key] : request.metadata[column.key] ?? null}`));
129
+ };
130
+ if (options.validate ?? true) {
131
+ yield* Effect.all([
132
+ db.select({ id: jobs.id }).from(jobs).limit(0),
133
+ db.select({ jobId: attempts.jobId }).from(attempts).limit(0),
134
+ db.select({ key: schedules.key }).from(schedules).limit(0),
135
+ db.select({ queue: queues.queue }).from(queues).limit(0),
136
+ db.select({ key: dedupe.key }).from(dedupe).limit(0)
137
+ ]).pipe(Effect.mapError(storeError(`effect-mq: tables "${jobsName}"/"${attemptsName}" are missing or mismatched — ` +
138
+ `re-export the effect-mq/drizzle schema factories from your drizzle schema and run your migrations (drizzle-kit generate)`)));
139
+ }
140
+ // Wake plumbing: a queue-filtered waiter registry (same protocol as the
141
+ // memory driver), fed by (a) local store operations and (b) cross-process
142
+ // LISTEN notifications whose payload names the queue ("*" broadcasts).
143
+ // Filtering matters at scale: without it every enqueue wakes every idle
144
+ // taker of every queue on the store.
145
+ let wakeVersion = 0;
146
+ let lastBroadcast = 0;
147
+ const lastWake = new Map();
148
+ const waiters = new Set();
149
+ const lastWakeFor = (queue) => Math.max(lastWake.get(queue) ?? 0, lastBroadcast);
150
+ const signalWake = (queue) => {
151
+ wakeVersion += 1;
152
+ if (queue === undefined) {
153
+ lastBroadcast = wakeVersion;
154
+ }
155
+ else {
156
+ lastWake.set(queue, wakeVersion);
157
+ }
158
+ // Snapshot-and-clear BEFORE resolving: doneUnsafe resumes waiting
159
+ // fibers synchronously, and a woken taker that re-parks registers a
160
+ // NEW waiter — resolving inside the live Set iteration would visit it
161
+ // and livelock.
162
+ const toWake = [];
163
+ for (const waiter of waiters) {
164
+ if (queue === undefined || waiter.queues.has(queue)) {
165
+ waiters.delete(waiter);
166
+ toWake.push(waiter);
167
+ }
168
+ }
169
+ for (const waiter of toWake) {
170
+ Deferred.doneUnsafe(waiter.deferred, Effect.void);
171
+ }
172
+ };
173
+ // Resubscribe forever: if the LISTEN stream ends or fails, wake-ups
174
+ // degrade to the worker's pollInterval until the next attempt succeeds.
175
+ yield* client.listen(wakeChannel).pipe(Stream.runForEach((payload) => Effect.sync(() => signalWake(payload === "*" ? undefined : JobStore.QueueName(payload)))), Effect.catchCause((cause) => Effect.logWarning("effect-mq: LISTEN subscription failed; wake-ups degraded to polling until resubscribe", cause)), Effect.andThen(Effect.sleep("1 second")), Effect.forever, Effect.forkScoped);
176
+ if (options.historyTtl !== undefined) {
177
+ const ttlByState = JobStore.normalizeHistoryTtl(options.historyTtl);
178
+ const sweepMs = Duration.toMillis(options.historySweepInterval ?? "1 minute");
179
+ yield* Effect.gen(function* () {
180
+ yield* Effect.sleep(sweepMs);
181
+ const now = yield* nowDate;
182
+ // Per-state ceilings, refined by stricter per-row keep ages — a quiet
183
+ // job name is pruned on the timer, not only when its group is acked.
184
+ for (const state of ["completed", "failed", "cancelled"]) {
185
+ const ttl = ttlByState[state];
186
+ yield* db.execute(sql `
187
+ DELETE FROM ${jobs}
188
+ WHERE ${jobs.state} = ${state} AND (
189
+ ${ttl !== undefined ? sql `${jobs.finishedAt} <= ${new Date(now.getTime() - ttl)}` : sql `FALSE`}
190
+ OR (
191
+ COALESCE(
192
+ ${jobs.keep}->${state}->>'ageMs',
193
+ CASE WHEN ${jobs.keep} ?| array['completed', 'failed', 'cancelled'] THEN NULL
194
+ ELSE ${jobs.keep}->>'ageMs' END
195
+ ) IS NOT NULL
196
+ AND ${jobs.finishedAt} <= ${now}::timestamptz
197
+ - make_interval(secs => (COALESCE(
198
+ ${jobs.keep}->${state}->>'ageMs',
199
+ CASE WHEN ${jobs.keep} ?| array['completed', 'failed', 'cancelled'] THEN NULL
200
+ ELSE ${jobs.keep}->>'ageMs' END
201
+ )::double precision) / 1000.0))
202
+ )
203
+ `);
204
+ }
205
+ // Dead dedup rows: expired windows, or pointers at vanished jobs.
206
+ yield* db.execute(sql `
207
+ DELETE FROM ${dedupe}
208
+ WHERE (${dedupe.windowExpiresAt} IS NOT NULL AND ${dedupe.windowExpiresAt} <= ${now})
209
+ OR (${dedupe.windowExpiresAt} IS NULL AND NOT EXISTS (
210
+ SELECT 1 FROM ${jobs} WHERE ${jobs.id} = ${dedupe.jobId}
211
+ AND ${jobs.state} IN ('waiting', 'delayed', 'active')
212
+ ))
213
+ `);
214
+ }).pipe(Effect.catchCause((cause) => Effect.logWarning("effect-mq: history sweep failed", cause)), Effect.forever, Effect.forkScoped);
215
+ }
216
+ // Local mutation wake-up: bump synchronously, then best-effort NOTIFY so
217
+ // workers in other processes wake promptly too. The payload names the
218
+ // queue (and must be non-empty: @effect/sql-pg drops falsy payloads).
219
+ const wakeUp = (queue) => Effect.suspend(() => {
220
+ signalWake(queue);
221
+ return client.notify(wakeChannel, queue !== undefined && queue.length > 0 ? queue : "*").pipe(Effect.ignore);
222
+ });
223
+ const nowDate = Effect.map(Clock.currentTimeMillis, (ms) => new Date(ms));
224
+ // quote_ident handles quoted/mixed-case table names safely.
225
+ const seqExpr = sql `nextval(pg_get_serial_sequence(quote_ident(${jobsName}), 'seq'))`;
226
+ const insertAttempt = (tx, jobId, outcome, startedAt, finishedAt, exit) => tx.execute(sql `
227
+ INSERT INTO ${attempts} (job_id, attempt, outcome, started_at, finished_at, exit)
228
+ SELECT ${jobId}, COALESCE(MAX(${attempts.attempt}), 0) + 1, ${outcome}, ${startedAt}, ${finishedAt},
229
+ ${exit === undefined ? null : JSON.stringify(exit)}::jsonb
230
+ FROM ${attempts} WHERE ${attempts.jobId} = ${jobId}
231
+ `);
232
+ // Retention: drop terminal peers (same name + state) beyond count/age.
233
+ const keepPolicyFor = (keep, state) => {
234
+ if (keep === null || keep === undefined)
235
+ return undefined;
236
+ const policy = state === "completed"
237
+ ? keep.completed
238
+ : state === "failed"
239
+ ? keep.failed
240
+ : state === "cancelled"
241
+ ? keep.cancelled
242
+ : undefined;
243
+ if (policy !== undefined)
244
+ return policy;
245
+ // Rows persisted by 0.2.x carry the flat {count, ageMs} shape — honour
246
+ // it as an all-states policy so upgrades keep pruning.
247
+ if (keep.completed === undefined && keep.failed === undefined && keep.cancelled === undefined &&
248
+ ("count" in keep || "ageMs" in keep)) {
249
+ // SAFETY: the flat legacy shape carries KeepStatePolicy fields.
250
+ return keep;
251
+ }
252
+ return undefined;
253
+ };
254
+ const applyKeep = (tx, row, now) => Effect.gen(function* () {
255
+ const keep = keepPolicyFor(row.keep, row.state);
256
+ if (keep === undefined)
257
+ return;
258
+ if (keep.ageMs !== undefined) {
259
+ yield* tx.execute(sql `
260
+ DELETE FROM ${jobs}
261
+ WHERE ${jobs.name} = ${row.name} AND ${jobs.state} = ${row.state}
262
+ AND ${jobs.finishedAt} <= ${new Date(now.getTime() - keep.ageMs)}
263
+ `);
264
+ }
265
+ if (keep.count !== undefined) {
266
+ yield* tx.execute(sql `
267
+ DELETE FROM ${jobs}
268
+ WHERE ${jobs.name} = ${row.name} AND ${jobs.state} = ${row.state}
269
+ AND ${jobs.id} NOT IN (
270
+ SELECT ${jobs.id} FROM ${jobs}
271
+ WHERE ${jobs.name} = ${row.name} AND ${jobs.state} = ${row.state}
272
+ ORDER BY ${jobs.finishedAt} DESC, ${jobs.seq} DESC
273
+ LIMIT ${keep.count}
274
+ )
275
+ `);
276
+ }
277
+ });
278
+ // Distinguish JobNotFound vs LockLost after a guarded UPDATE hit 0 rows.
279
+ const explainMiss = (id) => db.select({ id: jobs.id }).from(jobs).where(eq(jobs.id, id)).pipe(Effect.mapError(storeError("failed to inspect job")), Effect.flatMap((rows) => Effect.fail(rows.length === 0
280
+ ? new JobStore.JobNotFoundError({ jobId: id })
281
+ : new JobStore.LockLostError({ jobId: id }))));
282
+ // The shared INSERT: store-assigned ids come from the configured
283
+ // generator (or the seq sequence); loop on the (unlikely) collision with
284
+ // an existing id — ON CONFLICT DO NOTHING makes the retry safe. Returns
285
+ // the result or undefined when the caller-supplied id already exists.
286
+ const insertJob = (exec, request, now) => Effect.gen(function* () {
287
+ const runAt = new Date(now.getTime() + Math.max(0, request.delayMs));
288
+ const state = request.delayMs > 0 ? "delayed" : "waiting";
289
+ const generate = options.idGenerator;
290
+ for (let i = 0; i < 5; i++) {
291
+ const generated = request.id === undefined && generate !== undefined
292
+ ? yield* Effect.suspend(() => {
293
+ const raw = generate(request);
294
+ return Effect.isEffect(raw) ? raw : Effect.succeed(raw);
295
+ })
296
+ : undefined;
297
+ const idExpr = request.id !== undefined
298
+ ? sql `${request.id}`
299
+ : generated !== undefined
300
+ ? sql `${generated}`
301
+ : sql `'j-' || ${seqExpr}::text`;
302
+ const rows = rowsOf(yield* exec.execute(sql `
303
+ INSERT INTO ${jobs} (id, name, queue, state, priority, payload, metadata,
304
+ attempts_max, backoff, keep, timeout_ms, dedupe_key, run_at, enqueued_at${extraColumnNames})
305
+ VALUES (${idExpr}, ${request.name}, ${request.queue}, ${state}, ${request.priority},
306
+ ${JSON.stringify(request.payload ?? null)}::jsonb, ${JSON.stringify(request.metadata)}::jsonb,
307
+ ${request.attemptsMax},
308
+ ${request.backoff === undefined ? null : JSON.stringify(request.backoff)}::jsonb,
309
+ ${request.keep === undefined ? null : JSON.stringify(request.keep)}::jsonb,
310
+ ${request.timeoutMs ?? null}, ${request.dedupe?.key ?? null}, ${runAt}, ${now}${extraColumnValues(request)})
311
+ ON CONFLICT (id) DO NOTHING
312
+ RETURNING ${jobs.id} AS id
313
+ `).pipe(Effect.mapError(storeError("enqueue failed"))));
314
+ const inserted = rows[0];
315
+ if (inserted !== undefined) {
316
+ return { id: JobId(inserted.id), duplicate: false };
317
+ }
318
+ if (request.id !== undefined) {
319
+ return { id: request.id, duplicate: true };
320
+ }
321
+ // generated id collided with an existing user id; try again
322
+ }
323
+ return yield* new JobStore.JobStoreError({
324
+ message: "enqueue failed: could not generate a unique job id"
325
+ });
326
+ });
327
+ // Enqueue with a dedup policy: one transaction locks the (name, key) row
328
+ // and applies the decision tree (replace-while-delayed, throttle window,
329
+ // pending dedup) before falling through to a fresh insert.
330
+ const enqueueDeduped = (request, policy) => db.transaction((tx) => Effect.gen(function* () {
331
+ const now = yield* nowDate;
332
+ // The explicit-id duplicate check precedes the dedup tree, matching
333
+ // the memory and redis drivers.
334
+ if (request.id !== undefined) {
335
+ const existing = rowsOf(yield* tx.execute(sql `
336
+ SELECT ${jobs.id} AS id FROM ${jobs} WHERE ${jobs.id} = ${request.id}
337
+ `));
338
+ if (existing.length > 0) {
339
+ return { id: request.id, duplicate: true, wake: false };
340
+ }
341
+ }
342
+ // A SELECT FOR UPDATE on a missing row locks nothing, so two
343
+ // concurrent first-enqueues would both insert. The no-op upsert
344
+ // always takes the row lock: a fresh placeholder (job_id = '')
345
+ // reads as "no entry" and falls through to the insert below.
346
+ const rows = rowsOf(yield* tx.execute(sql `
347
+ INSERT INTO ${dedupe} (name, key, job_id, window_expires_at)
348
+ VALUES (${request.name}, ${policy.key}, '', NULL)
349
+ ON CONFLICT (name, key) DO UPDATE SET name = EXCLUDED.name
350
+ RETURNING ${dedupe.jobId} AS "jobId", ${dedupe.windowExpiresAt} AS "windowExpiresAt"
351
+ `));
352
+ const entry = rows[0];
353
+ if (entry !== undefined && entry.jobId !== "") {
354
+ // Plain read (no FOR UPDATE): locking the job row here would
355
+ // invert the jobs-then-dedupe lock order every terminal
356
+ // transition uses and deadlock under load. The replace branch
357
+ // compensates with a state-conditional UPDATE.
358
+ const keyed = rowsOf(yield* tx.execute(sql `
359
+ SELECT ${jobs.state} AS state FROM ${jobs} WHERE ${jobs.id} = ${entry.jobId}
360
+ `));
361
+ const keyedState = keyed[0]?.state;
362
+ const windowLive = entry.windowExpiresAt !== null &&
363
+ entry.windowExpiresAt.getTime() > now.getTime();
364
+ const bumpWindow = policy.extend && policy.ttlMs !== undefined
365
+ ? tx.execute(sql `
366
+ UPDATE ${dedupe} SET window_expires_at = ${new Date(now.getTime() + policy.ttlMs)}
367
+ WHERE ${dedupe.name} = ${request.name} AND ${dedupe.key} = ${policy.key}
368
+ `).pipe(Effect.asVoid)
369
+ : Effect.void;
370
+ // Latest-wins while the keyed job is still delayed. The UPDATE
371
+ // re-checks the state so a concurrent claim degrades this to a
372
+ // plain dedup instead of rewriting an active job.
373
+ if (policy.replace && keyedState === "delayed") {
374
+ const replaced = rowsOf(yield* tx.execute(sql `
375
+ UPDATE ${jobs} SET
376
+ payload = ${JSON.stringify(request.payload ?? null)}::jsonb,
377
+ metadata = ${JSON.stringify(request.metadata)}::jsonb,
378
+ priority = ${request.priority},
379
+ attempts_max = ${request.attemptsMax},
380
+ backoff = ${request.backoff === undefined ? null : JSON.stringify(request.backoff)}::jsonb,
381
+ keep = ${request.keep === undefined ? null : JSON.stringify(request.keep)}::jsonb,
382
+ timeout_ms = ${request.timeoutMs ?? null},
383
+ run_at = ${new Date(now.getTime() + Math.max(0, request.delayMs))}${extraColumnAssignments(request)}
384
+ WHERE ${jobs.id} = ${entry.jobId} AND ${jobs.state} = 'delayed'
385
+ RETURNING ${jobs.id} AS id, ${jobs.queue} AS "queue"
386
+ `));
387
+ if (replaced.length > 0) {
388
+ // A landed replace re-arms the ttl window (the entry must
389
+ // outlive the chain it is deduplicating).
390
+ if (policy.ttlMs !== undefined) {
391
+ yield* tx.execute(sql `
392
+ UPDATE ${dedupe} SET window_expires_at = ${new Date(now.getTime() + policy.ttlMs)}
393
+ WHERE ${dedupe.name} = ${request.name} AND ${dedupe.key} = ${policy.key}
394
+ `);
395
+ }
396
+ // The replace does not move the job between queues — wake
397
+ // the queue that actually holds the now-rescheduled job.
398
+ return {
399
+ id: JobId(entry.jobId),
400
+ duplicate: true,
401
+ wake: true,
402
+ wakeQueue: JobStore.QueueName(replaced[0]?.queue ?? request.queue)
403
+ };
404
+ }
405
+ return { id: JobId(entry.jobId), duplicate: true, wake: false };
406
+ }
407
+ if (windowLive) {
408
+ yield* bumpWindow;
409
+ return { id: JobId(entry.jobId), duplicate: true, wake: false };
410
+ }
411
+ const pending = keyedState !== undefined && keyedState !== "completed" &&
412
+ keyedState !== "failed" && keyedState !== "cancelled";
413
+ if (entry.windowExpiresAt === null && pending) {
414
+ return { id: JobId(entry.jobId), duplicate: true, wake: false };
415
+ }
416
+ // Dead entry: the new job takes over the key below.
417
+ }
418
+ const result = yield* insertJob(tx, request, now);
419
+ if (!result.duplicate) {
420
+ yield* tx.execute(sql `
421
+ INSERT INTO ${dedupe} (name, key, job_id, window_expires_at)
422
+ VALUES (${request.name}, ${policy.key}, ${result.id},
423
+ ${policy.ttlMs === undefined ? null : new Date(now.getTime() + policy.ttlMs)})
424
+ ON CONFLICT (name, key) DO UPDATE SET
425
+ job_id = EXCLUDED.job_id, window_expires_at = EXCLUDED.window_expires_at
426
+ `);
427
+ }
428
+ return { ...result, wake: !result.duplicate };
429
+ })).pipe(
430
+ // Residual lock-order inversions (replace vs cancel of the same
431
+ // delayed job) surface as Postgres deadlocks (40P01); one side is
432
+ // killed and safe to retry.
433
+ Effect.retry({
434
+ times: 3,
435
+ while: (error) => String(error).includes("40P01") || String(error).includes("deadlock detected")
436
+ }), Effect.mapError((error) => error instanceof JobStore.JobStoreError ? error : storeError("enqueue failed")(error)));
437
+ // A job leaving the pending states frees its pending-mode dedup row; live
438
+ // throttle windows deliberately outlast the job.
439
+ const releaseDedupe = (exec, name, dedupeKey, jobId, now) => dedupeKey === null
440
+ ? Effect.void
441
+ : exec.execute(sql `
442
+ DELETE FROM ${dedupe}
443
+ WHERE ${dedupe.name} = ${name} AND ${dedupe.key} = ${dedupeKey}
444
+ AND ${dedupe.jobId} = ${jobId}
445
+ AND (${dedupe.windowExpiresAt} IS NULL OR ${dedupe.windowExpiresAt} <= ${now})
446
+ `).pipe(Effect.asVoid);
447
+ const store = {
448
+ enqueue: (request) => Effect.gen(function* () {
449
+ if (request.dedupe !== undefined) {
450
+ const result = yield* enqueueDeduped(request, request.dedupe);
451
+ if (result.wake) {
452
+ yield* wakeUp("wakeQueue" in result && result.wakeQueue !== undefined ? result.wakeQueue : request.queue);
453
+ }
454
+ return { id: result.id, duplicate: result.duplicate };
455
+ }
456
+ const now = yield* nowDate;
457
+ const result = yield* insertJob(db, request, now);
458
+ if (!result.duplicate) {
459
+ yield* wakeUp(request.queue);
460
+ }
461
+ return result;
462
+ }),
463
+ claim: (claimOptions) => Effect.suspend(() => {
464
+ // Snapshot BEFORE the transaction: any wake that fires while the
465
+ // claim's statements run must make awaitWake(token) return
466
+ // immediately (spurious wake-ups are allowed; lost ones are not).
467
+ const observedWake = wakeVersion;
468
+ return db.transaction((tx) => Effect.gen(function* () {
469
+ const now = yield* nowDate;
470
+ // Promote due delayed jobs first (separate statement: CTEs share
471
+ // a snapshot, so an UPDATE CTE would be invisible to the claim).
472
+ yield* tx.execute(sql `
473
+ UPDATE ${jobs} SET state = 'waiting'
474
+ WHERE ${jobs.queue} = ${claimOptions.queue} AND ${jobs.state} = 'delayed'
475
+ AND ${jobs.runAt} <= ${now}
476
+ `);
477
+ const pausedRows = rowsOf(yield* tx.execute(sql `
478
+ SELECT ${queues.paused} AS "paused" FROM ${queues}
479
+ WHERE ${queues.queue} = ${claimOptions.queue}
480
+ `));
481
+ const isPaused = pausedRows[0]?.paused === true;
482
+ const claimed = isPaused ? [] : rowsOf(yield* tx.execute(sql `
483
+ WITH candidate AS (
484
+ SELECT ${jobs.id} AS id FROM ${jobs}
485
+ WHERE ${jobs.queue} = ${claimOptions.queue} AND ${jobs.state} = 'waiting'
486
+ AND ${jobs.name} = ANY(${sql.param([...claimOptions.names])})
487
+ ORDER BY ${jobs.priority} DESC, ${jobs.seq} ASC
488
+ FOR UPDATE SKIP LOCKED
489
+ LIMIT 1
490
+ )
491
+ UPDATE ${jobs} SET state = 'active', lock_token = ${claimOptions.token},
492
+ lock_expires_at = ${new Date(now.getTime() + claimOptions.lockDurationMs)},
493
+ processed_at = ${now}
494
+ FROM candidate WHERE ${jobs.id} = candidate.id
495
+ RETURNING ${jobs.id} AS "id", ${jobs.name} AS "name", ${jobs.queue} AS "queue",
496
+ ${jobs.state} AS "state", ${jobs.priority} AS "priority", ${jobs.seq} AS "seq",
497
+ ${jobs.payload} AS "payload", ${jobs.metadata} AS "metadata",
498
+ ${jobs.attemptsMax} AS "attemptsMax", ${jobs.attemptsMade} AS "attemptsMade",
499
+ ${jobs.stalledCount} AS "stalledCount", ${jobs.backoff} AS "backoff",
500
+ ${jobs.keep} AS "keep", ${jobs.timeoutMs} AS "timeoutMs",
501
+ ${jobs.cancelRequested} AS "cancelRequested", ${jobs.dedupeKey} AS "dedupeKey",
502
+ ${jobs.runAt} AS "runAt", ${jobs.enqueuedAt} AS "enqueuedAt",
503
+ ${jobs.processedAt} AS "processedAt", ${jobs.finishedAt} AS "finishedAt",
504
+ ${jobs.exit} AS "exit", ${jobs.failedReason} AS "failedReason"
505
+ `));
506
+ const row = claimed[0];
507
+ if (row !== undefined) {
508
+ const result = { _tag: "Claimed", job: toRecord(row) };
509
+ return result;
510
+ }
511
+ const next = rowsOf(yield* tx.execute(sql `
512
+ SELECT MIN(${jobs.runAt}) AS next FROM ${jobs}
513
+ WHERE ${jobs.queue} = ${claimOptions.queue} AND ${jobs.state} = 'delayed'
514
+ AND ${jobs.name} = ANY(${sql.param([...claimOptions.names])})
515
+ `));
516
+ const empty = {
517
+ _tag: "Empty",
518
+ nextRunAt: next[0]?.next?.getTime(),
519
+ wakeToken: observedWake
520
+ };
521
+ return empty;
522
+ }));
523
+ }).pipe(Effect.mapError((error) => error instanceof JobStore.JobStoreError ? error : storeError("claim failed")(error))),
524
+ ack: (id, token, outcome) => db.transaction((tx) => Effect.gen(function* () {
525
+ const now = yield* nowDate;
526
+ const update = outcome._tag === "Complete"
527
+ ? sql `state = 'completed', cancel_requested = FALSE, exit = ${JSON.stringify(outcome.exit ?? null)}::jsonb, finished_at = ${now}`
528
+ : outcome._tag === "Fail"
529
+ ? sql `state = 'failed', cancel_requested = FALSE, exit = ${JSON.stringify(outcome.exit ?? null)}::jsonb, finished_at = ${now}`
530
+ : outcome._tag === "Cancelled"
531
+ ? sql `state = 'cancelled', cancel_requested = FALSE, finished_at = ${now}`
532
+ // A cancel that raced this natural failure wins over revival
533
+ // (mirrors release/recoverStalled).
534
+ : sql `state = CASE WHEN ${jobs.cancelRequested} THEN 'cancelled'
535
+ ELSE ${outcome.delayMs > 0 ? "delayed" : "waiting"} END,
536
+ finished_at = CASE WHEN ${jobs.cancelRequested} THEN ${now}::timestamptz ELSE NULL END,
537
+ run_at = CASE WHEN ${jobs.cancelRequested} THEN ${jobs.runAt}
538
+ ELSE ${new Date(now.getTime() + Math.max(0, outcome.delayMs))}::timestamptz END,
539
+ seq = CASE WHEN ${jobs.cancelRequested} THEN ${jobs.seq} ELSE ${seqExpr} END,
540
+ cancel_requested = FALSE`;
541
+ const rows = rowsOf(yield* tx.execute(sql `
542
+ UPDATE ${jobs} SET ${update},
543
+ attempts_made = ${jobs.attemptsMade} + 1, lock_token = NULL, lock_expires_at = NULL
544
+ WHERE ${jobs.id} = ${id} AND ${jobs.state} = 'active' AND ${jobs.lockToken} = ${token}
545
+ RETURNING ${jobs.processedAt} AS "processedAt", ${jobs.name} AS "name",
546
+ ${jobs.state} AS "state", ${jobs.keep} AS "keep", ${jobs.dedupeKey} AS "dedupeKey",
547
+ ${jobs.queue} AS "queue"
548
+ `));
549
+ const row = rows[0];
550
+ if (row === undefined) {
551
+ return yield* explainMiss(id);
552
+ }
553
+ const cancelledRetry = outcome._tag === "Retry" && row.state === "cancelled";
554
+ const ledgerOutcome = outcome._tag === "Complete"
555
+ ? "completed"
556
+ : outcome._tag === "Fail"
557
+ ? "failed"
558
+ : outcome._tag === "Cancelled" || cancelledRetry
559
+ ? "cancelled"
560
+ : "retried";
561
+ yield* insertAttempt(tx, id, ledgerOutcome, row.processedAt, now, outcome._tag === "Cancelled" || cancelledRetry ? undefined : outcome.exit);
562
+ if (outcome._tag !== "Retry" || cancelledRetry) {
563
+ yield* releaseDedupe(tx, row.name, row.dedupeKey, id, now);
564
+ yield* applyKeep(tx, row, now);
565
+ }
566
+ return outcome._tag === "Retry" && !cancelledRetry
567
+ ? JobStore.QueueName(row.queue)
568
+ : undefined;
569
+ })).pipe(Effect.mapError((error) => error instanceof JobStore.JobNotFoundError || error instanceof JobStore.LockLostError ||
570
+ error instanceof JobStore.JobStoreError
571
+ ? error
572
+ : storeError("ack failed")(error)), Effect.tap((queue) => queue !== undefined ? wakeUp(queue) : Effect.void), Effect.asVoid),
573
+ release: (id, token) => Effect.gen(function* () {
574
+ const released = yield* db.transaction((tx) => Effect.gen(function* () {
575
+ const now = yield* nowDate;
576
+ // A cancel that arrived while the worker was shutting down is
577
+ // honoured instead of reviving the job.
578
+ const rows = rowsOf(yield* tx.execute(sql `
579
+ UPDATE ${jobs} SET
580
+ state = CASE WHEN ${jobs.cancelRequested} THEN 'cancelled' ELSE 'waiting' END,
581
+ finished_at = CASE WHEN ${jobs.cancelRequested} THEN ${now}::timestamptz ELSE NULL END,
582
+ cancel_requested = FALSE,
583
+ lock_token = NULL, lock_expires_at = NULL
584
+ WHERE ${jobs.id} = ${id} AND ${jobs.state} = 'active' AND ${jobs.lockToken} = ${token}
585
+ RETURNING ${jobs.id} AS id, (${jobs.state} = 'cancelled') AS cancelled,
586
+ ${jobs.processedAt} AS "processedAt", ${jobs.name} AS "name", ${jobs.keep} AS "keep",
587
+ ${jobs.dedupeKey} AS "dedupeKey", ${jobs.queue} AS "queue"
588
+ `));
589
+ const row = rows[0];
590
+ if (row === undefined)
591
+ return undefined;
592
+ if (row.cancelled) {
593
+ yield* insertAttempt(tx, id, "cancelled", row.processedAt, now, undefined);
594
+ yield* releaseDedupe(tx, row.name, row.dedupeKey, id, now);
595
+ yield* applyKeep(tx, { name: row.name, state: "cancelled", keep: row.keep }, now);
596
+ }
597
+ return { cancelled: row.cancelled, queue: JobStore.QueueName(row.queue) };
598
+ })).pipe(Effect.mapError((error) => error instanceof JobStore.JobStoreError ? error : storeError("release failed")(error)));
599
+ if (released === undefined) {
600
+ return yield* explainMiss(id);
601
+ }
602
+ if (!released.cancelled) {
603
+ yield* wakeUp(released.queue);
604
+ }
605
+ }),
606
+ extendLocks: (locks, durationMs) => Effect.gen(function* () {
607
+ if (locks.length === 0) {
608
+ const empty = { lost: [], cancelRequested: [] };
609
+ return empty;
610
+ }
611
+ const now = yield* nowDate;
612
+ const rows = rowsOf(yield* db.execute(sql `
613
+ WITH input AS (
614
+ SELECT ids.job_id, toks.token
615
+ FROM unnest(${sql.param(locks.map((lock) => lock.id))}::text[]) WITH ORDINALITY AS ids(job_id, ord)
616
+ JOIN unnest(${sql.param(locks.map((lock) => lock.token))}::text[]) WITH ORDINALITY AS toks(token, ord) USING (ord)
617
+ ),
618
+ updated AS (
619
+ UPDATE ${jobs} SET lock_expires_at = ${new Date(now.getTime() + durationMs)}
620
+ FROM input
621
+ WHERE ${jobs.id} = input.job_id AND ${jobs.state} = 'active'
622
+ AND ${jobs.lockToken} = input.token AND ${jobs.cancelRequested} = FALSE
623
+ RETURNING ${jobs.id} AS id
624
+ )
625
+ SELECT input.job_id AS id,
626
+ CASE WHEN ${jobs.id} IS NOT NULL AND ${jobs.state} = 'active'
627
+ AND ${jobs.lockToken} = input.token AND ${jobs.cancelRequested} THEN 'cancel'
628
+ ELSE 'lost' END AS status
629
+ FROM input
630
+ LEFT JOIN updated ON updated.id = input.job_id
631
+ LEFT JOIN ${jobs} ON ${jobs.id} = input.job_id
632
+ WHERE updated.id IS NULL
633
+ `).pipe(Effect.mapError(storeError("extendLocks failed"))));
634
+ const result = {
635
+ lost: rows.filter((row) => row.status === "lost").map((row) => JobId(row.id)),
636
+ cancelRequested: rows.filter((row) => row.status === "cancel").map((row) => JobId(row.id))
637
+ };
638
+ return result;
639
+ }),
640
+ recoverStalled: (recoverOptions) => db.transaction((tx) => Effect.gen(function* () {
641
+ const now = yield* nowDate;
642
+ // A stalled job whose worker died before honouring a cancel
643
+ // request is finished as cancelled rather than revived.
644
+ const rows = rowsOf(yield* tx.execute(sql `
645
+ UPDATE ${jobs} SET
646
+ stalled_count = CASE WHEN ${jobs.cancelRequested} THEN ${jobs.stalledCount}
647
+ ELSE ${jobs.stalledCount} + 1 END,
648
+ lock_token = NULL, lock_expires_at = NULL,
649
+ state = CASE
650
+ WHEN ${jobs.cancelRequested} THEN 'cancelled'
651
+ WHEN ${jobs.stalledCount} + 1 > ${recoverOptions.maxStalledCount}::int THEN 'failed'
652
+ ELSE 'waiting' END,
653
+ finished_at = CASE
654
+ WHEN ${jobs.cancelRequested} OR ${jobs.stalledCount} + 1 > ${recoverOptions.maxStalledCount}::int
655
+ THEN ${now}::timestamptz ELSE NULL END,
656
+ failed_reason = CASE
657
+ WHEN ${jobs.cancelRequested} THEN NULL
658
+ WHEN ${jobs.stalledCount} + 1 > ${recoverOptions.maxStalledCount}::int
659
+ THEN 'job stalled more than allowable limit' ELSE NULL END,
660
+ cancel_requested = FALSE
661
+ WHERE ${jobs.state} = 'active' AND ${jobs.lockExpiresAt} <= ${now}::timestamptz
662
+ RETURNING ${jobs.id} AS "id", ${jobs.state} AS "state", ${jobs.processedAt} AS "processedAt",
663
+ ${jobs.name} AS "name", ${jobs.keep} AS "keep", ${jobs.dedupeKey} AS "dedupeKey"
664
+ `));
665
+ const recovered = [];
666
+ for (const row of rows) {
667
+ yield* insertAttempt(tx, row.id, row.state === "cancelled" ? "cancelled" : "stalled", row.processedAt, now, undefined);
668
+ if (row.state === "cancelled" || row.state === "failed") {
669
+ yield* releaseDedupe(tx, row.name, row.dedupeKey, row.id, now);
670
+ }
671
+ if (row.state === "cancelled") {
672
+ yield* applyKeep(tx, { name: row.name, state: "cancelled", keep: row.keep }, now);
673
+ }
674
+ else {
675
+ recovered.push({ id: JobId(row.id), failed: row.state === "failed" });
676
+ }
677
+ }
678
+ return recovered;
679
+ })).pipe(Effect.mapError((error) => error instanceof JobStore.JobStoreError ? error : storeError("recoverStalled failed")(error)), Effect.tap((recovered) => recovered.some((entry) => !entry.failed) ? wakeUp() : Effect.void)),
680
+ awaitWake: (queues, wakeToken) => Effect.suspend(() => {
681
+ if (queues.some((queue) => lastWakeFor(queue) > wakeToken))
682
+ return Effect.void;
683
+ const waiter = { queues: new Set(queues), deferred: Deferred.makeUnsafe() };
684
+ waiters.add(waiter);
685
+ return Deferred.await(waiter.deferred).pipe(Effect.ensuring(Effect.sync(() => waiters.delete(waiter))));
686
+ }),
687
+ getJob: (id) => db.select().from(jobs).where(eq(jobs.id, id)).pipe(Effect.mapError(storeError("getJob failed")), Effect.map((rows) => {
688
+ const row = rows[0];
689
+ return row === undefined ? Option.none() : Option.some(toRecord(row));
690
+ })),
691
+ getAttempts: (id) => db.select().from(attempts).where(eq(attempts.jobId, id)).orderBy(asc(attempts.attempt)).pipe(Effect.mapError(storeError("getAttempts failed")), Effect.map((rows) => rows.map((row) => ({
692
+ attempt: row.attempt,
693
+ startedAt: row.startedAt?.getTime(),
694
+ finishedAt: row.finishedAt.getTime(),
695
+ outcome: row.outcome,
696
+ exit: row.exit ?? undefined
697
+ })))),
698
+ list: (listOptions) => Effect.gen(function* () {
699
+ const limit = Math.max(1, listOptions.limit ?? 50);
700
+ const conditions = [sql `TRUE`];
701
+ if (listOptions.queue !== undefined)
702
+ conditions.push(sql `${jobs.queue} = ${listOptions.queue}`);
703
+ if (listOptions.name !== undefined)
704
+ conditions.push(sql `${jobs.name} = ${listOptions.name}`);
705
+ if (listOptions.states !== undefined) {
706
+ // NB: an empty array matches nothing (ANY('{}') is false), same
707
+ // as the memory driver.
708
+ conditions.push(sql `${jobs.state} = ANY(${sql.param([...listOptions.states])})`);
709
+ }
710
+ if (listOptions.metadata !== undefined && Object.keys(listOptions.metadata).length > 0) {
711
+ conditions.push(sql `${jobs.metadata} @> ${JSON.stringify(listOptions.metadata)}::jsonb`);
712
+ }
713
+ if (listOptions.cursor !== undefined) {
714
+ const split = listOptions.cursor.indexOf(":");
715
+ const cursorAt = new Date(Number(listOptions.cursor.slice(0, split)));
716
+ const cursorId = listOptions.cursor.slice(split + 1);
717
+ conditions.push(sql `(${jobs.enqueuedAt}, ${jobs.id}) < (${cursorAt}, ${cursorId})`);
718
+ }
719
+ const rows = rowsOf(yield* db.execute(sql `
720
+ SELECT ${jobs.id} AS "id", ${jobs.name} AS "name", ${jobs.queue} AS "queue",
721
+ ${jobs.state} AS "state", ${jobs.priority} AS "priority", ${jobs.seq} AS "seq",
722
+ ${jobs.payload} AS "payload", ${jobs.metadata} AS "metadata",
723
+ ${jobs.attemptsMax} AS "attemptsMax", ${jobs.attemptsMade} AS "attemptsMade",
724
+ ${jobs.stalledCount} AS "stalledCount", ${jobs.backoff} AS "backoff",
725
+ ${jobs.keep} AS "keep", ${jobs.timeoutMs} AS "timeoutMs",
726
+ ${jobs.cancelRequested} AS "cancelRequested",
727
+ ${jobs.runAt} AS "runAt", ${jobs.enqueuedAt} AS "enqueuedAt",
728
+ ${jobs.processedAt} AS "processedAt", ${jobs.finishedAt} AS "finishedAt",
729
+ ${jobs.exit} AS "exit", ${jobs.failedReason} AS "failedReason"
730
+ FROM ${jobs}
731
+ WHERE ${sql.join(conditions, sql ` AND `)}
732
+ ORDER BY ${jobs.enqueuedAt} DESC, ${jobs.id} DESC
733
+ LIMIT ${limit + 1}
734
+ `).pipe(Effect.mapError(storeError("list failed"))));
735
+ const items = rows.slice(0, limit).map(toRecord);
736
+ const last = items[items.length - 1];
737
+ return {
738
+ items,
739
+ cursor: rows.length > limit && last !== undefined
740
+ ? `${last.enqueuedAt}:${last.id}`
741
+ : undefined
742
+ };
743
+ }),
744
+ retry: (id) => Effect.gen(function* () {
745
+ const now = yield* nowDate;
746
+ const rows = rowsOf(yield* db.execute(sql `
747
+ UPDATE ${jobs} SET state = 'waiting', attempts_made = 0, stalled_count = 0,
748
+ cancel_requested = FALSE,
749
+ exit = NULL, failed_reason = NULL, finished_at = NULL, processed_at = NULL,
750
+ run_at = ${now}, seq = ${seqExpr}
751
+ WHERE ${jobs.id} = ${id} AND ${jobs.state} = 'failed'
752
+ RETURNING ${jobs.id} AS id, ${jobs.queue} AS "queue"
753
+ `).pipe(Effect.mapError(storeError("retry failed"))));
754
+ if (rows.length === 0) {
755
+ const existing = yield* db.select({ state: jobs.state }).from(jobs)
756
+ .where(eq(jobs.id, id)).pipe(Effect.mapError(storeError("retry failed")));
757
+ const found = existing[0];
758
+ if (found === undefined) {
759
+ return yield* new JobStore.JobNotFoundError({ jobId: id });
760
+ }
761
+ return yield* new JobStore.JobNotRetryableError({ jobId: id, state: found.state });
762
+ }
763
+ yield* wakeUp(JobStore.QueueName(rows[0]?.queue ?? ""));
764
+ }),
765
+ cancel: (id) => db.transaction((tx) => Effect.gen(function* () {
766
+ const now = yield* nowDate;
767
+ // One guarded statement: waiting/delayed become terminal, active
768
+ // gets the cancel-request flag; anything else is reported by state.
769
+ const rows = rowsOf(yield* tx.execute(sql `
770
+ UPDATE ${jobs} SET
771
+ state = CASE WHEN ${jobs.state} IN ('waiting', 'delayed') THEN 'cancelled' ELSE ${jobs.state} END,
772
+ finished_at = CASE WHEN ${jobs.state} IN ('waiting', 'delayed') THEN ${now}::timestamptz ELSE ${jobs.finishedAt} END,
773
+ cancel_requested = CASE WHEN ${jobs.state} = 'active' THEN TRUE ELSE ${jobs.cancelRequested} END
774
+ WHERE ${jobs.id} = ${id} AND ${jobs.state} IN ('waiting', 'delayed', 'active')
775
+ RETURNING ${jobs.id} AS id, ${jobs.state} AS state,
776
+ ${jobs.processedAt} AS "processedAt", ${jobs.name} AS "name", ${jobs.keep} AS "keep",
777
+ ${jobs.dedupeKey} AS "dedupeKey"
778
+ `));
779
+ const row = rows[0];
780
+ if (row === undefined) {
781
+ const existing = rowsOf(yield* tx.execute(sql `
782
+ SELECT ${jobs.state} AS state FROM ${jobs} WHERE ${jobs.id} = ${id}
783
+ `));
784
+ const found = existing[0];
785
+ if (found === undefined) {
786
+ return yield* new JobStore.JobNotFoundError({ jobId: id });
787
+ }
788
+ return yield* new JobStore.JobNotCancellableError({ jobId: id, state: found.state });
789
+ }
790
+ if (row.state === "cancelled") {
791
+ yield* insertAttempt(tx, id, "cancelled", row.processedAt, now, undefined);
792
+ yield* releaseDedupe(tx, row.name, row.dedupeKey, id, now);
793
+ yield* applyKeep(tx, { name: row.name, state: "cancelled", keep: row.keep }, now);
794
+ }
795
+ })).pipe(Effect.mapError((error) => error instanceof JobStore.JobNotFoundError ||
796
+ error instanceof JobStore.JobNotCancellableError ||
797
+ error instanceof JobStore.JobStoreError
798
+ ? error
799
+ : storeError("cancel failed")(error)), Effect.asVoid),
800
+ promote: (id) => Effect.gen(function* () {
801
+ const now = yield* nowDate;
802
+ const rows = rowsOf(yield* db.execute(sql `
803
+ UPDATE ${jobs} SET state = 'waiting', run_at = ${now}
804
+ WHERE ${jobs.id} = ${id} AND ${jobs.state} = 'delayed'
805
+ RETURNING ${jobs.id} AS id, ${jobs.queue} AS "queue"
806
+ `).pipe(Effect.mapError(storeError("promote failed"))));
807
+ if (rows.length === 0) {
808
+ const existing = rowsOf(yield* db.execute(sql `
809
+ SELECT ${jobs.state} AS state FROM ${jobs} WHERE ${jobs.id} = ${id}
810
+ `).pipe(Effect.mapError(storeError("promote failed"))));
811
+ const found = existing[0];
812
+ if (found === undefined) {
813
+ return yield* new JobStore.JobNotFoundError({ jobId: id });
814
+ }
815
+ return yield* new JobStore.JobNotPromotableError({ jobId: id, state: found.state });
816
+ }
817
+ yield* wakeUp(JobStore.QueueName(rows[0]?.queue ?? ""));
818
+ }),
819
+ pause: (queue) => db.execute(sql `
820
+ INSERT INTO ${queues} (queue, paused) VALUES (${queue}, TRUE)
821
+ ON CONFLICT (queue) DO UPDATE SET paused = TRUE
822
+ `).pipe(Effect.mapError(storeError("pause failed")), Effect.asVoid),
823
+ resume: (queue) => db.execute(sql `
824
+ UPDATE ${queues} SET paused = FALSE WHERE ${queues.queue} = ${queue}
825
+ `).pipe(Effect.mapError(storeError("resume failed")), Effect.andThen(wakeUp(queue))),
826
+ pausedQueues: () => db.execute(sql `
827
+ SELECT ${queues.queue} AS queue FROM ${queues} WHERE ${queues.paused} = TRUE
828
+ `).pipe(Effect.mapError(storeError("pausedQueues failed")), Effect.map((result) => rowsOf(result).map((row) => JobStore.QueueName(row.queue)))),
829
+ upsertSchedule: (schedule) => db.execute(sql `
830
+ INSERT INTO ${schedules} (key, job_name, queue, cron, tz, every_ms, payload, metadata,
831
+ priority, attempts_max, backoff, keep, timeout_ms, next_run_at)
832
+ VALUES (${schedule.key}, ${schedule.jobName}, ${schedule.queue},
833
+ ${schedule.cron ?? null}, ${schedule.tz ?? null}, ${schedule.everyMs ?? null},
834
+ ${JSON.stringify(schedule.payload ?? null)}::jsonb, ${JSON.stringify(schedule.metadata)}::jsonb,
835
+ ${schedule.priority}, ${schedule.attemptsMax},
836
+ ${schedule.backoff === undefined ? null : JSON.stringify(schedule.backoff)}::jsonb,
837
+ ${schedule.keep === undefined ? null : JSON.stringify(schedule.keep)}::jsonb,
838
+ ${schedule.timeoutMs ?? null}, ${new Date(schedule.nextRunAt)})
839
+ ON CONFLICT (key) DO UPDATE SET
840
+ job_name = EXCLUDED.job_name, queue = EXCLUDED.queue, cron = EXCLUDED.cron,
841
+ tz = EXCLUDED.tz, every_ms = EXCLUDED.every_ms, payload = EXCLUDED.payload,
842
+ metadata = EXCLUDED.metadata, priority = EXCLUDED.priority,
843
+ attempts_max = EXCLUDED.attempts_max, backoff = EXCLUDED.backoff,
844
+ keep = EXCLUDED.keep, timeout_ms = EXCLUDED.timeout_ms,
845
+ next_run_at = CASE
846
+ WHEN ${schedules.cron} IS NOT DISTINCT FROM EXCLUDED.cron
847
+ AND ${schedules.tz} IS NOT DISTINCT FROM EXCLUDED.tz
848
+ AND ${schedules.everyMs} IS NOT DISTINCT FROM EXCLUDED.every_ms
849
+ THEN ${schedules.nextRunAt}
850
+ ELSE EXCLUDED.next_run_at END
851
+ `).pipe(Effect.mapError(storeError("upsertSchedule failed")), Effect.andThen(wakeUp(schedule.queue))),
852
+ removeSchedule: (key) => db.execute(sql `
853
+ DELETE FROM ${schedules} WHERE ${schedules.key} = ${key}
854
+ RETURNING ${schedules.key} AS key
855
+ `).pipe(Effect.mapError(storeError("removeSchedule failed")), Effect.map((result) => rowsOf(result).length > 0)),
856
+ listSchedules: (listOptions) => Effect.gen(function* () {
857
+ const conditions = [sql `TRUE`];
858
+ if (listOptions?.jobName !== undefined) {
859
+ conditions.push(sql `${schedules.jobName} = ${listOptions.jobName}`);
860
+ }
861
+ if (listOptions?.queue !== undefined) {
862
+ conditions.push(sql `${schedules.queue} = ${listOptions.queue}`);
863
+ }
864
+ const rows = rowsOf(yield* db.execute(sql `
865
+ SELECT ${schedules.key} AS "key", ${schedules.jobName} AS "jobName",
866
+ ${schedules.queue} AS "queue", ${schedules.cron} AS "cron", ${schedules.tz} AS "tz",
867
+ ${schedules.everyMs} AS "everyMs", ${schedules.payload} AS "payload",
868
+ ${schedules.metadata} AS "metadata", ${schedules.priority} AS "priority",
869
+ ${schedules.attemptsMax} AS "attemptsMax", ${schedules.backoff} AS "backoff",
870
+ ${schedules.keep} AS "keep", ${schedules.timeoutMs} AS "timeoutMs",
871
+ ${schedules.nextRunAt} AS "nextRunAt"
872
+ FROM ${schedules}
873
+ WHERE ${sql.join(conditions, sql ` AND `)}
874
+ ORDER BY ${schedules.key}
875
+ `).pipe(Effect.mapError(storeError("listSchedules failed"))));
876
+ return rows.map(toSchedule);
877
+ }),
878
+ dueSchedules: () => Effect.gen(function* () {
879
+ const now = yield* nowDate;
880
+ const rows = rowsOf(yield* db.execute(sql `
881
+ SELECT ${schedules.key} AS "key", ${schedules.jobName} AS "jobName",
882
+ ${schedules.queue} AS "queue", ${schedules.cron} AS "cron", ${schedules.tz} AS "tz",
883
+ ${schedules.everyMs} AS "everyMs", ${schedules.payload} AS "payload",
884
+ ${schedules.metadata} AS "metadata", ${schedules.priority} AS "priority",
885
+ ${schedules.attemptsMax} AS "attemptsMax", ${schedules.backoff} AS "backoff",
886
+ ${schedules.keep} AS "keep", ${schedules.timeoutMs} AS "timeoutMs",
887
+ ${schedules.nextRunAt} AS "nextRunAt"
888
+ FROM ${schedules}
889
+ WHERE ${schedules.nextRunAt} <= ${now}
890
+ ORDER BY ${schedules.nextRunAt} ASC
891
+ `).pipe(Effect.mapError(storeError("dueSchedules failed"))));
892
+ return rows.map(toSchedule);
893
+ }),
894
+ advanceSchedule: (key, expectedRunAt, nextRunAt) => db.execute(sql `
895
+ UPDATE ${schedules} SET next_run_at = ${new Date(nextRunAt)}
896
+ WHERE ${schedules.key} = ${key} AND ${schedules.nextRunAt} = ${new Date(expectedRunAt)}
897
+ `).pipe(Effect.mapError(storeError("advanceSchedule failed")), Effect.asVoid),
898
+ counts: (queue) => db.execute(sql `
899
+ SELECT ${jobs.state} AS "state", count(*)::int AS "count" FROM ${jobs}
900
+ ${queue === undefined ? sql `` : sql `WHERE ${jobs.queue} = ${queue}`}
901
+ GROUP BY ${jobs.state}
902
+ `).pipe(Effect.mapError(storeError("counts failed")), Effect.map((result) => {
903
+ const rows = rowsOf(result);
904
+ const counts = {
905
+ waiting: 0,
906
+ delayed: 0,
907
+ active: 0,
908
+ completed: 0,
909
+ failed: 0,
910
+ cancelled: 0
911
+ };
912
+ for (const row of rows)
913
+ counts[row.state] = row.count;
914
+ return counts;
915
+ })),
916
+ remove: (id) => db.execute(sql `
917
+ DELETE FROM ${jobs}
918
+ WHERE ${jobs.id} = ${id} AND ${jobs.state} <> 'active'
919
+ RETURNING ${jobs.id} AS id
920
+ `).pipe(Effect.mapError(storeError("remove failed")), Effect.map((result) => rowsOf(result).length > 0))
921
+ };
922
+ return store;
923
+ });
924
+ /**
925
+ * A Postgres-backed `JobStore` layer over your drizzle tables. Requires
926
+ * `PgClient` (from `@effect/sql-pg`).
927
+ *
928
+ * ```ts
929
+ * const StoreLive = DrizzleJobStore.layer({ jobs, attempts, store: Durable }).pipe(
930
+ * Layer.provide(PgClient.layer({ url: Redacted.make(DATABASE_URL) }))
931
+ * )
932
+ * ```
933
+ *
934
+ * @since 0.1.0
935
+ */
936
+ export const layer = (options) => Layer.effect(
937
+ // SAFETY: when `options.store` is omitted the public signature fixes
938
+ // `StoreId` to its default `JobStore.JobStore`, so the default key is the
939
+ // right `Context.Key<StoreId>`; when it is present the cast is an identity.
940
+ (options.store ?? JobStore.JobStore), make(options));
941
+ //# sourceMappingURL=DrizzleJobStore.js.map