effect-mq 0.1.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 (53) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +235 -0
  3. package/dist/Job.d.ts +222 -0
  4. package/dist/Job.d.ts.map +1 -0
  5. package/dist/Job.js +218 -0
  6. package/dist/Job.js.map +1 -0
  7. package/dist/JobStore.d.ts +401 -0
  8. package/dist/JobStore.d.ts.map +1 -0
  9. package/dist/JobStore.js +89 -0
  10. package/dist/JobStore.js.map +1 -0
  11. package/dist/MemoryJobStore.d.ts +34 -0
  12. package/dist/MemoryJobStore.d.ts.map +1 -0
  13. package/dist/MemoryJobStore.js +381 -0
  14. package/dist/MemoryJobStore.js.map +1 -0
  15. package/dist/Worker.d.ts +127 -0
  16. package/dist/Worker.d.ts.map +1 -0
  17. package/dist/Worker.js +274 -0
  18. package/dist/Worker.js.map +1 -0
  19. package/dist/drizzle/DrizzleJobStore.d.ts +59 -0
  20. package/dist/drizzle/DrizzleJobStore.d.ts.map +1 -0
  21. package/dist/drizzle/DrizzleJobStore.js +426 -0
  22. package/dist/drizzle/DrizzleJobStore.js.map +1 -0
  23. package/dist/drizzle/index.d.ts +19 -0
  24. package/dist/drizzle/index.d.ts.map +1 -0
  25. package/dist/drizzle/index.js +19 -0
  26. package/dist/drizzle/index.js.map +1 -0
  27. package/dist/drizzle/schema.d.ts +464 -0
  28. package/dist/drizzle/schema.d.ts.map +1 -0
  29. package/dist/drizzle/schema.js +68 -0
  30. package/dist/drizzle/schema.js.map +1 -0
  31. package/dist/index.d.ts +30 -0
  32. package/dist/index.d.ts.map +1 -0
  33. package/dist/index.js +30 -0
  34. package/dist/index.js.map +1 -0
  35. package/dist/testing/conformance.d.ts +27 -0
  36. package/dist/testing/conformance.d.ts.map +1 -0
  37. package/dist/testing/conformance.js +451 -0
  38. package/dist/testing/conformance.js.map +1 -0
  39. package/dist/testing/index.d.ts +8 -0
  40. package/dist/testing/index.d.ts.map +1 -0
  41. package/dist/testing/index.js +8 -0
  42. package/dist/testing/index.js.map +1 -0
  43. package/package.json +71 -0
  44. package/src/Job.ts +606 -0
  45. package/src/JobStore.ts +446 -0
  46. package/src/MemoryJobStore.ts +467 -0
  47. package/src/Worker.ts +514 -0
  48. package/src/drizzle/DrizzleJobStore.ts +599 -0
  49. package/src/drizzle/index.ts +20 -0
  50. package/src/drizzle/schema.ts +116 -0
  51. package/src/index.ts +33 -0
  52. package/src/testing/conformance.ts +654 -0
  53. package/src/testing/index.ts +7 -0
@@ -0,0 +1,599 @@
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, 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, Effect, Layer, Option, type Scope, Stream } from "effect"
25
+ import type { MqJobAttemptsTable, MqJobsTable } 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
+ /** Bind to a `JobStore.named(...)` key; default: the default `JobStore`. */
38
+ readonly store?: Context.Key<StoreId, JobStore.Service> | undefined
39
+ /**
40
+ * Probe the tables at startup and fail fast when the schema is missing
41
+ * (default true). Migrations are owned by your drizzle-kit pipeline.
42
+ */
43
+ readonly validate?: boolean | undefined
44
+ }
45
+
46
+ type Db = PgDrizzle.EffectPgDatabase & { readonly $client: PgClient.PgClient }
47
+
48
+ const storeError = (message: string) => (cause: unknown) =>
49
+ new JobStore.JobStoreError({ message, cause })
50
+
51
+ /**
52
+ * drizzle's Effect driver types raw `execute` results as row arrays, but at
53
+ * runtime the raw path yields the node-postgres `QueryResult` envelope
54
+ * (`{ rows }`) while query-builder paths really do yield arrays. Accept both.
55
+ */
56
+ interface RowEnvelope<T> {
57
+ readonly rows: ReadonlyArray<T>
58
+ }
59
+
60
+ const rowsOf = <T>(result: ReadonlyArray<T> | RowEnvelope<T>): ReadonlyArray<T> =>
61
+ "rows" in result ? result.rows : result
62
+
63
+ type JobRow = {
64
+ readonly id: string
65
+ readonly name: string
66
+ readonly queue: string
67
+ readonly state: JobStore.JobState
68
+ readonly priority: number
69
+ readonly seq: number | string
70
+ readonly payload: unknown
71
+ readonly metadata: Record<string, string>
72
+ readonly attemptsMax: number
73
+ readonly attemptsMade: number
74
+ readonly stalledCount: number
75
+ readonly backoff: JobStore.BackoffPolicy | null
76
+ readonly keep: JobStore.KeepPolicy | null
77
+ readonly runAt: Date
78
+ readonly enqueuedAt: Date
79
+ readonly processedAt: Date | null
80
+ readonly finishedAt: Date | null
81
+ readonly exit: unknown
82
+ readonly failedReason: string | null
83
+ }
84
+
85
+ const toRecord = (row: JobRow): JobStore.JobRecord => ({
86
+ id: JobId(row.id),
87
+ name: row.name,
88
+ queue: JobStore.QueueName(row.queue),
89
+ payload: row.payload,
90
+ metadata: row.metadata ?? {},
91
+ state: row.state,
92
+ priority: row.priority,
93
+ attemptsMax: row.attemptsMax,
94
+ attemptsMade: row.attemptsMade,
95
+ stalledCount: row.stalledCount,
96
+ backoff: row.backoff ?? undefined,
97
+ keep: row.keep ?? undefined,
98
+ runAt: row.runAt.getTime(),
99
+ enqueuedAt: row.enqueuedAt.getTime(),
100
+ processedAt: row.processedAt?.getTime(),
101
+ finishedAt: row.finishedAt?.getTime(),
102
+ exit: row.exit ?? undefined,
103
+ failedReason: row.failedReason ?? undefined
104
+ })
105
+
106
+ /**
107
+ * Build the store implementation. Requires `PgClient` and a `Scope` (for the
108
+ * LISTEN subscription).
109
+ *
110
+ * @since 0.1.0
111
+ */
112
+ export const make = (
113
+ options: DrizzleJobStoreOptions<any>
114
+ ): Effect.Effect<JobStore.Service, JobStore.JobStoreError, PgClient.PgClient | Scope.Scope> =>
115
+ Effect.gen(function*() {
116
+ const db: Db = yield* PgDrizzle.makeWithDefaults()
117
+ const client = db.$client
118
+ const jobs = options.jobs
119
+ const attempts = options.attempts
120
+ const jobsName = getTableConfig(jobs).name
121
+ const attemptsName = getTableConfig(attempts).name
122
+ const wakeChannel = `effect_mq_wake_${jobsName}`
123
+
124
+ if (options.validate ?? true) {
125
+ yield* Effect.all([
126
+ db.select({ id: jobs.id }).from(jobs).limit(0),
127
+ db.select({ jobId: attempts.jobId }).from(attempts).limit(0)
128
+ ]).pipe(
129
+ Effect.mapError(storeError(
130
+ `effect-mq: tables "${jobsName}"/"${attemptsName}" are missing or mismatched — ` +
131
+ `re-export the effect-mq/drizzle schema factories from your drizzle schema and run your migrations (drizzle-kit generate)`
132
+ ))
133
+ )
134
+ }
135
+
136
+ // Wake plumbing: a local version + deferred chain (same protocol as the
137
+ // memory driver), fed by (a) local store operations and (b) cross-process
138
+ // LISTEN notifications.
139
+ let wakeVersion = 0
140
+ let wake = Deferred.makeUnsafe<void>()
141
+ const signalWake = () => {
142
+ wakeVersion += 1
143
+ const current = wake
144
+ wake = Deferred.makeUnsafe<void>()
145
+ Deferred.doneUnsafe(current, Effect.void)
146
+ }
147
+ // Resubscribe forever: if the LISTEN stream ends or fails, wake-ups
148
+ // degrade to the worker's pollInterval until the next attempt succeeds.
149
+ yield* client.listen(wakeChannel).pipe(
150
+ Stream.runForEach(() => Effect.sync(signalWake)),
151
+ Effect.catchCause((cause) =>
152
+ Effect.logWarning(
153
+ "effect-mq: LISTEN subscription failed; wake-ups degraded to polling until resubscribe",
154
+ cause
155
+ )
156
+ ),
157
+ Effect.andThen(Effect.sleep("1 second")),
158
+ Effect.forever,
159
+ Effect.forkScoped
160
+ )
161
+ // Local mutation wake-up: bump synchronously, then best-effort NOTIFY so
162
+ // workers in other processes wake promptly too.
163
+ const wakeUp: Effect.Effect<void> = Effect.suspend(() => {
164
+ signalWake()
165
+ // The payload must be non-empty: @effect/sql-pg drops falsy payloads.
166
+ return client.notify(wakeChannel, "1").pipe(Effect.ignore)
167
+ })
168
+
169
+ const nowDate = Effect.map(Clock.currentTimeMillis, (ms) => new Date(ms))
170
+
171
+ // quote_ident handles quoted/mixed-case table names safely.
172
+ const seqExpr = sql`nextval(pg_get_serial_sequence(quote_ident(${jobsName}), 'seq'))`
173
+
174
+ const insertAttempt = (
175
+ tx: Pick<Db, "execute">,
176
+ jobId: string,
177
+ outcome: JobStore.AttemptRecord["outcome"],
178
+ startedAt: Date | null,
179
+ finishedAt: Date,
180
+ exit: JobStore.AttemptRecord["exit"]
181
+ ) =>
182
+ tx.execute(sql`
183
+ INSERT INTO ${attempts} (job_id, attempt, outcome, started_at, finished_at, exit)
184
+ SELECT ${jobId}, COALESCE(MAX(${attempts.attempt}), 0) + 1, ${outcome}, ${startedAt}, ${finishedAt},
185
+ ${exit === undefined ? null : JSON.stringify(exit)}::jsonb
186
+ FROM ${attempts} WHERE ${attempts.jobId} = ${jobId}
187
+ `)
188
+
189
+ // Retention: drop terminal peers (same name + state) beyond count/age.
190
+ const applyKeep = (
191
+ tx: Pick<Db, "execute">,
192
+ row: { name: string; state: string; keep: JobStore.KeepPolicy | null },
193
+ now: Date
194
+ ) =>
195
+ Effect.gen(function*() {
196
+ const keep = row.keep
197
+ if (keep === null || keep === undefined) return
198
+ if (keep.ageMs !== undefined) {
199
+ yield* tx.execute(sql`
200
+ DELETE FROM ${jobs}
201
+ WHERE ${jobs.name} = ${row.name} AND ${jobs.state} = ${row.state}
202
+ AND ${jobs.finishedAt} <= ${new Date(now.getTime() - keep.ageMs)}
203
+ `)
204
+ }
205
+ if (keep.count !== undefined) {
206
+ yield* tx.execute(sql`
207
+ DELETE FROM ${jobs}
208
+ WHERE ${jobs.name} = ${row.name} AND ${jobs.state} = ${row.state}
209
+ AND ${jobs.id} NOT IN (
210
+ SELECT ${jobs.id} FROM ${jobs}
211
+ WHERE ${jobs.name} = ${row.name} AND ${jobs.state} = ${row.state}
212
+ ORDER BY ${jobs.finishedAt} DESC, ${jobs.seq} DESC
213
+ LIMIT ${keep.count}
214
+ )
215
+ `)
216
+ }
217
+ })
218
+
219
+ // Distinguish JobNotFound vs LockLost after a guarded UPDATE hit 0 rows.
220
+ const explainMiss = (
221
+ id: JobStore.JobId
222
+ ): Effect.Effect<never, JobStore.JobStoreError | JobStore.JobNotFoundError | JobStore.LockLostError> =>
223
+ db.select({ id: jobs.id }).from(jobs).where(eq(jobs.id, id)).pipe(
224
+ Effect.mapError(storeError("failed to inspect job")),
225
+ Effect.flatMap((rows) =>
226
+ Effect.fail<JobStore.JobNotFoundError | JobStore.LockLostError>(
227
+ rows.length === 0
228
+ ? new JobStore.JobNotFoundError({ jobId: id })
229
+ : new JobStore.LockLostError({ jobId: id })
230
+ )
231
+ )
232
+ )
233
+
234
+ const store: JobStore.Service = {
235
+ enqueue: (request) =>
236
+ Effect.gen(function*() {
237
+ const now = yield* nowDate
238
+ const runAt = new Date(now.getTime() + Math.max(0, request.delayMs))
239
+ const state = request.delayMs > 0 ? "delayed" : "waiting"
240
+ // Store-assigned ids come from the seq sequence; loop on the
241
+ // (unlikely) collision with a user-supplied id.
242
+ for (let i = 0; i < 5; i++) {
243
+ const idExpr = request.id !== undefined
244
+ ? sql`${request.id}`
245
+ : sql`'j-' || ${seqExpr}::text`
246
+ const rows = rowsOf(yield* db.execute<{ id: string }>(sql`
247
+ INSERT INTO ${jobs} (id, name, queue, state, priority, payload, metadata,
248
+ attempts_max, backoff, keep, run_at, enqueued_at)
249
+ VALUES (${idExpr}, ${request.name}, ${request.queue}, ${state}, ${request.priority},
250
+ ${JSON.stringify(request.payload ?? null)}::jsonb, ${JSON.stringify(request.metadata)}::jsonb,
251
+ ${request.attemptsMax},
252
+ ${request.backoff === undefined ? null : JSON.stringify(request.backoff)}::jsonb,
253
+ ${request.keep === undefined ? null : JSON.stringify(request.keep)}::jsonb,
254
+ ${runAt}, ${now})
255
+ ON CONFLICT (id) DO NOTHING
256
+ RETURNING ${jobs.id} AS id
257
+ `).pipe(Effect.mapError(storeError("enqueue failed"))))
258
+ const inserted = rows[0]
259
+ if (inserted !== undefined) {
260
+ yield* wakeUp
261
+ return { id: JobId(inserted.id), duplicate: false }
262
+ }
263
+ if (request.id !== undefined) {
264
+ return { id: request.id, duplicate: true }
265
+ }
266
+ // generated id collided with an existing user id; try again
267
+ }
268
+ return yield* new JobStore.JobStoreError({
269
+ message: "enqueue failed: could not generate a unique job id"
270
+ })
271
+ }),
272
+
273
+ claim: (claimOptions) =>
274
+ Effect.suspend(() => {
275
+ // Snapshot BEFORE the transaction: any wake that fires while the
276
+ // claim's statements run must make awaitWake(token) return
277
+ // immediately (spurious wake-ups are allowed; lost ones are not).
278
+ const observedWake = wakeVersion
279
+ return db.transaction((tx) =>
280
+ Effect.gen(function*() {
281
+ const now = yield* nowDate
282
+ // Promote due delayed jobs first (separate statement: CTEs share
283
+ // a snapshot, so an UPDATE CTE would be invisible to the claim).
284
+ yield* tx.execute(sql`
285
+ UPDATE ${jobs} SET state = 'waiting'
286
+ WHERE ${jobs.queue} = ${claimOptions.queue} AND ${jobs.state} = 'delayed'
287
+ AND ${jobs.runAt} <= ${now}
288
+ `)
289
+ const claimed = rowsOf(yield* tx.execute<JobRow>(sql`
290
+ WITH candidate AS (
291
+ SELECT ${jobs.id} AS id FROM ${jobs}
292
+ WHERE ${jobs.queue} = ${claimOptions.queue} AND ${jobs.state} = 'waiting'
293
+ AND ${jobs.name} = ANY(${sql.param([...claimOptions.names])})
294
+ ORDER BY ${jobs.priority} DESC, ${jobs.seq} ASC
295
+ FOR UPDATE SKIP LOCKED
296
+ LIMIT 1
297
+ )
298
+ UPDATE ${jobs} SET state = 'active', lock_token = ${claimOptions.token},
299
+ lock_expires_at = ${new Date(now.getTime() + claimOptions.lockDurationMs)},
300
+ processed_at = ${now}
301
+ FROM candidate WHERE ${jobs.id} = candidate.id
302
+ RETURNING ${jobs.id} AS "id", ${jobs.name} AS "name", ${jobs.queue} AS "queue",
303
+ ${jobs.state} AS "state", ${jobs.priority} AS "priority", ${jobs.seq} AS "seq",
304
+ ${jobs.payload} AS "payload", ${jobs.metadata} AS "metadata",
305
+ ${jobs.attemptsMax} AS "attemptsMax", ${jobs.attemptsMade} AS "attemptsMade",
306
+ ${jobs.stalledCount} AS "stalledCount", ${jobs.backoff} AS "backoff",
307
+ ${jobs.keep} AS "keep", ${jobs.runAt} AS "runAt", ${jobs.enqueuedAt} AS "enqueuedAt",
308
+ ${jobs.processedAt} AS "processedAt", ${jobs.finishedAt} AS "finishedAt",
309
+ ${jobs.exit} AS "exit", ${jobs.failedReason} AS "failedReason"
310
+ `))
311
+ const row = claimed[0]
312
+ if (row !== undefined) {
313
+ const result: JobStore.ClaimResult = { _tag: "Claimed", job: toRecord(row) }
314
+ return result
315
+ }
316
+ const next = rowsOf(yield* tx.execute<{ next: Date | null }>(sql`
317
+ SELECT MIN(${jobs.runAt}) AS next FROM ${jobs}
318
+ WHERE ${jobs.queue} = ${claimOptions.queue} AND ${jobs.state} = 'delayed'
319
+ AND ${jobs.name} = ANY(${sql.param([...claimOptions.names])})
320
+ `))
321
+ const empty: JobStore.ClaimResult = {
322
+ _tag: "Empty",
323
+ nextRunAt: next[0]?.next?.getTime(),
324
+ wakeToken: observedWake
325
+ }
326
+ return empty
327
+ })
328
+ )
329
+ }).pipe(Effect.mapError((error) =>
330
+ error instanceof JobStore.JobStoreError ? error : storeError("claim failed")(error)
331
+ )),
332
+
333
+ ack: (id, token, outcome) =>
334
+ db.transaction((tx) =>
335
+ Effect.gen(function*() {
336
+ const now = yield* nowDate
337
+ const update = outcome._tag === "Complete"
338
+ ? sql`state = 'completed', exit = ${JSON.stringify(outcome.exit ?? null)}::jsonb, finished_at = ${now}`
339
+ : outcome._tag === "Fail"
340
+ ? sql`state = 'failed', exit = ${JSON.stringify(outcome.exit ?? null)}::jsonb, finished_at = ${now}`
341
+ : sql`state = ${outcome.delayMs > 0 ? "delayed" : "waiting"},
342
+ run_at = ${new Date(now.getTime() + Math.max(0, outcome.delayMs))},
343
+ seq = ${seqExpr}`
344
+ const rows = rowsOf(yield* tx.execute<
345
+ { processedAt: Date | null; name: string; state: string; keep: JobStore.KeepPolicy | null }
346
+ >(sql`
347
+ UPDATE ${jobs} SET ${update},
348
+ attempts_made = ${jobs.attemptsMade} + 1, lock_token = NULL, lock_expires_at = NULL
349
+ WHERE ${jobs.id} = ${id} AND ${jobs.state} = 'active' AND ${jobs.lockToken} = ${token}
350
+ RETURNING ${jobs.processedAt} AS "processedAt", ${jobs.name} AS "name",
351
+ ${jobs.state} AS "state", ${jobs.keep} AS "keep"
352
+ `))
353
+ const row = rows[0]
354
+ if (row === undefined) {
355
+ return yield* explainMiss(id)
356
+ }
357
+ const ledgerOutcome = outcome._tag === "Complete"
358
+ ? "completed" as const
359
+ : outcome._tag === "Fail"
360
+ ? "failed" as const
361
+ : "retried" as const
362
+ yield* insertAttempt(tx, id, ledgerOutcome, row.processedAt, now, outcome.exit)
363
+ if (outcome._tag !== "Retry") {
364
+ yield* applyKeep(tx, row, now)
365
+ }
366
+ })
367
+ ).pipe(
368
+ Effect.mapError((error) =>
369
+ error instanceof JobStore.JobNotFoundError || error instanceof JobStore.LockLostError ||
370
+ error instanceof JobStore.JobStoreError
371
+ ? error
372
+ : storeError("ack failed")(error)
373
+ ),
374
+ Effect.tap(() => outcome._tag === "Retry" ? wakeUp : Effect.void)
375
+ ),
376
+
377
+ release: (id, token) =>
378
+ Effect.gen(function*() {
379
+ const rows = rowsOf(yield* db.execute<{ id: string }>(sql`
380
+ UPDATE ${jobs} SET state = 'waiting', lock_token = NULL, lock_expires_at = NULL
381
+ WHERE ${jobs.id} = ${id} AND ${jobs.state} = 'active' AND ${jobs.lockToken} = ${token}
382
+ RETURNING ${jobs.id} AS id
383
+ `).pipe(Effect.mapError(storeError("release failed"))))
384
+ if (rows.length === 0) {
385
+ return yield* explainMiss(id)
386
+ }
387
+ yield* wakeUp
388
+ }),
389
+
390
+ extendLocks: (locks, durationMs) =>
391
+ Effect.gen(function*() {
392
+ if (locks.length === 0) return []
393
+ const now = yield* nowDate
394
+ const rows = rowsOf(yield* db.execute<{ id: string }>(sql`
395
+ WITH input AS (
396
+ SELECT ids.job_id, toks.token
397
+ FROM unnest(${sql.param(locks.map((lock) => lock.id))}::text[]) WITH ORDINALITY AS ids(job_id, ord)
398
+ JOIN unnest(${sql.param(locks.map((lock) => lock.token))}::text[]) WITH ORDINALITY AS toks(token, ord) USING (ord)
399
+ ),
400
+ updated AS (
401
+ UPDATE ${jobs} SET lock_expires_at = ${new Date(now.getTime() + durationMs)}
402
+ FROM input
403
+ WHERE ${jobs.id} = input.job_id AND ${jobs.state} = 'active' AND ${jobs.lockToken} = input.token
404
+ RETURNING ${jobs.id} AS id
405
+ )
406
+ SELECT input.job_id AS id FROM input
407
+ LEFT JOIN updated ON updated.id = input.job_id
408
+ WHERE updated.id IS NULL
409
+ `).pipe(Effect.mapError(storeError("extendLocks failed"))))
410
+ return rows.map((row) => JobId(row.id))
411
+ }),
412
+
413
+ recoverStalled: (recoverOptions) =>
414
+ db.transaction((tx) =>
415
+ Effect.gen(function*() {
416
+ const now = yield* nowDate
417
+ const rows = rowsOf(yield* tx.execute<
418
+ { id: string; state: string; processedAt: Date | null }
419
+ >(sql`
420
+ UPDATE ${jobs} SET
421
+ stalled_count = ${jobs.stalledCount} + 1,
422
+ lock_token = NULL, lock_expires_at = NULL,
423
+ state = CASE WHEN ${jobs.stalledCount} + 1 > ${recoverOptions.maxStalledCount}::int
424
+ THEN 'failed' ELSE 'waiting' END,
425
+ finished_at = CASE WHEN ${jobs.stalledCount} + 1 > ${recoverOptions.maxStalledCount}::int
426
+ THEN ${now}::timestamptz ELSE NULL END,
427
+ failed_reason = CASE WHEN ${jobs.stalledCount} + 1 > ${recoverOptions.maxStalledCount}::int
428
+ THEN 'job stalled more than allowable limit' ELSE NULL END
429
+ WHERE ${jobs.state} = 'active' AND ${jobs.lockExpiresAt} <= ${now}::timestamptz
430
+ RETURNING ${jobs.id} AS "id", ${jobs.state} AS "state", ${jobs.processedAt} AS "processedAt"
431
+ `))
432
+ for (const row of rows) {
433
+ yield* insertAttempt(tx, row.id, "stalled", row.processedAt, now, undefined)
434
+ }
435
+ return rows.map((row) => ({ id: JobId(row.id), failed: row.state === "failed" }))
436
+ })
437
+ ).pipe(
438
+ Effect.mapError((error) =>
439
+ error instanceof JobStore.JobStoreError ? error : storeError("recoverStalled failed")(error)
440
+ ),
441
+ Effect.tap((recovered) =>
442
+ recovered.some((entry) => !entry.failed) ? wakeUp : Effect.void
443
+ )
444
+ ),
445
+
446
+ awaitWake: (_queues, wakeToken) =>
447
+ Effect.suspend(() => {
448
+ if (wakeVersion > wakeToken) return Effect.void
449
+ return Deferred.await(wake)
450
+ }),
451
+
452
+ getJob: (id) =>
453
+ db.select().from(jobs).where(eq(jobs.id, id)).pipe(
454
+ Effect.mapError(storeError("getJob failed")),
455
+ Effect.map((rows) => {
456
+ const row = rows[0]
457
+ return row === undefined ? Option.none() : Option.some(toRecord(row))
458
+ })
459
+ ),
460
+
461
+ getAttempts: (id) =>
462
+ db.select().from(attempts).where(eq(attempts.jobId, id)).orderBy(asc(attempts.attempt)).pipe(
463
+ Effect.mapError(storeError("getAttempts failed")),
464
+ Effect.map((rows) =>
465
+ rows.map((row): JobStore.AttemptRecord => ({
466
+ attempt: row.attempt,
467
+ startedAt: row.startedAt?.getTime(),
468
+ finishedAt: row.finishedAt.getTime(),
469
+ outcome: row.outcome,
470
+ exit: row.exit ?? undefined
471
+ }))
472
+ )
473
+ ),
474
+
475
+ list: (listOptions) =>
476
+ Effect.gen(function*() {
477
+ const limit = Math.max(1, listOptions.limit ?? 50)
478
+ const conditions = [sql`TRUE`]
479
+ if (listOptions.queue !== undefined) conditions.push(sql`${jobs.queue} = ${listOptions.queue}`)
480
+ if (listOptions.name !== undefined) conditions.push(sql`${jobs.name} = ${listOptions.name}`)
481
+ if (listOptions.states !== undefined) {
482
+ // NB: an empty array matches nothing (ANY('{}') is false), same
483
+ // as the memory driver.
484
+ conditions.push(sql`${jobs.state} = ANY(${sql.param([...listOptions.states])})`)
485
+ }
486
+ if (listOptions.metadata !== undefined && Object.keys(listOptions.metadata).length > 0) {
487
+ conditions.push(sql`${jobs.metadata} @> ${JSON.stringify(listOptions.metadata)}::jsonb`)
488
+ }
489
+ if (listOptions.cursor !== undefined) {
490
+ const split = listOptions.cursor.indexOf(":")
491
+ const cursorAt = new Date(Number(listOptions.cursor.slice(0, split)))
492
+ const cursorId = listOptions.cursor.slice(split + 1)
493
+ conditions.push(
494
+ sql`(${jobs.enqueuedAt}, ${jobs.id}) < (${cursorAt}, ${cursorId})`
495
+ )
496
+ }
497
+ const rows = rowsOf(yield* db.execute<JobRow>(sql`
498
+ SELECT ${jobs.id} AS "id", ${jobs.name} AS "name", ${jobs.queue} AS "queue",
499
+ ${jobs.state} AS "state", ${jobs.priority} AS "priority", ${jobs.seq} AS "seq",
500
+ ${jobs.payload} AS "payload", ${jobs.metadata} AS "metadata",
501
+ ${jobs.attemptsMax} AS "attemptsMax", ${jobs.attemptsMade} AS "attemptsMade",
502
+ ${jobs.stalledCount} AS "stalledCount", ${jobs.backoff} AS "backoff",
503
+ ${jobs.keep} AS "keep", ${jobs.runAt} AS "runAt", ${jobs.enqueuedAt} AS "enqueuedAt",
504
+ ${jobs.processedAt} AS "processedAt", ${jobs.finishedAt} AS "finishedAt",
505
+ ${jobs.exit} AS "exit", ${jobs.failedReason} AS "failedReason"
506
+ FROM ${jobs}
507
+ WHERE ${sql.join(conditions, sql` AND `)}
508
+ ORDER BY ${jobs.enqueuedAt} DESC, ${jobs.id} DESC
509
+ LIMIT ${limit + 1}
510
+ `).pipe(Effect.mapError(storeError("list failed"))))
511
+ const items = rows.slice(0, limit).map(toRecord)
512
+ const last = items[items.length - 1]
513
+ return {
514
+ items,
515
+ cursor: rows.length > limit && last !== undefined
516
+ ? `${last.enqueuedAt}:${last.id}`
517
+ : undefined
518
+ }
519
+ }),
520
+
521
+ retry: (id) =>
522
+ Effect.gen(function*() {
523
+ const now = yield* nowDate
524
+ const rows = rowsOf(yield* db.execute<{ id: string }>(sql`
525
+ UPDATE ${jobs} SET state = 'waiting', attempts_made = 0, stalled_count = 0,
526
+ exit = NULL, failed_reason = NULL, finished_at = NULL, processed_at = NULL,
527
+ run_at = ${now}, seq = ${seqExpr}
528
+ WHERE ${jobs.id} = ${id} AND ${jobs.state} = 'failed'
529
+ RETURNING ${jobs.id} AS id
530
+ `).pipe(Effect.mapError(storeError("retry failed"))))
531
+ if (rows.length === 0) {
532
+ const existing = yield* db.select({ state: jobs.state }).from(jobs)
533
+ .where(eq(jobs.id, id)).pipe(Effect.mapError(storeError("retry failed")))
534
+ const found = existing[0]
535
+ if (found === undefined) {
536
+ return yield* new JobStore.JobNotFoundError({ jobId: id })
537
+ }
538
+ return yield* new JobStore.JobNotRetryableError({ jobId: id, state: found.state })
539
+ }
540
+ yield* wakeUp
541
+ }),
542
+
543
+ counts: (queue) =>
544
+ db.execute<{ state: JobStore.JobState; count: number }>(sql`
545
+ SELECT ${jobs.state} AS "state", count(*)::int AS "count" FROM ${jobs}
546
+ ${queue === undefined ? sql`` : sql`WHERE ${jobs.queue} = ${queue}`}
547
+ GROUP BY ${jobs.state}
548
+ `).pipe(
549
+ Effect.mapError(storeError("counts failed")),
550
+ Effect.map((result) => {
551
+ const rows = rowsOf(result)
552
+ const counts = {
553
+ waiting: 0,
554
+ delayed: 0,
555
+ active: 0,
556
+ completed: 0,
557
+ failed: 0
558
+ } satisfies Record<JobStore.JobState, number>
559
+ for (const row of rows) counts[row.state] = row.count
560
+ return counts
561
+ })
562
+ ),
563
+
564
+ remove: (id) =>
565
+ db.execute(sql`
566
+ DELETE FROM ${jobs}
567
+ WHERE ${jobs.id} = ${id} AND ${jobs.state} <> 'active'
568
+ RETURNING ${jobs.id} AS id
569
+ `).pipe(
570
+ Effect.mapError(storeError("remove failed")),
571
+ Effect.map((result) => rowsOf(result).length > 0)
572
+ )
573
+ }
574
+
575
+ return store
576
+ })
577
+
578
+ /**
579
+ * A Postgres-backed `JobStore` layer over your drizzle tables. Requires
580
+ * `PgClient` (from `@effect/sql-pg`).
581
+ *
582
+ * ```ts
583
+ * const StoreLive = DrizzleJobStore.layer({ jobs, attempts, store: Durable }).pipe(
584
+ * Layer.provide(PgClient.layer({ url: Redacted.make(DATABASE_URL) }))
585
+ * )
586
+ * ```
587
+ *
588
+ * @since 0.1.0
589
+ */
590
+ export const layer = <StoreId = JobStore.JobStore>(
591
+ options: DrizzleJobStoreOptions<StoreId>
592
+ ): Layer.Layer<StoreId, JobStore.JobStoreError, PgClient.PgClient> =>
593
+ Layer.effect(
594
+ // SAFETY: when `options.store` is omitted the public signature fixes
595
+ // `StoreId` to its default `JobStore.JobStore`, so the default key is the
596
+ // right `Context.Key<StoreId>`; when it is present the cast is an identity.
597
+ (options.store ?? JobStore.JobStore) as Context.Key<StoreId, JobStore.Service>,
598
+ make(options)
599
+ )
@@ -0,0 +1,20 @@
1
+ /**
2
+ * Postgres storage for effect-mq through drizzle.
3
+ *
4
+ * @since 0.1.0
5
+ */
6
+
7
+ /**
8
+ * The `JobStore` layer over your drizzle tables (via `drizzle-orm/effect-postgres`).
9
+ *
10
+ * @since 0.1.0
11
+ */
12
+ export * as DrizzleJobStore from "./DrizzleJobStore.ts"
13
+
14
+ /**
15
+ * Drizzle table factories — re-export from your schema for drizzle-kit
16
+ * migrations and typed queries.
17
+ *
18
+ * @since 0.1.0
19
+ */
20
+ export * from "./schema.ts"