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,446 @@
1
+ /**
2
+ * The storage seam of effect-mq.
3
+ *
4
+ * `JobStore` is the minimal, storage-agnostic interface a queue backend must
5
+ * implement. Every method must be atomic within the driver. The reference
6
+ * implementation is `MemoryJobStore`; Postgres (via `effect-mq/drizzle`),
7
+ * Redis, etc. drivers implement the same service.
8
+ *
9
+ * The store works entirely on *encoded* (JSON-safe) payloads and exits —
10
+ * schema encoding/decoding happens in `Job` (producer side) and `Worker`
11
+ * (consumer side), so drivers stay dumb.
12
+ *
13
+ * Multiple stores can coexist in one application via named store keys (see
14
+ * `named`): each job definition binds to a store key, so business-critical
15
+ * jobs can live in Postgres while disposable ones live elsewhere.
16
+ *
17
+ * @since 0.1.0
18
+ */
19
+ import { Brand, Context, Data, type Effect, type Option, Predicate } from "effect"
20
+
21
+ /**
22
+ * The identifier of an enqueued job. Produced by `enqueue` (either
23
+ * store-assigned or derived from a custom id / idempotency key).
24
+ *
25
+ * @since 0.1.0
26
+ */
27
+ export type JobId = Brand.Branded<string, "effect-mq/JobId">
28
+
29
+ /**
30
+ * Brand a raw string as a `JobId`.
31
+ *
32
+ * @since 0.1.0
33
+ */
34
+ export const JobId: Brand.Constructor<JobId> = Brand.nominal<JobId>()
35
+
36
+ /**
37
+ * The name of a queue.
38
+ *
39
+ * @since 0.1.0
40
+ */
41
+ export type QueueName = Brand.Branded<string, "effect-mq/QueueName">
42
+
43
+ /**
44
+ * Brand a raw string as a `QueueName`.
45
+ *
46
+ * @since 0.1.0
47
+ */
48
+ export const QueueName: Brand.Constructor<QueueName> = Brand.nominal<QueueName>()
49
+
50
+ /**
51
+ * The lifecycle states of a job.
52
+ *
53
+ * - `waiting`: runnable now, ordered by (priority desc, enqueue order asc)
54
+ * - `delayed`: must not run before `runAt`
55
+ * - `active`: claimed by a worker holding a lock token
56
+ * - `completed` / `failed`: terminal, with an encoded `Exit` stored
57
+ *
58
+ * @since 0.1.0
59
+ */
60
+ export type JobState = "waiting" | "delayed" | "active" | "completed" | "failed"
61
+
62
+ /**
63
+ * Retry backoff policy, persisted on the job record so any worker can route
64
+ * retries consistently. Delay for attempt `n` (1-based):
65
+ *
66
+ * - `fixed`: `delayMs`
67
+ * - `exponential`: `delayMs * factor ** (n - 1)` (factor defaults to 2)
68
+ *
69
+ * @since 0.1.0
70
+ */
71
+ export interface BackoffPolicy {
72
+ readonly _tag: "fixed" | "exponential"
73
+ readonly delayMs: number
74
+ readonly factor?: number | undefined
75
+ }
76
+
77
+ /**
78
+ * Retention policy for terminal (completed/failed) jobs, persisted on the
79
+ * record. Applied by the store after terminal acks, scoped to jobs with the
80
+ * same name and state. Default (undefined) keeps records forever.
81
+ *
82
+ * @since 0.1.0
83
+ */
84
+ export interface KeepPolicy {
85
+ /** Keep at most this many terminal records (per name + state). */
86
+ readonly count?: number | undefined
87
+ /** Remove terminal records older than this many milliseconds. */
88
+ readonly ageMs?: number | undefined
89
+ }
90
+
91
+ /**
92
+ * One run of a job, persisted so the full run history is durable and
93
+ * inspectable — the storage-level analogue of `tapError` before a rerun,
94
+ * extended to successes. Attempt numbers are monotonic per job and survive
95
+ * `retry` (they are decoupled from the record's `attemptsMade` budget).
96
+ *
97
+ * @since 0.1.0
98
+ */
99
+ export interface AttemptRecord {
100
+ /** 1-based, monotonic per job (= previous ledger length + 1). */
101
+ readonly attempt: number
102
+ /** Claim time of this run (epoch millis). */
103
+ readonly startedAt: number | undefined
104
+ /** Ack/recovery time of this run (epoch millis). */
105
+ readonly finishedAt: number
106
+ readonly outcome: "completed" | "retried" | "failed" | "stalled"
107
+ /** Schema-encoded `Exit`; undefined for `stalled`. */
108
+ readonly exit: unknown
109
+ }
110
+
111
+ /**
112
+ * A job as persisted by the store. `payload` and `exit` are schema-encoded
113
+ * (JSON-safe) values — the store never inspects them. The per-run ledger is
114
+ * fetched separately via `getAttempts` so listings stay cheap.
115
+ *
116
+ * @since 0.1.0
117
+ */
118
+ export interface JobRecord {
119
+ readonly id: JobId
120
+ readonly name: string
121
+ readonly queue: QueueName
122
+ readonly payload: unknown
123
+ /** Flat, indexable projection of business context for querying/UIs. */
124
+ readonly metadata: Readonly<Record<string, string>>
125
+ readonly state: JobState
126
+ readonly priority: number
127
+ /** Total attempts allowed (including the first run). */
128
+ readonly attemptsMax: number
129
+ /** Attempts consumed in the current budget (reset by `retry`). */
130
+ readonly attemptsMade: number
131
+ readonly stalledCount: number
132
+ readonly backoff: BackoffPolicy | undefined
133
+ readonly keep: KeepPolicy | undefined
134
+ /** Epoch millis before which the job must not be claimed. */
135
+ readonly runAt: number
136
+ readonly enqueuedAt: number
137
+ readonly processedAt: number | undefined
138
+ readonly finishedAt: number | undefined
139
+ /** Schema-encoded `Exit`, present for completed/failed jobs. */
140
+ readonly exit: unknown
141
+ /** Set when the store itself failed the job (e.g. exceeded stall limit). */
142
+ readonly failedReason: string | undefined
143
+ }
144
+
145
+ /**
146
+ * @since 0.1.0
147
+ */
148
+ export interface EnqueueRequest {
149
+ /**
150
+ * Custom/idempotency id. When a job with this id already exists (in any
151
+ * state), the request is a no-op and the result has `duplicate: true`.
152
+ * When `undefined` the store assigns a unique id.
153
+ */
154
+ readonly id: JobId | undefined
155
+ readonly name: string
156
+ readonly queue: QueueName
157
+ readonly payload: unknown
158
+ readonly metadata: Readonly<Record<string, string>>
159
+ readonly priority: number
160
+ readonly attemptsMax: number
161
+ readonly backoff: BackoffPolicy | undefined
162
+ readonly keep: KeepPolicy | undefined
163
+ readonly delayMs: number
164
+ }
165
+
166
+ /**
167
+ * @since 0.1.0
168
+ */
169
+ export interface EnqueueResult {
170
+ readonly id: JobId
171
+ /** True when a job with this id already existed; nothing was modified. */
172
+ readonly duplicate: boolean
173
+ }
174
+
175
+ /**
176
+ * @since 0.1.0
177
+ */
178
+ export interface ClaimOptions {
179
+ readonly queue: QueueName
180
+ /** Only jobs with these names may be claimed (the worker's registered handlers). */
181
+ readonly names: ReadonlyArray<string>
182
+ /** Worker-generated lock token; all subsequent acks must present it. */
183
+ readonly token: string
184
+ readonly lockDurationMs: number
185
+ }
186
+
187
+ /**
188
+ * Result of a claim attempt. `Empty.nextRunAt` is the earliest `runAt` among
189
+ * matching delayed jobs (so the worker knows how long to sleep), and
190
+ * `wakeToken` is an opaque cursor for `awaitWake` so wake-ups that happen
191
+ * between the claim and the wait are not lost.
192
+ *
193
+ * @since 0.1.0
194
+ */
195
+ export type ClaimResult =
196
+ | { readonly _tag: "Claimed"; readonly job: JobRecord }
197
+ | {
198
+ readonly _tag: "Empty"
199
+ readonly nextRunAt: number | undefined
200
+ readonly wakeToken: number
201
+ }
202
+
203
+ /**
204
+ * How a worker acknowledges a claimed job. Retry routing (backoff delay,
205
+ * attempts accounting) is computed by the worker; the store only applies it.
206
+ *
207
+ * Every outcome appends an `AttemptRecord` to the job's ledger (`Complete` →
208
+ * completed, `Retry` → retried, `Fail` → failed).
209
+ *
210
+ * @since 0.1.0
211
+ */
212
+ export type AckOutcome =
213
+ | { readonly _tag: "Complete"; readonly exit: unknown }
214
+ | { readonly _tag: "Retry"; readonly delayMs: number; readonly exit: unknown }
215
+ | { readonly _tag: "Fail"; readonly exit: unknown }
216
+
217
+ /**
218
+ * Filters and pagination for `list`. Results are ordered newest-first
219
+ * (`enqueuedAt` desc, then id desc); pass the returned `cursor` back to get
220
+ * the next page.
221
+ *
222
+ * @since 0.1.0
223
+ */
224
+ export interface ListOptions {
225
+ readonly queue?: QueueName | undefined
226
+ readonly name?: string | undefined
227
+ readonly states?: ReadonlyArray<JobState> | undefined
228
+ /** Every entry must match the record's metadata exactly (AND semantics). */
229
+ readonly metadata?: Readonly<Record<string, string>> | undefined
230
+ readonly cursor?: string | undefined
231
+ /** Page size; default 50. */
232
+ readonly limit?: number | undefined
233
+ }
234
+
235
+ /**
236
+ * @since 0.1.0
237
+ */
238
+ export interface ListResult {
239
+ readonly items: ReadonlyArray<JobRecord>
240
+ /** Present when more items may exist; pass back via `ListOptions.cursor`. */
241
+ readonly cursor: string | undefined
242
+ }
243
+
244
+ /**
245
+ * A transient or fatal driver error (connection loss, serialization, etc.).
246
+ *
247
+ * @since 0.1.0
248
+ */
249
+ export class JobStoreError extends Data.TaggedError("JobStoreError")<{
250
+ readonly message: string
251
+ readonly cause?: unknown
252
+ }> {}
253
+
254
+ /**
255
+ * Tag-based guard (safe across duplicate module copies, unlike `instanceof`).
256
+ *
257
+ * @since 0.1.0
258
+ */
259
+ // oxlint-disable-next-line anti-slop/no-unknown-parameters -- a type guard IS the boundary parser; `unknown` input is its purpose
260
+ export const isJobStoreError = (u: unknown): u is JobStoreError =>
261
+ Predicate.hasProperty(u, "_tag") && u._tag === "JobStoreError"
262
+
263
+
264
+ /**
265
+ * The presented lock token no longer owns the job (it stalled and was
266
+ * recovered, or another worker claimed it).
267
+ *
268
+ * @since 0.1.0
269
+ */
270
+ export class LockLostError extends Data.TaggedError("LockLostError")<{
271
+ readonly jobId: JobId
272
+ }> {}
273
+
274
+ /**
275
+ * @since 0.1.0
276
+ */
277
+ export class JobNotFoundError extends Data.TaggedError("JobNotFoundError")<{
278
+ readonly jobId: JobId
279
+ }> {}
280
+
281
+ /**
282
+ * `retry` was called on a job that is not in the `failed` state.
283
+ *
284
+ * @since 0.1.0
285
+ */
286
+ export class JobNotRetryableError extends Data.TaggedError("JobNotRetryableError")<{
287
+ readonly jobId: JobId
288
+ readonly state: JobState
289
+ }> {}
290
+
291
+ /**
292
+ * The service shape every store implements.
293
+ *
294
+ * @since 0.1.0
295
+ */
296
+ export interface Service {
297
+ /**
298
+ * Insert a job. Routing: `delayMs > 0` lands in `delayed`, otherwise
299
+ * `waiting`. Duplicate ids are a silent no-op (see `EnqueueRequest.id`).
300
+ */
301
+ readonly enqueue: (
302
+ request: EnqueueRequest
303
+ ) => Effect.Effect<EnqueueResult, JobStoreError>
304
+
305
+ /**
306
+ * Atomically: promote due delayed jobs, then claim the best runnable job
307
+ * matching `queue` + `names` (highest priority first, FIFO within a
308
+ * priority), locking it with `token` for `lockDurationMs`.
309
+ */
310
+ readonly claim: (
311
+ options: ClaimOptions
312
+ ) => Effect.Effect<ClaimResult, JobStoreError>
313
+
314
+ /**
315
+ * Acknowledge a claimed job. Verifies the lock token, releases the lock,
316
+ * increments `attemptsMade`, appends to the attempts ledger, then applies
317
+ * the outcome (`Complete`/`Fail` are terminal and apply the record's `keep`
318
+ * policy; `Retry` re-queues after `delayMs`).
319
+ */
320
+ readonly ack: (
321
+ id: JobId,
322
+ token: string,
323
+ outcome: AckOutcome
324
+ ) => Effect.Effect<void, JobStoreError | JobNotFoundError | LockLostError>
325
+
326
+ /**
327
+ * Return a claimed job to `waiting` without consuming an attempt or
328
+ * recording a ledger entry (used on worker shutdown).
329
+ */
330
+ readonly release: (
331
+ id: JobId,
332
+ token: string
333
+ ) => Effect.Effect<void, JobStoreError | JobNotFoundError | LockLostError>
334
+
335
+ /**
336
+ * Heartbeat: extend the given locks. Returns the ids whose lock could NOT
337
+ * be extended (lost to stall recovery or another worker).
338
+ */
339
+ readonly extendLocks: (
340
+ locks: ReadonlyArray<{ readonly id: JobId; readonly token: string }>,
341
+ durationMs: number
342
+ ) => Effect.Effect<ReadonlyArray<JobId>, JobStoreError>
343
+
344
+ /**
345
+ * Sweep active jobs whose lock has expired. Each recovered job gets
346
+ * `stalledCount + 1` and a `stalled` ledger entry; jobs exceeding
347
+ * `maxStalledCount` are failed (`failed: true` in the result), the rest
348
+ * return to `waiting`.
349
+ */
350
+ readonly recoverStalled: (options: {
351
+ readonly maxStalledCount: number
352
+ }) => Effect.Effect<
353
+ ReadonlyArray<{ readonly id: JobId; readonly failed: boolean }>,
354
+ JobStoreError
355
+ >
356
+
357
+ /**
358
+ * Resolve when new work *may* be runnable for the given queues since the
359
+ * `wakeToken` observed by a previous `claim`. Spurious wake-ups are fine;
360
+ * callers must combine with their own timeout. Must be interruptible.
361
+ * Polling-only drivers may never resolve.
362
+ */
363
+ readonly awaitWake: (
364
+ queues: ReadonlyArray<QueueName>,
365
+ wakeToken: number
366
+ ) => Effect.Effect<void, JobStoreError>
367
+
368
+ readonly getJob: (
369
+ id: JobId
370
+ ) => Effect.Effect<Option.Option<JobRecord>, JobStoreError>
371
+
372
+ /** The job's run ledger, oldest first. Empty for unknown ids. */
373
+ readonly getAttempts: (
374
+ id: JobId
375
+ ) => Effect.Effect<ReadonlyArray<AttemptRecord>, JobStoreError>
376
+
377
+ /** Query jobs (newest first) — the data layer for dashboards/UIs. */
378
+ readonly list: (
379
+ options: ListOptions
380
+ ) => Effect.Effect<ListResult, JobStoreError>
381
+
382
+ /**
383
+ * Re-run a failed job: back to `waiting` with a fresh attempt budget
384
+ * (`attemptsMade`/`stalledCount` reset, terminal fields cleared). The
385
+ * attempts ledger is preserved and keeps numbering monotonically.
386
+ */
387
+ readonly retry: (
388
+ id: JobId
389
+ ) => Effect.Effect<
390
+ void,
391
+ JobStoreError | JobNotFoundError | JobNotRetryableError
392
+ >
393
+
394
+ readonly counts: (
395
+ queue?: QueueName
396
+ ) => Effect.Effect<Record<JobState, number>, JobStoreError>
397
+
398
+ /** Remove a job (and its ledger). Refuses (returns false) when active. */
399
+ readonly remove: (id: JobId) => Effect.Effect<boolean, JobStoreError>
400
+ }
401
+
402
+ /**
403
+ * The default store key. Jobs without an explicit `store` binding use this.
404
+ *
405
+ * @since 0.1.0
406
+ */
407
+ export class JobStore extends Context.Service<JobStore, Service>()(
408
+ "effect-mq/JobStore"
409
+ ) {}
410
+
411
+ /**
412
+ * Phantom identifier for a named store — appears in `R` so the type system
413
+ * enforces that the right store layer is provided.
414
+ *
415
+ * @since 0.1.0
416
+ */
417
+ export interface Named<in out Name extends string> {
418
+ readonly "~effect-mq/JobStore/Named": Name
419
+ }
420
+
421
+ /**
422
+ * Create a named store key. Jobs bound to it (via `Job.make`'s `store`
423
+ * option) require it in `R` instead of the default `JobStore`, letting
424
+ * different jobs run on different storage infrastructure:
425
+ *
426
+ * ```ts
427
+ * const Durable = JobStore.named("durable") // -> Postgres in prod
428
+ * const Ephemeral = JobStore.named("ephemeral") // -> Redis in prod
429
+ * ```
430
+ *
431
+ * Keys are identified by their name string: two `named("durable")` calls are
432
+ * interchangeable.
433
+ *
434
+ * @since 0.1.0
435
+ */
436
+ export const named = <const Name extends string>(
437
+ name: Name
438
+ ): Context.Key<Named<Name>, Service> =>
439
+ Context.Service<Named<Name>, Service>(`effect-mq/JobStore/${name}`)
440
+
441
+ /**
442
+ * Any store key — the default `JobStore` or a `named` one.
443
+ *
444
+ * @since 0.1.0
445
+ */
446
+ export type AnyKey = Context.Key<any, Service>