effect-mq 0.6.0 → 0.7.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "effect-mq",
3
- "version": "0.6.0",
3
+ "version": "0.7.0",
4
4
  "description": "Effect-native background jobs: schema-first definitions, storage-agnostic queue core, worker runtime, and a Postgres store through drizzle.",
5
5
  "license": "MIT",
6
6
  "author": "Adam Rankin",
package/src/JobStore.ts CHANGED
@@ -594,9 +594,18 @@ export type AckOutcome =
594
594
  }
595
595
 
596
596
  /**
597
- * Filters and pagination for `list`. Results are ordered newest-first
598
- * (`enqueuedAt` desc, then id desc); pass the returned `cursor` back to get
599
- * the next page.
597
+ * Filters, ordering, and pagination for `list`. The default order is
598
+ * newest-first (`enqueuedAt` desc, then id desc); pass the returned
599
+ * `cursor` back — with the SAME options that produced it — to get the next
600
+ * page.
601
+ *
602
+ * Ordering is not free on every storage layout, so the contract defines a
603
+ * REQUIRED (filter, orderBy) surface every driver serves
604
+ * (conformance-pinned): `enqueuedAt` with any filters; `runAt` for
605
+ * `states: ["delayed"]` within a `queue`; `finishedAt` for terminal
606
+ * `states`, with or without `name`/`queue`. Beyond that surface a driver
607
+ * either serves the query or dies with `ListOrderUnsupportedError` — never
608
+ * a silent full scan. Memory and Postgres serve every combination.
600
609
  *
601
610
  * @since 0.1.0
602
611
  */
@@ -606,11 +615,37 @@ export interface ListOptions {
606
615
  readonly states?: ReadonlyArray<JobState> | undefined
607
616
  /** Every entry must match the record's metadata exactly (AND semantics). */
608
617
  readonly metadata?: Readonly<Record<string, string>> | undefined
618
+ /**
619
+ * The field to order by (default `enqueuedAt`). Jobs missing the field
620
+ * (`finishedAt` on non-terminal rows) sort as 0.
621
+ *
622
+ * @since 0.7.0
623
+ */
624
+ readonly orderBy?: "enqueuedAt" | "runAt" | "finishedAt" | undefined
625
+ /**
626
+ * Direction (default `desc`). The id tiebreak follows the direction.
627
+ *
628
+ * @since 0.7.0
629
+ */
630
+ readonly order?: "asc" | "desc" | undefined
609
631
  readonly cursor?: string | undefined
610
632
  /** Page size; default 50. */
611
633
  readonly limit?: number | undefined
612
634
  }
613
635
 
636
+ /**
637
+ * A driver received a (filter, `orderBy`) combination outside the surface
638
+ * it can serve without a full scan (see `ListOptions`). Delivered as a
639
+ * defect: the query contradicts the driver's documented matrix, which is a
640
+ * programming mistake, not a runtime condition to recover from.
641
+ *
642
+ * @since 0.7.0
643
+ */
644
+ export class ListOrderUnsupportedError extends Data.TaggedError("ListOrderUnsupportedError")<{
645
+ readonly orderBy: string
646
+ readonly message: string
647
+ }> {}
648
+
614
649
  /**
615
650
  * @since 0.1.0
616
651
  */
@@ -795,7 +795,14 @@ const makeStoreUnsafe = (options?: MemoryJobStoreOptions | undefined): MemorySto
795
795
  Effect.sync(() => {
796
796
  const limit = Math.max(1, options.limit ?? 50)
797
797
  const states = options.states === undefined ? undefined : new Set(options.states)
798
- // Newest first, stable across retries: (enqueuedAt desc, id desc).
798
+ const orderBy = options.orderBy ?? "enqueuedAt"
799
+ const descending = (options.order ?? "desc") === "desc"
800
+ // Jobs missing the field (finishedAt on non-terminal rows) sort as 0.
801
+ const orderValue = (job: MemJob): number =>
802
+ orderBy === "enqueuedAt" ? job.enqueuedAt : orderBy === "runAt" ? job.runAt : job.finishedAt ?? 0
803
+ const compareIds = (a: string, b: string): number => (a < b ? -1 : a > b ? 1 : 0)
804
+ // Stable across retries: the id tiebreak follows the direction, and
805
+ // the cursor excludes everything at or before its (value, id).
799
806
  let cursor: { readonly at: number; readonly id: string } | undefined
800
807
  if (options.cursor !== undefined) {
801
808
  const split = options.cursor.indexOf(":")
@@ -811,26 +818,27 @@ const makeStoreUnsafe = (options?: MemoryJobStoreOptions | undefined): MemorySto
811
818
  (states === undefined || states.has(job.state)) &&
812
819
  (options.metadata === undefined || metadataMatches(job.metadata, options.metadata))
813
820
  )
814
- .toSorted((a, b) =>
815
- b.enqueuedAt !== a.enqueuedAt
816
- ? b.enqueuedAt - a.enqueuedAt
817
- : b.id < a.id
818
- ? -1
819
- : b.id > a.id
820
- ? 1
821
- : 0
822
- )
823
- .filter((job) =>
824
- cursor === undefined ||
825
- job.enqueuedAt < cursor.at ||
826
- (job.enqueuedAt === cursor.at && job.id < cursor.id)
827
- )
821
+ .toSorted((a, b) => {
822
+ const byValue = descending
823
+ ? orderValue(b) - orderValue(a)
824
+ : orderValue(a) - orderValue(b)
825
+ if (byValue !== 0) return byValue
826
+ return descending ? compareIds(b.id, a.id) : compareIds(a.id, b.id)
827
+ })
828
+ .filter((job) => {
829
+ if (cursor === undefined) return true
830
+ const value = orderValue(job)
831
+ if (descending) {
832
+ return value < cursor.at || (value === cursor.at && job.id < cursor.id)
833
+ }
834
+ return value > cursor.at || (value === cursor.at && job.id > cursor.id)
835
+ })
828
836
  const items = matches.slice(0, limit).map(snapshot)
829
- const last = items[items.length - 1]
837
+ const lastJob = matches[Math.min(limit, matches.length) - 1]
830
838
  const result: ListResult = {
831
839
  items,
832
- cursor: matches.length > limit && last !== undefined
833
- ? `${last.enqueuedAt}:${last.id}`
840
+ cursor: matches.length > limit && lastJob !== undefined
841
+ ? `${orderValue(lastJob)}:${lastJob.id}`
834
842
  : undefined
835
843
  }
836
844
  return result
@@ -1585,12 +1585,30 @@ export const make = (
1585
1585
  if (listOptions.metadata !== undefined && Object.keys(listOptions.metadata).length > 0) {
1586
1586
  conditions.push(sql`${jobs.metadata} @> ${JSON.stringify(listOptions.metadata)}::jsonb`)
1587
1587
  }
1588
+ const orderBy = listOptions.orderBy ?? "enqueuedAt"
1589
+ const descending = (listOptions.order ?? "desc") === "desc"
1590
+ // Order on the exact value the record reports: enqueued_at/run_at
1591
+ // are NOT NULL columns; finished_at is absent on non-terminal rows
1592
+ // and sorts as 0 — COALESCE to the epoch, which is what the
1593
+ // `<orderValueMillis>:<id>` cursor round-trips through `new
1594
+ // Date(0)`. All stored timestamps were bound from the Clock at
1595
+ // millisecond precision, so cursor equality comparisons are exact.
1596
+ const orderExpr = orderBy === "enqueuedAt"
1597
+ ? sql`${jobs.enqueuedAt}`
1598
+ : orderBy === "runAt"
1599
+ ? sql`${jobs.runAt}`
1600
+ : sql`COALESCE(${jobs.finishedAt}, 'epoch'::timestamptz)`
1601
+ const direction = descending ? sql`DESC` : sql`ASC`
1588
1602
  if (listOptions.cursor !== undefined) {
1603
+ // Exclusive keyset: `<orderValueMillis>:<id>`, strictly past the
1604
+ // cursor row in the requested direction (id tiebreak included).
1589
1605
  const split = listOptions.cursor.indexOf(":")
1590
1606
  const cursorAt = new Date(Number(listOptions.cursor.slice(0, split)))
1591
1607
  const cursorId = listOptions.cursor.slice(split + 1)
1592
1608
  conditions.push(
1593
- sql`(${jobs.enqueuedAt}, ${jobs.id}) < (${cursorAt}, ${cursorId})`
1609
+ descending
1610
+ ? sql`(${orderExpr}, ${jobs.id}) < (${cursorAt}, ${cursorId})`
1611
+ : sql`(${orderExpr}, ${jobs.id}) > (${cursorAt}, ${cursorId})`
1594
1612
  )
1595
1613
  }
1596
1614
  const rows = rowsOf(yield* db.execute<JobRow>(sql`
@@ -1610,7 +1628,7 @@ export const make = (
1610
1628
  ${jobs.exit} AS "exit", ${jobs.failedReason} AS "failedReason"
1611
1629
  FROM ${jobs}
1612
1630
  WHERE ${sql.join(conditions, sql` AND `)}
1613
- ORDER BY ${jobs.enqueuedAt} DESC, ${jobs.id} DESC
1631
+ ORDER BY ${orderExpr} ${direction}, ${jobs.id} ${direction}
1614
1632
  LIMIT ${limit + 1}
1615
1633
  `).pipe(Effect.mapError(storeError("list failed"))))
1616
1634
  const items = rows.slice(0, limit).map(toRecord)
@@ -1618,7 +1636,13 @@ export const make = (
1618
1636
  return {
1619
1637
  items,
1620
1638
  cursor: rows.length > limit && last !== undefined
1621
- ? `${last.enqueuedAt}:${last.id}`
1639
+ ? `${
1640
+ orderBy === "enqueuedAt"
1641
+ ? last.enqueuedAt
1642
+ : orderBy === "runAt"
1643
+ ? last.runAt
1644
+ : last.finishedAt ?? 0
1645
+ }:${last.id}`
1622
1646
  : undefined
1623
1647
  }
1624
1648
  }),
@@ -12,7 +12,20 @@
12
12
  *
13
13
  * @since 0.2.0
14
14
  */
15
- import { Clock, type Context, Deferred, Duration, Effect, Exit, Layer, Option, Queue, Schedule, type Scope } from "effect"
15
+ import {
16
+ Clock,
17
+ type Context,
18
+ Data,
19
+ Deferred,
20
+ Duration,
21
+ Effect,
22
+ Exit,
23
+ Layer,
24
+ Option,
25
+ Queue,
26
+ Schedule,
27
+ type Scope
28
+ } from "effect"
16
29
  import { Redis } from "effect/unstable/persistence"
17
30
  import * as JobStore from "../JobStore.ts"
18
31
  import * as scripts from "./scripts.ts"
@@ -37,6 +50,65 @@ export interface RedisJobStoreOptions {
37
50
  * Default: `j-<n>` from the store's counter. See `JobStore.IdGenerator`.
38
51
  */
39
52
  readonly idGenerator?: JobStore.IdGenerator | undefined
53
+ /**
54
+ * The optional list indexes: `p:byname:<name>` and `p:byqueue:<queue>`,
55
+ * both scored by `enqueuedAt`, serving `list({ name })` / `list({ queue })`
56
+ * without a full scan. Default: all on. `false` disables both; a partial
57
+ * object disables one (`{ name: false }`). Disable an index only when you
58
+ * never list that way — a `list` that ROUTES to a disabled index dies with
59
+ * `ListIndexDisabledError` (a query servable from another structure, e.g.
60
+ * `name` + terminal `states` ordered by `finishedAt`, never touches it).
61
+ *
62
+ * This setting is a PER-PREFIX invariant: every store instance sharing a
63
+ * key prefix must agree on it. Index writes happen at insert time in the
64
+ * writing process, so a store with an index off inserts rows that index
65
+ * never sees — enabled readers on the same prefix silently miss those rows
66
+ * (the `p:index:<kind>:ready` marker churn between mixed stores is a
67
+ * symptom of the misconfiguration, not a safety net against it).
68
+ *
69
+ * Init reconciles each index one-shot. Enabled with no `ready` marker: a
70
+ * full driver-paged ZSCAN of `p:all` rebuilds it, then stamps the marker
71
+ * with the rebuild's start time. Enabled with a marker: rows enqueued since
72
+ * the marker minus a 60s margin are (re-)indexed — so a rolling deploy
73
+ * whose old, index-less writers kept inserting after the marker landed is
74
+ * healed by the last new-code boot — and the marker is re-stamped.
75
+ * Disabled: only the marker is deleted (a future re-enable then does a full
76
+ * rebuild instead of trusting stale zsets). The zsets themselves are NEVER
77
+ * deleted at init — this store cannot know whether an enabled sibling is
78
+ * serving reads from them. After disabling everywhere, reclaim the memory
79
+ * manually, e.g.:
80
+ * `redis-cli --scan --pattern '<prefix>:byname:*' | xargs redis-cli del`.
81
+ *
82
+ * @since 0.7.0
83
+ */
84
+ readonly indexes?: { readonly name?: boolean | undefined; readonly queue?: boolean | undefined } | false | undefined
85
+ }
86
+
87
+ /**
88
+ * A `list` query routed to a list index this store is configured not to
89
+ * maintain (`RedisJobStoreOptions.indexes`). Delivered as a defect, not a
90
+ * typed failure: the configuration said "we never list this way", so the
91
+ * query contradicting it is a programming mistake, matching the library's
92
+ * die-on-config-mistake idiom.
93
+ *
94
+ * @since 0.7.0
95
+ */
96
+ export class ListIndexDisabledError extends Data.TaggedError("ListIndexDisabledError")<{
97
+ readonly index: "name" | "queue"
98
+ readonly message: string
99
+ }> {}
100
+
101
+ const TERMINAL_STATES: ReadonlySet<JobStore.JobState> = new Set(["completed", "failed", "cancelled"])
102
+
103
+ /**
104
+ * The `list` predicates the routed structure does NOT pin, applied per row
105
+ * inside the list script. Field names are part of the script's contract.
106
+ */
107
+ interface ResidualFilters {
108
+ readonly queue?: JobStore.QueueName | undefined
109
+ readonly name?: string | undefined
110
+ readonly states?: ReadonlyArray<JobStore.JobState> | undefined
111
+ readonly metadata?: Readonly<Record<string, string>> | undefined
40
112
  }
41
113
 
42
114
  const storeError = (message: string) => (cause: unknown) => new JobStore.JobStoreError({ message, cause })
@@ -202,34 +274,104 @@ export const make = (
202
274
  const redis = yield* Redis.Redis
203
275
  const prefix = options?.prefix ?? "effect-mq"
204
276
  const wakeChannel = `${prefix}:wake`
277
+ const indexOptions = options?.indexes
278
+ const indexes: scripts.IndexConfig = indexOptions === false
279
+ ? { name: false, queue: false }
280
+ : { name: indexOptions?.name ?? true, queue: indexOptions?.queue ?? true }
281
+ const HELPERS = scripts.helpers(indexes)
282
+
283
+ const evalEnqueue = redis.eval(scripts.enqueue(HELPERS))
284
+ const evalClaim = redis.eval(scripts.claim(HELPERS))
285
+ const evalAck = redis.eval(scripts.ack(HELPERS))
286
+ const evalRelease = redis.eval(scripts.release(HELPERS))
287
+ const evalExtendLocks = redis.eval(scripts.extendLocks(HELPERS))
288
+ const evalRecoverStalled = redis.eval(scripts.recoverStalled(HELPERS))
289
+ const evalGetJob = redis.eval(scripts.getJob(HELPERS))
290
+ const evalList = redis.eval(scripts.list(HELPERS))
291
+ const evalIndexMembers = redis.eval(scripts.indexMembers(HELPERS))
292
+ const evalIndexTailPage = redis.eval(scripts.indexTailPage(HELPERS))
293
+ const evalCounts = redis.eval(scripts.counts(HELPERS))
294
+ const evalRemove = redis.eval(scripts.remove(HELPERS))
295
+ const evalRetry = redis.eval(scripts.retry(HELPERS))
296
+ const evalCancel = redis.eval(scripts.cancel(HELPERS))
297
+ const evalPromote = redis.eval(scripts.promote(HELPERS))
298
+ const evalUpsertSchedule = redis.eval(scripts.upsertSchedule(HELPERS))
299
+ const evalRemoveSchedule = redis.eval(scripts.removeSchedule(HELPERS))
300
+ const evalListSchedules = redis.eval(scripts.listSchedules(HELPERS))
301
+ const evalDueSchedules = redis.eval(scripts.dueSchedules(HELPERS))
302
+ const evalAdvanceSchedule = redis.eval(scripts.advanceSchedule(HELPERS))
303
+ const evalTickSchedule = redis.eval(scripts.tickSchedule(HELPERS))
304
+ const evalEnqueueMany = redis.eval(scripts.enqueueMany(HELPERS))
305
+ const evalSweepState = redis.eval(scripts.sweepState(HELPERS))
306
+ const evalSweepDedupes = redis.eval(scripts.sweepDedupes(HELPERS))
307
+ const evalFanOut = redis.eval(scripts.fanOut(HELPERS))
308
+ const evalRecordChildResults = redis.eval(scripts.recordChildResults(HELPERS))
309
+ const evalListChildResults = redis.eval(scripts.listChildResults(HELPERS))
310
+ const evalFlowSweepWork = redis.eval(scripts.flowSweepWork(HELPERS))
311
+ const evalMarkChildrenCascaded = redis.eval(scripts.markChildrenCascaded(HELPERS))
205
312
 
206
- const evalEnqueue = redis.eval(scripts.enqueue)
207
- const evalClaim = redis.eval(scripts.claim)
208
- const evalAck = redis.eval(scripts.ack)
209
- const evalRelease = redis.eval(scripts.release)
210
- const evalExtendLocks = redis.eval(scripts.extendLocks)
211
- const evalRecoverStalled = redis.eval(scripts.recoverStalled)
212
- const evalGetJob = redis.eval(scripts.getJob)
213
- const evalList = redis.eval(scripts.list)
214
- const evalCounts = redis.eval(scripts.counts)
215
- const evalRemove = redis.eval(scripts.remove)
216
- const evalRetry = redis.eval(scripts.retry)
217
- const evalCancel = redis.eval(scripts.cancel)
218
- const evalPromote = redis.eval(scripts.promote)
219
- const evalUpsertSchedule = redis.eval(scripts.upsertSchedule)
220
- const evalRemoveSchedule = redis.eval(scripts.removeSchedule)
221
- const evalListSchedules = redis.eval(scripts.listSchedules)
222
- const evalDueSchedules = redis.eval(scripts.dueSchedules)
223
- const evalAdvanceSchedule = redis.eval(scripts.advanceSchedule)
224
- const evalTickSchedule = redis.eval(scripts.tickSchedule)
225
- const evalEnqueueMany = redis.eval(scripts.enqueueMany)
226
- const evalSweepState = redis.eval(scripts.sweepState)
227
- const evalSweepDedupes = redis.eval(scripts.sweepDedupes)
228
- const evalFanOut = redis.eval(scripts.fanOut)
229
- const evalRecordChildResults = redis.eval(scripts.recordChildResults)
230
- const evalListChildResults = redis.eval(scripts.listChildResults)
231
- const evalFlowSweepWork = redis.eval(scripts.flowSweepWork)
232
- const evalMarkChildrenCascaded = redis.eval(scripts.markChildrenCascaded)
313
+ // List-index reconcile, once per boot per index. All work is driver-paged
314
+ // never one giant Lua call — so the single-threaded server is never
315
+ // held. There is no build lock: concurrent boots duplicate idempotent
316
+ // ZADDs, a crash before the marker stamp makes the next boot redo the
317
+ // work, and rows inserted meanwhile are indexed live by insertJobRow.
318
+ //
319
+ // - enabled, no marker: full rebuild via ZSCAN over `all` (cursor-based
320
+ // and linear immune to score ties and rank shifts; every member
321
+ // present for the whole scan is guaranteed returned). Rows deleted
322
+ // mid-scan leave at most stale members the read path self-heals.
323
+ // - enabled, marker present: heal the tail. Index writes are per-process,
324
+ // so writers without them (an older version mid-rolling-deploy, or a
325
+ // misconfigured indexes-off store) may have inserted unindexed rows
326
+ // AFTER the marker landed. Re-indexing everything enqueued since the
327
+ // marker minus a 60s margin closes that window: the last enabled boot
328
+ // after such writers stop covers everything they wrote before it.
329
+ // - disabled: delete ONLY the marker (a later re-enable must not trust
330
+ // stale zsets). The zsets stay — an enabled sibling may be reading
331
+ // them, and this store cannot know.
332
+ //
333
+ // Both paths re-stamp the marker with this boot's start time. Init-time
334
+ // infra failures die — the store never starts half-configured.
335
+ yield* Effect.gen(function*() {
336
+ const bootAt = yield* Clock.currentTimeMillis
337
+ for (const kind of ["name", "queue"] as const) {
338
+ const marker = `${prefix}:index:${kind}:ready`
339
+ if (!indexes[kind]) {
340
+ yield* redis.send("DEL", marker)
341
+ continue
342
+ }
343
+ // SAFETY: GET always replies with a bulk string or null.
344
+ const stamped = (yield* redis.send("GET", marker)) as string | null
345
+ const markerAt = stamped === null || stamped === "" ? Number.NaN : Number(stamped)
346
+ if (Number.isNaN(markerAt)) {
347
+ let cursor = "0"
348
+ do {
349
+ const reply = yield* redis.send("ZSCAN", `${prefix}:all`, cursor, "COUNT", "500")
350
+ // SAFETY: ZSCAN always replies [nextCursor, member/score pairs].
351
+ const [next, flat] = reply as [string, ReadonlyArray<string>]
352
+ cursor = next
353
+ const ids: Array<string> = []
354
+ for (let i = 0; i < flat.length; i += 2) {
355
+ const id = flat[i]
356
+ if (id !== undefined) ids.push(id)
357
+ }
358
+ // COUNT is only a hint — chunk what actually came back.
359
+ for (let start = 0; start < ids.length; start += 500) {
360
+ yield* evalIndexMembers(prefix, kind, JSON.stringify(ids.slice(start, start + 500)))
361
+ }
362
+ } while (cursor !== "0")
363
+ } else {
364
+ const min = String(markerAt - 60_000)
365
+ let offset = 0
366
+ while (true) {
367
+ const scanned = Number(yield* evalIndexTailPage(prefix, kind, min, offset, 500))
368
+ if (scanned < 500) break
369
+ offset += scanned
370
+ }
371
+ }
372
+ yield* redis.send("SET", marker, String(bootAt))
373
+ }
374
+ }).pipe(Effect.orDie)
233
375
 
234
376
  // Wake protocol: a queue-filtered waiter registry (same-process wake-ups
235
377
  // never depend on the pub/sub round trip), with the channel carrying
@@ -756,24 +898,102 @@ export const make = (
756
898
  list: (listOptions) =>
757
899
  Effect.gen(function*() {
758
900
  const limit = Math.max(1, listOptions.limit ?? 50)
759
- const filters = {
760
- queue: listOptions.queue,
761
- name: listOptions.name,
762
- states: listOptions.states,
763
- metadata: listOptions.metadata
901
+ const { metadata, name, queue, states } = listOptions
902
+ const orderBy = listOptions.orderBy ?? "enqueuedAt"
903
+ const order = listOptions.order ?? "desc"
904
+ // Routing: the narrowest structure whose zset score IS the
905
+ // requested order value; everything it does not pin stays a
906
+ // residual predicate applied in-script. Routing runs BEFORE the
907
+ // disabled-index check — a query another structure serves (e.g.
908
+ // name + terminal states ordered by finishedAt) must never die.
909
+ let sources: ReadonlyArray<string>
910
+ let residual: ResidualFilters
911
+ if (orderBy === "finishedAt") {
912
+ if (states === undefined || states.some((state) => !TERMINAL_STATES.has(state))) {
913
+ return yield* Effect.die(
914
+ new JobStore.ListOrderUnsupportedError({
915
+ orderBy,
916
+ message: "effect-mq: the Redis store serves orderBy \"finishedAt\" only with " +
917
+ "states ⊆ {completed, failed, cancelled} (the finished/terminal zsets carry that order)"
918
+ })
919
+ )
920
+ }
921
+ // ≤3 per-state sources, merged by (finishedAt, id) in-script.
922
+ const uniqueStates = [...new Set(states)]
923
+ sources = name === undefined
924
+ ? uniqueStates.map((state) => `${prefix}:finished:${state}`)
925
+ : uniqueStates.map((state) => `${prefix}:terminal:${name}:${state}`)
926
+ residual = { queue, metadata }
927
+ } else if (orderBy === "runAt") {
928
+ const onlyDelayed = states !== undefined && states.length > 0 &&
929
+ states.every((state) => state === "delayed")
930
+ if (queue === undefined || !onlyDelayed) {
931
+ return yield* Effect.die(
932
+ new JobStore.ListOrderUnsupportedError({
933
+ orderBy,
934
+ message: "effect-mq: the Redis store serves orderBy \"runAt\" only with " +
935
+ "states: [\"delayed\"] and a queue (the delayed:<queue> zset carries that order)"
936
+ })
937
+ )
938
+ }
939
+ sources = [`${prefix}:delayed:${queue}`]
940
+ residual = { name, metadata }
941
+ } else if (name !== undefined) {
942
+ if (!indexes.name) {
943
+ return yield* Effect.die(
944
+ new ListIndexDisabledError({
945
+ index: "name",
946
+ message: "effect-mq: list({ name }) routes to the byname index, " +
947
+ "but RedisJobStoreOptions.indexes.name is disabled for this store"
948
+ })
949
+ )
950
+ }
951
+ sources = [`${prefix}:byname:${name}`]
952
+ residual = { queue, states, metadata }
953
+ } else if (queue !== undefined) {
954
+ if (!indexes.queue) {
955
+ return yield* Effect.die(
956
+ new ListIndexDisabledError({
957
+ index: "queue",
958
+ message: "effect-mq: list({ queue }) routes to the byqueue index, " +
959
+ "but RedisJobStoreOptions.indexes.queue is disabled for this store"
960
+ })
961
+ )
962
+ }
963
+ sources = [`${prefix}:byqueue:${queue}`]
964
+ residual = { states, metadata }
965
+ } else {
966
+ sources = [`${prefix}:all`]
967
+ residual = { states, metadata }
764
968
  }
765
969
  const reply: { items: ReadonlyArray<ReadonlyArray<string>> | Record<string, never>; more: boolean } = JSON
766
970
  .parse(
767
- yield* evalList(prefix, JSON.stringify(filters), listOptions.cursor ?? "", limit)
971
+ yield* evalList(
972
+ prefix,
973
+ JSON.stringify(sources),
974
+ order,
975
+ JSON.stringify(residual),
976
+ listOptions.cursor ?? "",
977
+ limit
978
+ ).pipe(Effect.mapError(storeError("list failed")))
768
979
  )
769
980
  const items = asArray(reply.items).map((flat) => toRecord(foldPairs(flat)))
770
981
  const last = items[items.length - 1]
982
+ // The cursor value is the order field, which equals the routed
983
+ // zset's score for every row the script returned.
984
+ const orderValue = last === undefined
985
+ ? 0
986
+ : orderBy === "enqueuedAt"
987
+ ? last.enqueuedAt
988
+ : orderBy === "runAt"
989
+ ? last.runAt
990
+ : last.finishedAt ?? 0
771
991
  const result: JobStore.ListResult = {
772
992
  items,
773
- cursor: reply.more && last !== undefined ? `${last.enqueuedAt}:${last.id}` : undefined
993
+ cursor: reply.more && last !== undefined ? `${orderValue}:${last.id}` : undefined
774
994
  }
775
995
  return result
776
- }).pipe(Effect.mapError(storeError("list failed"))),
996
+ }),
777
997
 
778
998
  retry: (id) =>
779
999
  Effect.gen(function*() {