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 @@
1
+ {"version":3,"file":"schema.d.ts","sourceRoot":"","sources":["../../src/drizzle-postgres/schema.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,150 @@
1
+ import { sql } from "drizzle-orm";
2
+ import { bigint, boolean, index, integer, jsonb, pgTable, primaryKey, text, timestamp } from "drizzle-orm/pg-core";
3
+ const jobsColumns = () => ({
4
+ id: text("id").primaryKey().$type(),
5
+ name: text("name").notNull().$type(),
6
+ queue: text("queue").notNull().$type(),
7
+ state: text("state").notNull().$type(),
8
+ priority: integer("priority").notNull().default(0),
9
+ /** FIFO order within a priority; bumped on retry so retries go to the tail. */
10
+ seq: bigint("seq", { mode: "number" }).notNull().generatedByDefaultAsIdentity(),
11
+ payload: jsonb("payload"),
12
+ metadata: jsonb("metadata").notNull().default({}).$type(),
13
+ attemptsMax: integer("attempts_max").notNull(),
14
+ attemptsMade: integer("attempts_made").notNull().default(0),
15
+ stalledCount: integer("stalled_count").notNull().default(0),
16
+ backoff: jsonb("backoff").$type(),
17
+ keep: jsonb("keep").$type(),
18
+ timeoutMs: bigint("timeout_ms", { mode: "number" }),
19
+ cancelRequested: boolean("cancel_requested").notNull().default(false),
20
+ dedupeKey: text("dedupe_key"),
21
+ runAt: timestamp("run_at", { withTimezone: true, mode: "date" }).notNull(),
22
+ enqueuedAt: timestamp("enqueued_at", { withTimezone: true, mode: "date" }).notNull(),
23
+ processedAt: timestamp("processed_at", { withTimezone: true, mode: "date" }),
24
+ finishedAt: timestamp("finished_at", { withTimezone: true, mode: "date" }),
25
+ exit: jsonb("exit"),
26
+ failedReason: text("failed_reason"),
27
+ lockToken: text("lock_token"),
28
+ lockExpiresAt: timestamp("lock_expires_at", { withTimezone: true, mode: "date" })
29
+ });
30
+ /**
31
+ * The jobs table factory. `JobName` types the `name` column — derive it from
32
+ * your job definitions: `mqJobs<typeof GenerateInvoice._tag | typeof Report._tag>()`.
33
+ *
34
+ * `extend` adds your own columns (tenant ids, object ids, ...) to the table.
35
+ * At enqueue the Postgres store fills each extended column from the job's
36
+ * `metadata` entry with the same TS key (NULL when absent); override the
37
+ * mapping with the store's `extraValues` option. Extended columns are yours
38
+ * to read with plain drizzle queries — FKs, RLS policies, and `extraConfig`
39
+ * indexes all work:
40
+ *
41
+ * ```ts
42
+ * export const jobs = mqJobs<JobNames>("effect_mq_jobs", {
43
+ * extend: {
44
+ * companyId: text("company_id").notNull(),
45
+ * objectId: text("object_id")
46
+ * },
47
+ * extraConfig: (t) => [index("jobs_company_idx").on(t.companyId, t.state)]
48
+ * })
49
+ * ```
50
+ *
51
+ * @since 0.1.0
52
+ */
53
+ export const mqJobs = (tableName = "effect_mq_jobs", options) => pgTable(tableName, {
54
+ ...jobsColumns(),
55
+ // SAFETY: when `extend` is absent, `Extend` was never inferred from a
56
+ // value and stays at its empty-record default, which {} satisfies.
57
+ ...options?.extend ?? {}
58
+ }, (table) => [
59
+ // Claim path: pop highest priority, FIFO within it.
60
+ index(`${tableName}_ready_idx`)
61
+ .on(table.queue, table.priority.desc(), table.seq.asc())
62
+ .where(sql `${table.state} = 'waiting'`),
63
+ // Delayed promotion + nextRunAt.
64
+ index(`${tableName}_delayed_idx`)
65
+ .on(table.queue, table.runAt)
66
+ .where(sql `${table.state} = 'delayed'`),
67
+ // Stalled sweep.
68
+ index(`${tableName}_active_idx`)
69
+ .on(table.lockExpiresAt)
70
+ .where(sql `${table.state} = 'active'`),
71
+ // History/retention queries (leading `name` also serves name-only filters).
72
+ index(`${tableName}_history_idx`).on(table.name, table.state, table.finishedAt),
73
+ // Listing (newest first, keyset pagination).
74
+ index(`${tableName}_listing_idx`).on(table.enqueuedAt.desc(), table.id.desc()),
75
+ // Metadata containment queries.
76
+ index(`${tableName}_metadata_idx`).using("gin", table.metadata.op("jsonb_path_ops")),
77
+ ...options?.extraConfig?.(table) ?? []
78
+ ]);
79
+ const attemptsColumns = (jobs) => ({
80
+ jobId: text("job_id").notNull().references(() => jobs.id, { onDelete: "cascade" }).$type(),
81
+ attempt: integer("attempt").notNull(),
82
+ outcome: text("outcome").notNull().$type(),
83
+ startedAt: timestamp("started_at", { withTimezone: true, mode: "date" }),
84
+ finishedAt: timestamp("finished_at", { withTimezone: true, mode: "date" }).notNull(),
85
+ exit: jsonb("exit")
86
+ });
87
+ /**
88
+ * The job run-ledger table factory (one row per attempt, including
89
+ * successes and stall recoveries).
90
+ *
91
+ * @since 0.1.0
92
+ */
93
+ export const mqJobAttempts = (jobs, tableName = "effect_mq_job_attempts", options) => pgTable(tableName, attemptsColumns(jobs), (table) => [
94
+ primaryKey({ columns: [table.jobId, table.attempt] }),
95
+ ...options?.extraConfig?.(table) ?? []
96
+ ]);
97
+ const schedulesColumns = () => ({
98
+ key: text("key").primaryKey().$type(),
99
+ jobName: text("job_name").notNull(),
100
+ queue: text("queue").notNull().$type(),
101
+ cron: text("cron"),
102
+ tz: text("tz"),
103
+ everyMs: bigint("every_ms", { mode: "number" }),
104
+ payload: jsonb("payload"),
105
+ metadata: jsonb("metadata").notNull().default({}).$type(),
106
+ priority: integer("priority").notNull().default(0),
107
+ attemptsMax: integer("attempts_max").notNull(),
108
+ backoff: jsonb("backoff").$type(),
109
+ keep: jsonb("keep").$type(),
110
+ timeoutMs: bigint("timeout_ms", { mode: "number" }),
111
+ nextRunAt: timestamp("next_run_at", { withTimezone: true, mode: "date" }).notNull()
112
+ });
113
+ /**
114
+ * Repeatable-job schedules (one row per `Job.schedule` key).
115
+ *
116
+ * @since 0.2.0
117
+ */
118
+ export const mqSchedules = (tableName = "effect_mq_schedules", options) => pgTable(tableName, schedulesColumns(), (table) => [
119
+ index(`${tableName}_due_idx`).on(table.nextRunAt),
120
+ ...options?.extraConfig?.(table) ?? []
121
+ ]);
122
+ const dedupeColumns = () => ({
123
+ name: text("name").notNull(),
124
+ key: text("key").notNull(),
125
+ jobId: text("job_id").notNull().$type(),
126
+ /** Set for ttl/throttle windows; NULL rows live as long as their job is pending. */
127
+ windowExpiresAt: timestamp("window_expires_at", { withTimezone: true, mode: "date" })
128
+ });
129
+ /**
130
+ * Dedup-key registry (one row per job name + dedup key; see `DedupePolicy`).
131
+ *
132
+ * @since 0.3.0
133
+ */
134
+ export const mqDedupe = (tableName = "effect_mq_dedupe", options) => pgTable(tableName, dedupeColumns(), (table) => [
135
+ primaryKey({ columns: [table.name, table.key] }),
136
+ ...options?.extraConfig?.(table) ?? []
137
+ ]);
138
+ const queueControlColumns = () => ({
139
+ queue: text("queue").primaryKey().$type(),
140
+ paused: boolean("paused").notNull().default(false)
141
+ });
142
+ /**
143
+ * Durable queue control flags (pause/resume).
144
+ *
145
+ * @since 0.2.0
146
+ */
147
+ export const mqQueueControl = (tableName = "effect_mq_queue_control", options) => pgTable(tableName, queueControlColumns(), (table) => [
148
+ ...options?.extraConfig?.(table) ?? []
149
+ ]);
150
+ //# sourceMappingURL=schema.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"schema.js","sourceRoot":"","sources":["../../src/drizzle-postgres/schema.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,58 @@
1
+ /**
2
+ * A Redis-backed `JobStore` built on `effect/unstable/persistence`'s `Redis`
3
+ * service. Every mutation is a single Lua script (see `scripts.ts`), so all
4
+ * `JobStore` operations are atomic on the server and safe across processes.
5
+ *
6
+ * Provide the `Redis` service from your platform package — `NodeRedis.layer`
7
+ * (`@effect/platform-node`, node-redis), `BunRedis.layer`
8
+ * (`@effect/platform-bun`, `Bun.redis`) — or `Redis.make` over any client.
9
+ *
10
+ * Wake-ups ride the client's pub/sub channel, so workers in other processes
11
+ * pick jobs up promptly; the worker's `pollInterval` is the fallback.
12
+ *
13
+ * @since 0.2.0
14
+ */
15
+ import { type Context, Duration, Effect, Layer, type Scope } from "effect";
16
+ import { Redis } from "effect/unstable/persistence";
17
+ import * as JobStore from "../JobStore.ts";
18
+ /**
19
+ * @since 0.2.0
20
+ */
21
+ export interface RedisJobStoreOptions {
22
+ /** Key prefix for everything this store writes (default `effect-mq`). */
23
+ readonly prefix?: string | undefined;
24
+ /**
25
+ * Store-level retention ceiling: terminal records older than this are
26
+ * removed by a periodic sweep — one duration for all terminal states or a
27
+ * per-state split (`{ completed: "1 day", failed: "30 days" }`). The sweep
28
+ * also honours stricter per-job `keep.age` rules.
29
+ */
30
+ readonly historyTtl?: JobStore.HistoryTtlInput | undefined;
31
+ /** History sweep cadence (default 1 minute). */
32
+ readonly historySweepInterval?: Duration.Input | undefined;
33
+ /**
34
+ * Generator for store-assigned job ids (e.g. `() => \`job_${ulid()}\``).
35
+ * Default: `j-<n>` from the store's counter. See `JobStore.IdGenerator`.
36
+ */
37
+ readonly idGenerator?: JobStore.IdGenerator | undefined;
38
+ }
39
+ /**
40
+ * Build a `RedisJobStore` service. Needs the `Redis` service and a `Scope`
41
+ * (the wake-up subscription and the optional history sweeper live in it).
42
+ *
43
+ * @since 0.2.0
44
+ */
45
+ export declare const make: (options?: RedisJobStoreOptions | undefined) => Effect.Effect<JobStore.Service, never, Redis.Redis | Scope.Scope>;
46
+ /**
47
+ * A Redis-backed layer for the default `JobStore`.
48
+ *
49
+ * @since 0.2.0
50
+ */
51
+ export declare const layer: (options?: RedisJobStoreOptions | undefined) => Layer.Layer<JobStore.JobStore, never, Redis.Redis>;
52
+ /**
53
+ * A Redis-backed layer for a specific named store key.
54
+ *
55
+ * @since 0.2.0
56
+ */
57
+ export declare const layerFor: <StoreId>(store: Context.Key<StoreId, JobStore.Service>, options?: RedisJobStoreOptions | undefined) => Layer.Layer<StoreId, never, Redis.Redis>;
58
+ //# sourceMappingURL=RedisJobStore.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"RedisJobStore.d.ts","sourceRoot":"","sources":["../../src/redis/RedisJobStore.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;GAaG;AACH,OAAO,EAAS,KAAK,OAAO,EAAY,QAAQ,EAAE,MAAM,EAAQ,KAAK,EAA2B,KAAK,KAAK,EAAE,MAAM,QAAQ,CAAA;AAC1H,OAAO,EAAE,KAAK,EAAE,MAAM,6BAA6B,CAAA;AACnD,OAAO,KAAK,QAAQ,MAAM,gBAAgB,CAAA;AAG1C;;GAEG;AACH,MAAM,WAAW,oBAAoB;IACnC,yEAAyE;IACzE,QAAQ,CAAC,MAAM,CAAC,EAAE,MAAM,GAAG,SAAS,CAAA;IACpC;;;;;OAKG;IACH,QAAQ,CAAC,UAAU,CAAC,EAAE,QAAQ,CAAC,eAAe,GAAG,SAAS,CAAA;IAC1D,gDAAgD;IAChD,QAAQ,CAAC,oBAAoB,CAAC,EAAE,QAAQ,CAAC,KAAK,GAAG,SAAS,CAAA;IAC1D;;;OAGG;IACH,QAAQ,CAAC,WAAW,CAAC,EAAE,QAAQ,CAAC,WAAW,GAAG,SAAS,CAAA;CACxD;AAqFD;;;;;GAKG;AACH,eAAO,MAAM,IAAI,aACL,oBAAoB,GAAG,SAAS,KACzC,MAAM,CAAC,MAAM,CAAC,QAAQ,CAAC,OAAO,EAAE,KAAK,EAAE,KAAK,CAAC,KAAK,GAAG,KAAK,CAAC,KAAK,CAof/D,CAAA;AAEJ;;;;GAIG;AACH,eAAO,MAAM,KAAK,aACN,oBAAoB,GAAG,SAAS,KACzC,KAAK,CAAC,KAAK,CAAC,QAAQ,CAAC,QAAQ,EAAE,KAAK,EAAE,KAAK,CAAC,KAAK,CAAmD,CAAA;AAEvG;;;;GAIG;AACH,eAAO,MAAM,QAAQ,GAAI,OAAO,SACvB,OAAO,CAAC,GAAG,CAAC,OAAO,EAAE,QAAQ,CAAC,OAAO,CAAC,YACnC,oBAAoB,GAAG,SAAS,KACzC,KAAK,CAAC,KAAK,CAAC,OAAO,EAAE,KAAK,EAAE,KAAK,CAAC,KAAK,CAAuC,CAAA"}
@@ -0,0 +1,424 @@
1
+ /**
2
+ * A Redis-backed `JobStore` built on `effect/unstable/persistence`'s `Redis`
3
+ * service. Every mutation is a single Lua script (see `scripts.ts`), so all
4
+ * `JobStore` operations are atomic on the server and safe across processes.
5
+ *
6
+ * Provide the `Redis` service from your platform package — `NodeRedis.layer`
7
+ * (`@effect/platform-node`, node-redis), `BunRedis.layer`
8
+ * (`@effect/platform-bun`, `Bun.redis`) — or `Redis.make` over any client.
9
+ *
10
+ * Wake-ups ride the client's pub/sub channel, so workers in other processes
11
+ * pick jobs up promptly; the worker's `pollInterval` is the fallback.
12
+ *
13
+ * @since 0.2.0
14
+ */
15
+ import { Clock, Deferred, Duration, Effect, Exit, Layer, Option, Queue, Schedule } from "effect";
16
+ import { Redis } from "effect/unstable/persistence";
17
+ import * as JobStore from "../JobStore.js";
18
+ import * as scripts from "./scripts.js";
19
+ const storeError = (message) => (cause) => new JobStore.JobStoreError({ message, cause });
20
+ /** Fold a Lua `HGETALL` reply (flat `[field, value, ...]`) into a map. */
21
+ const foldPairs = (flat) => {
22
+ const out = new Map();
23
+ for (let i = 0; i + 1 < flat.length; i += 2) {
24
+ const field = flat[i];
25
+ const value = flat[i + 1];
26
+ if (field !== undefined && value !== undefined) {
27
+ out.set(field, value);
28
+ }
29
+ }
30
+ return out;
31
+ };
32
+ // Hash fields use "" for absent optional values.
33
+ const optionalString = (value) => value === undefined || value === "" ? undefined : value;
34
+ const optionalNumber = (value) => value === undefined || value === "" ? undefined : Number(value);
35
+ const optionalJson = (value) => {
36
+ if (value === undefined || value === "")
37
+ return undefined;
38
+ // SAFETY: the value round-trips JSON this driver itself wrote for the field.
39
+ return JSON.parse(value);
40
+ };
41
+ const toRecord = (hash) => ({
42
+ id: JobStore.JobId(hash.get("id") ?? ""),
43
+ name: hash.get("name") ?? "",
44
+ queue: JobStore.QueueName(hash.get("queue") ?? ""),
45
+ payload: optionalJson(hash.get("payload")) ?? null,
46
+ metadata: optionalJson(hash.get("metadata")) ?? {},
47
+ // SAFETY: the state field is only ever written with JobState members.
48
+ state: (hash.get("state") ?? "waiting"),
49
+ priority: Number(hash.get("priority") ?? 0),
50
+ attemptsMax: Number(hash.get("attemptsMax") ?? 1),
51
+ attemptsMade: Number(hash.get("attemptsMade") ?? 0),
52
+ stalledCount: Number(hash.get("stalledCount") ?? 0),
53
+ backoff: optionalJson(hash.get("backoff")),
54
+ keep: optionalJson(hash.get("keep")),
55
+ timeoutMs: optionalNumber(hash.get("timeoutMs")),
56
+ cancelRequested: hash.get("cancelRequested") === "1",
57
+ dedupeKey: optionalString(hash.get("dedupeKey")),
58
+ runAt: Number(hash.get("runAt") ?? 0),
59
+ enqueuedAt: Number(hash.get("enqueuedAt") ?? 0),
60
+ processedAt: optionalNumber(hash.get("processedAt")),
61
+ finishedAt: optionalNumber(hash.get("finishedAt")),
62
+ exit: optionalJson(hash.get("exit")),
63
+ failedReason: optionalString(hash.get("failedReason"))
64
+ });
65
+ const toSchedule = (hash) => ({
66
+ key: JobStore.ScheduleKey(hash.get("key") ?? ""),
67
+ jobName: hash.get("jobName") ?? "",
68
+ queue: JobStore.QueueName(hash.get("queue") ?? ""),
69
+ cron: optionalString(hash.get("cron")),
70
+ tz: optionalString(hash.get("tz")),
71
+ everyMs: optionalNumber(hash.get("everyMs")),
72
+ payload: optionalJson(hash.get("payload")),
73
+ metadata: optionalJson(hash.get("metadata")) ?? {},
74
+ priority: Number(hash.get("priority") ?? 0),
75
+ attemptsMax: Number(hash.get("attemptsMax") ?? 1),
76
+ backoff: optionalJson(hash.get("backoff")),
77
+ keep: optionalJson(hash.get("keep")),
78
+ timeoutMs: optionalNumber(hash.get("timeoutMs")),
79
+ nextRunAt: Number(hash.get("nextRunAt") ?? 0)
80
+ });
81
+ // cjson encodes an empty Lua table as {}, not [] — normalize.
82
+ const asArray = (value) => Array.isArray(value) ? value : [];
83
+ const JOB_STATES = [
84
+ "waiting",
85
+ "delayed",
86
+ "active",
87
+ "completed",
88
+ "failed",
89
+ "cancelled"
90
+ ];
91
+ /**
92
+ * Build a `RedisJobStore` service. Needs the `Redis` service and a `Scope`
93
+ * (the wake-up subscription and the optional history sweeper live in it).
94
+ *
95
+ * @since 0.2.0
96
+ */
97
+ export const make = (options) => Effect.gen(function* () {
98
+ const redis = yield* Redis.Redis;
99
+ const prefix = options?.prefix ?? "effect-mq";
100
+ const wakeChannel = `${prefix}:wake`;
101
+ const evalEnqueue = redis.eval(scripts.enqueue);
102
+ const evalClaim = redis.eval(scripts.claim);
103
+ const evalAck = redis.eval(scripts.ack);
104
+ const evalRelease = redis.eval(scripts.release);
105
+ const evalExtendLocks = redis.eval(scripts.extendLocks);
106
+ const evalRecoverStalled = redis.eval(scripts.recoverStalled);
107
+ const evalGetJob = redis.eval(scripts.getJob);
108
+ const evalList = redis.eval(scripts.list);
109
+ const evalCounts = redis.eval(scripts.counts);
110
+ const evalRemove = redis.eval(scripts.remove);
111
+ const evalRetry = redis.eval(scripts.retry);
112
+ const evalCancel = redis.eval(scripts.cancel);
113
+ const evalPromote = redis.eval(scripts.promote);
114
+ const evalUpsertSchedule = redis.eval(scripts.upsertSchedule);
115
+ const evalRemoveSchedule = redis.eval(scripts.removeSchedule);
116
+ const evalListSchedules = redis.eval(scripts.listSchedules);
117
+ const evalDueSchedules = redis.eval(scripts.dueSchedules);
118
+ const evalAdvanceSchedule = redis.eval(scripts.advanceSchedule);
119
+ const evalSweepState = redis.eval(scripts.sweepState);
120
+ const evalSweepDedupes = redis.eval(scripts.sweepDedupes);
121
+ // Wake protocol: a queue-filtered waiter registry (same-process wake-ups
122
+ // never depend on the pub/sub round trip), with the channel carrying
123
+ // cross-process wake-ups — the message names the queue ("*" broadcasts).
124
+ // Filtering matters at scale: without it every enqueue wakes every idle
125
+ // taker of every queue on the store.
126
+ let wakeVersion = 0;
127
+ let lastBroadcast = 0;
128
+ const lastWake = new Map();
129
+ const waiters = new Set();
130
+ const lastWakeFor = (queue) => Math.max(lastWake.get(queue) ?? 0, lastBroadcast);
131
+ const signalWakeLocal = (queue) => {
132
+ wakeVersion += 1;
133
+ if (queue === undefined) {
134
+ lastBroadcast = wakeVersion;
135
+ }
136
+ else {
137
+ lastWake.set(queue, wakeVersion);
138
+ }
139
+ // Snapshot-and-clear BEFORE resolving: doneUnsafe resumes waiting
140
+ // fibers synchronously, and a woken taker that re-parks registers a
141
+ // NEW waiter — resolving inside the live Set iteration would visit it
142
+ // and livelock.
143
+ const toWake = [];
144
+ for (const waiter of waiters) {
145
+ if (queue === undefined || waiter.queues.has(queue)) {
146
+ waiters.delete(waiter);
147
+ toWake.push(waiter);
148
+ }
149
+ }
150
+ for (const waiter of toWake) {
151
+ Deferred.doneUnsafe(waiter.deferred, Exit.succeed(void 0));
152
+ }
153
+ };
154
+ const wakeUp = (queue) => Effect.suspend(() => {
155
+ signalWakeLocal(queue);
156
+ return redis.send("PUBLISH", wakeChannel, queue !== undefined && queue.length > 0 ? queue : "*").pipe(Effect.ignore);
157
+ });
158
+ // Cross-process wake-ups. The pump resubscribes on connection loss (Bun
159
+ // subscribers do not auto-reconnect); the retry delay only ever runs
160
+ // after a real failure, so TestClock runs are unaffected.
161
+ yield* Effect.scoped(Effect.gen(function* () {
162
+ const messages = yield* redis.subscribe(wakeChannel);
163
+ while (true) {
164
+ const message = yield* Queue.take(messages);
165
+ signalWakeLocal(message.message === "*" ? undefined : JobStore.QueueName(message.message));
166
+ }
167
+ })).pipe(Effect.retry(Schedule.spaced("1 second")), Effect.catchCause((cause) => Effect.logWarning("effect-mq: redis wake subscription failed", cause)), Effect.forkScoped);
168
+ if (options?.historyTtl !== undefined) {
169
+ const ttlByState = JobStore.normalizeHistoryTtl(options.historyTtl);
170
+ const intervalMs = Duration.toMillis(options.historySweepInterval ?? "1 minute");
171
+ yield* Effect.gen(function* () {
172
+ yield* Effect.sleep(intervalMs);
173
+ const now = yield* Clock.currentTimeMillis;
174
+ // Per-state ceilings refined by stricter per-row keep ages, paged by
175
+ // an offset cursor so young rows are visited once per sweep.
176
+ for (const state of ["completed", "failed", "cancelled"]) {
177
+ const ttl = ttlByState[state];
178
+ let offset = 0;
179
+ while (true) {
180
+ const page = JSON.parse(yield* evalSweepState(prefix, state, ttl === undefined ? "" : String(ttl), 200, offset, now));
181
+ if (page.scanned < 200)
182
+ break;
183
+ offset += page.scanned - page.deleted;
184
+ }
185
+ }
186
+ while ((yield* evalSweepDedupes(prefix, 200, now)) !== "0") {
187
+ // bounded batches until the dedup backlog is clean
188
+ }
189
+ }).pipe(Effect.catchCause((cause) => Effect.logError("effect-mq: redis history sweep failed", cause)), Effect.forever, Effect.forkScoped);
190
+ }
191
+ const generateCandidate = (request, generate) => Effect.suspend(() => {
192
+ const raw = generate(request);
193
+ return Effect.isEffect(raw) ? raw : Effect.succeed(raw);
194
+ });
195
+ const enqueueOnce = (request, idMode, id, now) => evalEnqueue(prefix, idMode, id, request.name, request.queue, JSON.stringify(request.payload ?? null), JSON.stringify(request.metadata), request.priority, request.attemptsMax, request.backoff === undefined ? "" : JSON.stringify(request.backoff), request.keep === undefined ? "" : JSON.stringify(request.keep), request.timeoutMs === undefined ? "" : String(request.timeoutMs), Math.max(0, request.delayMs), now, request.dedupe?.key ?? "", request.dedupe?.ttlMs === undefined ? "" : String(request.dedupe.ttlMs), request.dedupe?.extend === true ? "1" : "0", request.dedupe?.replace === true ? "1" : "0");
196
+ const store = {
197
+ enqueue: (request) => Effect.gen(function* () {
198
+ const now = yield* Clock.currentTimeMillis;
199
+ const generate = options?.idGenerator;
200
+ for (let i = 0; i < 5; i++) {
201
+ const mode = request.id !== undefined
202
+ ? "user"
203
+ : generate !== undefined
204
+ ? "generated"
205
+ : "auto";
206
+ const candidate = mode === "user"
207
+ ? request.id ?? ""
208
+ : mode === "generated" && generate !== undefined
209
+ ? yield* generateCandidate(request, generate)
210
+ : "";
211
+ const reply = JSON.parse(yield* enqueueOnce(request, mode, candidate, now));
212
+ if (reply.collision === true)
213
+ continue;
214
+ if (reply.error !== undefined || reply.id === undefined) {
215
+ return yield* new JobStore.JobStoreError({
216
+ message: "enqueue failed: could not generate a unique job id"
217
+ });
218
+ }
219
+ if (reply.wake === true) {
220
+ // A replace-while-delayed reply names the keyed job's queue.
221
+ yield* wakeUp(reply.queue !== undefined ? JobStore.QueueName(reply.queue) : request.queue);
222
+ }
223
+ return { id: JobStore.JobId(reply.id), duplicate: reply.duplicate === true };
224
+ }
225
+ return yield* new JobStore.JobStoreError({
226
+ message: "enqueue failed: could not generate a unique job id"
227
+ });
228
+ }).pipe(Effect.mapError((error) => error instanceof JobStore.JobStoreError ? error : storeError("enqueue failed")(error))),
229
+ claim: (claimOptions) =>
230
+ // Snapshot BEFORE the script runs: a wake that fires while the claim
231
+ // executes must make awaitWake(token) return immediately.
232
+ Effect.suspend(() => {
233
+ const observedWake = wakeVersion;
234
+ return Effect.gen(function* () {
235
+ const now = yield* Clock.currentTimeMillis;
236
+ const reply = JSON.parse(yield* evalClaim(prefix, claimOptions.queue, JSON.stringify(claimOptions.names), claimOptions.token, claimOptions.lockDurationMs, now));
237
+ if (reply.job !== undefined) {
238
+ const claimed = { _tag: "Claimed", job: toRecord(foldPairs(reply.job)) };
239
+ return claimed;
240
+ }
241
+ const empty = {
242
+ _tag: "Empty",
243
+ nextRunAt: reply.nextRunAt,
244
+ wakeToken: observedWake
245
+ };
246
+ return empty;
247
+ });
248
+ }).pipe(Effect.mapError(storeError("claim failed"))),
249
+ ack: (id, token, outcome) => Effect.gen(function* () {
250
+ const now = yield* Clock.currentTimeMillis;
251
+ const exitJson = outcome._tag === "Cancelled" || outcome.exit === undefined
252
+ ? ""
253
+ : JSON.stringify(outcome.exit);
254
+ const delayMs = outcome._tag === "Retry" ? Math.max(0, outcome.delayMs) : 0;
255
+ const reply = JSON.parse(yield* evalAck(prefix, id, token, outcome._tag, exitJson, delayMs, now).pipe(Effect.mapError(storeError("ack failed"))));
256
+ if (reply.error === "notfound")
257
+ return yield* new JobStore.JobNotFoundError({ jobId: id });
258
+ if (reply.error === "locklost")
259
+ return yield* new JobStore.LockLostError({ jobId: id });
260
+ if (reply.wake === true) {
261
+ yield* wakeUp(reply.queue !== undefined ? JobStore.QueueName(reply.queue) : undefined);
262
+ }
263
+ }),
264
+ release: (id, token) => Effect.gen(function* () {
265
+ const now = yield* Clock.currentTimeMillis;
266
+ const reply = JSON.parse(yield* evalRelease(prefix, id, token, now).pipe(Effect.mapError(storeError("release failed"))));
267
+ if (reply.error === "notfound")
268
+ return yield* new JobStore.JobNotFoundError({ jobId: id });
269
+ if (reply.error === "locklost")
270
+ return yield* new JobStore.LockLostError({ jobId: id });
271
+ if (reply.wake === true) {
272
+ yield* wakeUp(reply.queue !== undefined ? JobStore.QueueName(reply.queue) : undefined);
273
+ }
274
+ }),
275
+ extendLocks: (locks, durationMs) => Effect.gen(function* () {
276
+ if (locks.length === 0) {
277
+ const empty = { lost: [], cancelRequested: [] };
278
+ return empty;
279
+ }
280
+ const now = yield* Clock.currentTimeMillis;
281
+ const reply = JSON.parse(yield* evalExtendLocks(prefix, JSON.stringify(locks), durationMs, now));
282
+ const result = {
283
+ lost: asArray(reply.lost).map(JobStore.JobId),
284
+ cancelRequested: asArray(reply.cancel).map(JobStore.JobId)
285
+ };
286
+ return result;
287
+ }).pipe(Effect.mapError(storeError("extendLocks failed"))),
288
+ recoverStalled: (recoverOptions) => Effect.gen(function* () {
289
+ const now = yield* Clock.currentTimeMillis;
290
+ const recovered = JSON.parse(yield* evalRecoverStalled(prefix, recoverOptions.maxStalledCount, now));
291
+ const result = recovered.map((entry) => ({ id: JobStore.JobId(entry.id), failed: entry.failed }));
292
+ if (result.some((entry) => !entry.failed)) {
293
+ yield* wakeUp();
294
+ }
295
+ return result;
296
+ }).pipe(Effect.mapError(storeError("recoverStalled failed"))),
297
+ awaitWake: (queues, wakeToken) => Effect.suspend(() => {
298
+ if (queues.some((queue) => lastWakeFor(queue) > wakeToken))
299
+ return Effect.void;
300
+ const waiter = { queues: new Set(queues), deferred: Deferred.makeUnsafe() };
301
+ waiters.add(waiter);
302
+ return Deferred.await(waiter.deferred).pipe(Effect.ensuring(Effect.sync(() => waiters.delete(waiter))));
303
+ }),
304
+ getJob: (id) => evalGetJob(prefix, id).pipe(Effect.mapError(storeError("getJob failed")), Effect.map((raw) => {
305
+ const flat = JSON.parse(raw);
306
+ return flat.length === 0 ? Option.none() : Option.some(toRecord(foldPairs(flat)));
307
+ })),
308
+ getAttempts: (id) => redis.send("LRANGE", `${prefix}:attempts:${id}`, "0", "-1").pipe(Effect.mapError(storeError("getAttempts failed")), Effect.map((raw) => {
309
+ // SAFETY: LRANGE always replies with an array of bulk strings.
310
+ const entries = raw;
311
+ return entries.map((entry) => {
312
+ const parsed = JSON.parse(entry);
313
+ const record = {
314
+ attempt: parsed.attempt,
315
+ startedAt: parsed.startedAt ?? undefined,
316
+ finishedAt: parsed.finishedAt,
317
+ outcome: parsed.outcome,
318
+ // The ledger omits the key entirely for absent exits, so a
319
+ // legitimate encoded null exit survives the round trip.
320
+ exit: Object.hasOwn(parsed, "exit") ? parsed.exit : undefined
321
+ };
322
+ return record;
323
+ });
324
+ })),
325
+ list: (listOptions) => Effect.gen(function* () {
326
+ const limit = Math.max(1, listOptions.limit ?? 50);
327
+ const filters = {
328
+ queue: listOptions.queue,
329
+ name: listOptions.name,
330
+ states: listOptions.states,
331
+ metadata: listOptions.metadata
332
+ };
333
+ const reply = JSON
334
+ .parse(yield* evalList(prefix, JSON.stringify(filters), listOptions.cursor ?? "", limit));
335
+ const items = asArray(reply.items).map((flat) => toRecord(foldPairs(flat)));
336
+ const last = items[items.length - 1];
337
+ const result = {
338
+ items,
339
+ cursor: reply.more && last !== undefined ? `${last.enqueuedAt}:${last.id}` : undefined
340
+ };
341
+ return result;
342
+ }).pipe(Effect.mapError(storeError("list failed"))),
343
+ retry: (id) => Effect.gen(function* () {
344
+ const now = yield* Clock.currentTimeMillis;
345
+ const reply = JSON.parse(yield* evalRetry(prefix, id, now).pipe(Effect.mapError(storeError("retry failed"))));
346
+ if (reply.error === "notfound")
347
+ return yield* new JobStore.JobNotFoundError({ jobId: id });
348
+ if (reply.error === "state") {
349
+ return yield* new JobStore.JobNotRetryableError({ jobId: id, state: reply.state ?? "failed" });
350
+ }
351
+ yield* wakeUp(reply.queue !== undefined ? JobStore.QueueName(reply.queue) : undefined);
352
+ }),
353
+ counts: (queue) => evalCounts(prefix).pipe(Effect.mapError(storeError("counts failed")), Effect.map((raw) => {
354
+ const flat = JSON.parse(raw);
355
+ const byField = foldPairs(flat);
356
+ // SAFETY: fromEntries over the exhaustive JOB_STATES list yields
357
+ // exactly one zeroed entry per JobState member.
358
+ const totals = Object.fromEntries(JOB_STATES.map((state) => [state, 0]));
359
+ for (const [field, count] of byField) {
360
+ const split = field.lastIndexOf("|");
361
+ const fieldQueue = field.slice(0, split);
362
+ // SAFETY: counts fields are written as `<queue>|<JobState>`.
363
+ const state = field.slice(split + 1);
364
+ if (queue === undefined || fieldQueue === queue) {
365
+ totals[state] += Number(count);
366
+ }
367
+ }
368
+ return totals;
369
+ })),
370
+ remove: (id) => evalRemove(prefix, id).pipe(Effect.mapError(storeError("remove failed")), Effect.map((raw) => raw !== "0")),
371
+ cancel: (id) => Effect.gen(function* () {
372
+ const now = yield* Clock.currentTimeMillis;
373
+ const reply = JSON.parse(yield* evalCancel(prefix, id, now).pipe(Effect.mapError(storeError("cancel failed"))));
374
+ if (reply.error === "notfound")
375
+ return yield* new JobStore.JobNotFoundError({ jobId: id });
376
+ if (reply.error === "state") {
377
+ return yield* new JobStore.JobNotCancellableError({ jobId: id, state: reply.state ?? "completed" });
378
+ }
379
+ }),
380
+ promote: (id) => Effect.gen(function* () {
381
+ const now = yield* Clock.currentTimeMillis;
382
+ const reply = JSON.parse(yield* evalPromote(prefix, id, now).pipe(Effect.mapError(storeError("promote failed"))));
383
+ if (reply.error === "notfound")
384
+ return yield* new JobStore.JobNotFoundError({ jobId: id });
385
+ if (reply.error === "state") {
386
+ return yield* new JobStore.JobNotPromotableError({ jobId: id, state: reply.state ?? "completed" });
387
+ }
388
+ yield* wakeUp(reply.queue !== undefined ? JobStore.QueueName(reply.queue) : undefined);
389
+ }),
390
+ pause: (queue) => redis.send("SADD", `${prefix}:paused`, queue).pipe(Effect.mapError(storeError("pause failed")), Effect.asVoid),
391
+ resume: (queue) => redis.send("SREM", `${prefix}:paused`, queue).pipe(Effect.mapError(storeError("resume failed")), Effect.flatMap((removed) => Number(removed) > 0 ? wakeUp(queue) : Effect.void)),
392
+ pausedQueues: () => redis.send("SMEMBERS", `${prefix}:paused`).pipe(Effect.mapError(storeError("pausedQueues failed")), Effect.map((raw) => {
393
+ // SAFETY: SMEMBERS always replies with an array of bulk strings.
394
+ const members = raw;
395
+ return members.map(JobStore.QueueName);
396
+ })),
397
+ upsertSchedule: (schedule) => evalUpsertSchedule(prefix, schedule.key, schedule.jobName, schedule.queue, schedule.cron ?? "", schedule.tz ?? "", schedule.everyMs === undefined ? "" : String(schedule.everyMs), schedule.payload === undefined ? "" : JSON.stringify(schedule.payload), JSON.stringify(schedule.metadata), String(schedule.priority), String(schedule.attemptsMax), schedule.backoff === undefined ? "" : JSON.stringify(schedule.backoff), schedule.keep === undefined ? "" : JSON.stringify(schedule.keep), schedule.timeoutMs === undefined ? "" : String(schedule.timeoutMs), schedule.nextRunAt).pipe(Effect.mapError(storeError("upsertSchedule failed")), Effect.andThen(wakeUp(schedule.queue))),
398
+ removeSchedule: (key) => evalRemoveSchedule(prefix, key).pipe(Effect.mapError(storeError("removeSchedule failed")), Effect.map((raw) => raw !== "0")),
399
+ listSchedules: (listOptions) => evalListSchedules(prefix, JSON.stringify(listOptions ?? {})).pipe(Effect.mapError(storeError("listSchedules failed")), Effect.map((raw) => {
400
+ const flat = JSON.parse(raw);
401
+ return flat.map((pairs) => toSchedule(foldPairs(pairs)));
402
+ })),
403
+ dueSchedules: () => Effect.gen(function* () {
404
+ const now = yield* Clock.currentTimeMillis;
405
+ const flat = JSON.parse(yield* evalDueSchedules(prefix, now));
406
+ return flat.map((pairs) => toSchedule(foldPairs(pairs)));
407
+ }).pipe(Effect.mapError(storeError("dueSchedules failed"))),
408
+ advanceSchedule: (key, expectedRunAt, nextRunAt) => evalAdvanceSchedule(prefix, key, expectedRunAt, nextRunAt).pipe(Effect.mapError(storeError("advanceSchedule failed")), Effect.asVoid)
409
+ };
410
+ return store;
411
+ });
412
+ /**
413
+ * A Redis-backed layer for the default `JobStore`.
414
+ *
415
+ * @since 0.2.0
416
+ */
417
+ export const layer = (options) => Layer.effect(JobStore.JobStore, make(options));
418
+ /**
419
+ * A Redis-backed layer for a specific named store key.
420
+ *
421
+ * @since 0.2.0
422
+ */
423
+ export const layerFor = (store, options) => Layer.effect(store, make(options));
424
+ //# sourceMappingURL=RedisJobStore.js.map