effect-distributed-lock 0.0.1 → 0.0.3

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/LICENSE ADDED
@@ -0,0 +1,22 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 Ethan Niser
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
22
+
package/README.md CHANGED
@@ -1,15 +1,157 @@
1
1
  # effect-distributed-lock
2
2
 
3
- To install dependencies:
3
+ *WARNING: This is still in active development, likely has bugs and is subject to change.*
4
+
5
+ A distributed mutex library for [Effect](https://effect.website/) with pluggable backends.
6
+
7
+ ## Features
8
+
9
+ - **Scope-based resource management** — locks are automatically released when the scope closes
10
+ - **Automatic TTL refresh** — keeps locks alive while held, prevents deadlocks if holder crashes
11
+ - **Pluggable backends** — ships with Redis, easy to implement others (etcd, DynamoDB, etc.)
12
+ - **Configurable retry policies** — control polling interval, TTL, and backing failure retry behavior
13
+ - **Type-safe errors** — tagged errors for precise error handling
14
+
15
+ ## Installation
4
16
 
5
17
  ```bash
6
- bun install
18
+ npm install effect-distributed-lock effect
19
+ # or
20
+ bun add effect-distributed-lock effect
21
+
22
+ # For Redis backing (optional)
23
+ npm install ioredis
7
24
  ```
8
25
 
9
- To run:
26
+ ## Quick Start
10
27
 
11
- ```bash
12
- bun run index.ts
28
+ ```typescript
29
+ import { Effect } from "effect";
30
+ import Redis from "ioredis";
31
+ import { DistributedMutex, RedisBacking } from "effect-distributed-lock";
32
+
33
+ const redis = new Redis(process.env.REDIS_URL);
34
+ const RedisLayer = RedisBacking.layer(redis);
35
+
36
+ const program = Effect.gen(function* () {
37
+ const mutex = yield* DistributedMutex.make("my-resource", {
38
+ ttl: "10 seconds",
39
+ refreshInterval: "3 seconds",
40
+ acquireRetryInterval: "500 millis",
41
+ backingFailureRetryPolicy: Schedule.exponential("100 millis")),
42
+ });
43
+
44
+ // Lock is held while effect runs, released automatically after
45
+ yield* mutex.withLock(doExclusiveWork);
46
+ });
47
+
48
+ program.pipe(Effect.provide(RedisLayer), Effect.runPromise);
13
49
  ```
14
50
 
15
- This project was created using `bun init` in bun v1.2.12. [Bun](https://bun.sh) is a fast all-in-one JavaScript runtime.
51
+ ## API
52
+
53
+ ### Creating a Mutex
54
+
55
+ ```typescript
56
+ const mutex = yield* DistributedMutex.make(key, config);
57
+ ```
58
+
59
+ | Config Option | Type | Default | Description |
60
+ | ---------------------- | ------------------ | ------------ | --------------------------------------------------- |
61
+ | `ttl` | `DurationInput` | `30 seconds` | Lock TTL (auto-releases if holder crashes) |
62
+ | `refreshInterval` | `DurationInput` | `ttl / 3` | How often to refresh TTL while holding |
63
+ | `acquireRetryInterval` | `DurationInput` | `100ms` | Polling interval when waiting to acquire |
64
+ | `backingFailureRetryPolicy` | `Schedule<void>` | `100ms` | Retry schedule for backing store failures |
65
+
66
+ ### Using the Mutex
67
+
68
+ #### `withLock` — Acquire, run, release
69
+
70
+ The simplest and recommended way. Acquires the lock (waiting indefinitely if needed), runs your effect, and releases when done:
71
+
72
+ ```typescript
73
+ yield* mutex.withLock(myEffect);
74
+ ```
75
+
76
+ #### `withLockIfAvailable` — Non-blocking acquire
77
+
78
+ Tries to acquire immediately without waiting. Returns `Option.some(result)` if successful, `Option.none()` if lock is held:
79
+
80
+ ```typescript
81
+ const result = yield* mutex.withLockIfAvailable(myEffect);
82
+ if (Option.isSome(result)) {
83
+ console.log("Got the lock!", result.value);
84
+ } else {
85
+ console.log("Lock was busy, skipping");
86
+ }
87
+ ```
88
+
89
+ #### `acquire` / `tryAcquire` — Manual scope control
90
+
91
+ For advanced use cases where you need explicit control over the lock lifecycle:
92
+
93
+ ```typescript
94
+ yield* Effect.scoped(
95
+ Effect.gen(function* () {
96
+ const keepAliveFiber = yield* mutex.acquire; // Lock held until scope closes
97
+ yield* doWork;
98
+ // Lock automatically released + keepalive fiber interrupted here
99
+ })
100
+ );
101
+ ```
102
+
103
+ Both `acquire` and `tryAcquire` return the keepalive fiber that refreshes the lock TTL. Errors related to the keep alive (losing the lock or backing store failure) are propagated to the fiber.
104
+
105
+ #### `isLocked` — Check lock status
106
+
107
+ ```typescript
108
+ const locked = yield* mutex.isLocked;
109
+ ```
110
+
111
+ ## Error Handling
112
+
113
+ All errors are tagged for precise handling with `Effect.catchTag`:
114
+
115
+ ```typescript
116
+ yield* mutex.withLock(myEffect).pipe(
117
+ Effect.catchTag("LockLostError", (e) =>
118
+ Effect.log(`Lock was lost while held: ${e.key}`)
119
+ ),
120
+ Effect.catchTag("MutexBackingError", (e) =>
121
+ Effect.log(`Redis error: ${e.message}`)
122
+ )
123
+ );
124
+ ```
125
+
126
+ | Error | Description |
127
+ | ------------------- | ---------------------------------------------------- |
128
+ | `LockLostError` | Lock TTL expired while we thought we held it |
129
+ | `MutexBackingError` | Error from the backing store (Redis connection, etc) |
130
+
131
+ ## Custom Backends
132
+
133
+ Implement the `DistributedMutexBacking` interface to use a different store:
134
+
135
+ ```typescript
136
+ import { Duration, Layer } from "effect";
137
+ import { DistributedMutex } from "effect-distributed-lock";
138
+
139
+ const MyCustomBacking = Layer.succeed(DistributedMutex.DistributedMutexBacking, {
140
+ tryAcquire: (key, holderId, ttl: Duration.Duration) => /* ... */,
141
+ release: (key, holderId) => /* ... */,
142
+ refresh: (key, holderId, ttl: Duration.Duration) => /* ... */,
143
+ isLocked: (key) => /* ... */,
144
+ getHolder: (key) => /* ... */,
145
+ });
146
+ ```
147
+
148
+ ## How It Works
149
+
150
+ 1. **Acquire**: Atomically sets the lock key if not exists (Redis: `SET key value NX PX ttl`)
151
+ 2. **Keepalive**: A background fiber refreshes the TTL periodically while the lock is held
152
+ 3. **Release**: Atomically deletes the key only if we're still the holder (Lua script for atomicity)
153
+ 4. **Crash safety**: If the holder crashes, the TTL expires and another process can acquire
154
+
155
+ ## License
156
+
157
+ MIT
package/package.json CHANGED
@@ -1,7 +1,16 @@
1
1
  {
2
2
  "name": "effect-distributed-lock",
3
- "version": "0.0.1",
3
+ "version": "0.0.3",
4
4
  "description": "A distributed mutex library for Effect with pluggable backends",
5
+ "license": "MIT",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "https://github.com/ethanniser/effect-distributed-lock.git"
9
+ },
10
+ "homepage": "https://github.com/ethanniser/effect-distributed-lock",
11
+ "bugs": {
12
+ "url": "https://github.com/ethanniser/effect-distributed-lock/issues"
13
+ },
5
14
  "type": "module",
6
15
  "scripts": {
7
16
  "build": "tsc",
@@ -1,7 +1,14 @@
1
- import { Context, Duration, Effect, Option, Schedule, Scope } from "effect";
2
1
  import {
3
- AcquireTimeoutError,
4
- BackingError,
2
+ Context,
3
+ Duration,
4
+ Effect,
5
+ Fiber,
6
+ Option,
7
+ Schedule,
8
+ Scope,
9
+ } from "effect";
10
+ import {
11
+ MutexBackingError,
5
12
  LockLostError,
6
13
  NotYetAcquiredError,
7
14
  } from "./Errors.js";
@@ -22,8 +29,8 @@ export interface DistributedMutexBacking {
22
29
  readonly tryAcquire: (
23
30
  key: string,
24
31
  holderId: string,
25
- ttlMs: number
26
- ) => Effect.Effect<boolean, BackingError>;
32
+ ttl: Duration.Duration
33
+ ) => Effect.Effect<boolean, MutexBackingError>;
27
34
 
28
35
  /**
29
36
  * Release the lock. Only succeeds if we are the current holder.
@@ -32,7 +39,7 @@ export interface DistributedMutexBacking {
32
39
  readonly release: (
33
40
  key: string,
34
41
  holderId: string
35
- ) => Effect.Effect<boolean, BackingError>;
42
+ ) => Effect.Effect<boolean, MutexBackingError>;
36
43
 
37
44
  /**
38
45
  * Refresh the TTL on a lock we hold.
@@ -41,20 +48,20 @@ export interface DistributedMutexBacking {
41
48
  readonly refresh: (
42
49
  key: string,
43
50
  holderId: string,
44
- ttlMs: number
45
- ) => Effect.Effect<boolean, BackingError>;
51
+ ttl: Duration.Duration
52
+ ) => Effect.Effect<boolean, MutexBackingError>;
46
53
 
47
54
  /**
48
55
  * Check if the lock is currently held (by anyone).
49
56
  */
50
- readonly isLocked: (key: string) => Effect.Effect<boolean, BackingError>;
57
+ readonly isLocked: (key: string) => Effect.Effect<boolean, MutexBackingError>;
51
58
 
52
59
  /**
53
60
  * Get the current holder ID, if any.
54
61
  */
55
62
  readonly getHolder: (
56
63
  key: string
57
- ) => Effect.Effect<Option.Option<string>, BackingError>;
64
+ ) => Effect.Effect<Option.Option<string>, MutexBackingError>;
58
65
  }
59
66
 
60
67
  export const DistributedMutexBacking =
@@ -84,17 +91,23 @@ export interface DistributedMutexConfig {
84
91
  * How often to poll when waiting to acquire the lock.
85
92
  * @default 100ms
86
93
  */
87
- readonly retryInterval?: Duration.DurationInput;
94
+ readonly acquireRetryInterval?: Duration.DurationInput;
88
95
 
89
96
  /**
90
- * Maximum time to wait when acquiring the lock.
91
- * If not set, will wait forever.
97
+ * How often to retry when a failure occurs.
98
+ * This could happen when:
99
+ * - Trying to acquire the lock
100
+ * - Refreshing the TTL
101
+ * - Releasing the lock
92
102
  */
93
- readonly acquireTimeout?: Duration.DurationInput;
103
+ readonly backingFailureRetryPolicy?: Schedule.Schedule<void>;
94
104
  }
95
105
 
96
106
  const DEFAULT_TTL = Duration.seconds(30);
97
- const DEFAULT_RETRY_INTERVAL = Duration.millis(100);
107
+ const DEFAULT_ACQUIRE_RETRY_INTERVAL = Duration.millis(100);
108
+ const DEFAULT_FAILURE_RETRY_POLICY = Schedule.spaced(
109
+ DEFAULT_ACQUIRE_RETRY_INTERVAL
110
+ ).pipe(Schedule.asVoid);
98
111
 
99
112
  // =============================================================================
100
113
  // Distributed Mutex Interface
@@ -116,11 +129,7 @@ export interface DistributedMutex {
116
129
  */
117
130
  readonly withLock: <A, E, R>(
118
131
  effect: Effect.Effect<A, E, R>
119
- ) => Effect.Effect<
120
- A,
121
- E | AcquireTimeoutError | LockLostError | BackingError,
122
- R
123
- >;
132
+ ) => Effect.Effect<A, E | LockLostError | MutexBackingError, R>;
124
133
 
125
134
  /**
126
135
  * Try to acquire the lock immediately without waiting.
@@ -129,16 +138,23 @@ export interface DistributedMutex {
129
138
  */
130
139
  readonly withLockIfAvailable: <A, E, R>(
131
140
  effect: Effect.Effect<A, E, R>
132
- ) => Effect.Effect<Option.Option<A>, E | LockLostError | BackingError, R>;
141
+ ) => Effect.Effect<
142
+ Option.Option<A>,
143
+ E | LockLostError | MutexBackingError,
144
+ R
145
+ >;
133
146
 
134
147
  /**
135
148
  * Acquire the lock, waiting if necessary.
136
149
  * The lock is held until the scope is closed.
137
150
  * The lock TTL is refreshed automatically while held.
151
+ *
152
+ * @returns The fiber that refreshes the lock hold. Its lifetime is tied to the scope.
153
+ * When the scope closes, the fiber is interrupted and the lock is released.
138
154
  */
139
155
  readonly acquire: Effect.Effect<
140
- void,
141
- AcquireTimeoutError | LockLostError | BackingError,
156
+ Fiber.Fiber<void, LockLostError | MutexBackingError>,
157
+ LockLostError | MutexBackingError,
142
158
  Scope.Scope
143
159
  >;
144
160
 
@@ -146,23 +162,55 @@ export interface DistributedMutex {
146
162
  * Try to acquire immediately without waiting.
147
163
  * Returns Some(void) if acquired (lock held until scope closes),
148
164
  * None if lock was not available.
165
+ *
166
+ * @returns The fiber that refreshes the lock hold. Its lifetime is tied to the scope.
167
+ * When the scope closes, the fiber is interrupted and the lock is released.
149
168
  */
150
169
  readonly tryAcquire: Effect.Effect<
151
- Option.Option<void>,
152
- LockLostError | BackingError,
170
+ Option.Option<Fiber.Fiber<void, LockLostError | MutexBackingError>>,
171
+ LockLostError | MutexBackingError,
153
172
  Scope.Scope
154
173
  >;
155
174
 
156
175
  /**
157
176
  * Check if the lock is currently held.
158
177
  */
159
- readonly isLocked: Effect.Effect<boolean, BackingError>;
178
+ readonly isLocked: Effect.Effect<boolean, MutexBackingError>;
160
179
  }
161
180
 
162
181
  // =============================================================================
163
182
  // Factory
164
183
  // =============================================================================
165
184
 
185
+ type FullyResolvedConfig = {
186
+ ttl: Duration.Duration;
187
+ refreshInterval: Duration.Duration;
188
+ acquireRetryInterval: Duration.Duration;
189
+ backingFailureRetryPolicy: Schedule.Schedule<void>;
190
+ };
191
+
192
+ function fullyResolveConfig(
193
+ config: DistributedMutexConfig
194
+ ): FullyResolvedConfig {
195
+ const ttl = config.ttl ? Duration.decode(config.ttl) : DEFAULT_TTL;
196
+ const refreshInterval = config.refreshInterval
197
+ ? Duration.decode(config.refreshInterval)
198
+ : Duration.millis(Duration.toMillis(ttl) / 3);
199
+ const acquireRetryInterval = config.acquireRetryInterval
200
+ ? Duration.decode(config.acquireRetryInterval)
201
+ : DEFAULT_ACQUIRE_RETRY_INTERVAL;
202
+ const backingFailureRetryPolicy = config.backingFailureRetryPolicy
203
+ ? config.backingFailureRetryPolicy
204
+ : DEFAULT_FAILURE_RETRY_POLICY;
205
+
206
+ return {
207
+ ttl,
208
+ refreshInterval,
209
+ acquireRetryInterval,
210
+ backingFailureRetryPolicy,
211
+ };
212
+ }
213
+
166
214
  /**
167
215
  * Create a distributed mutex for the given key.
168
216
  */
@@ -177,121 +225,125 @@ export const make = (
177
225
  const holderId = crypto.randomUUID();
178
226
 
179
227
  // Resolve config with defaults
180
- const ttl = config.ttl ? Duration.decode(config.ttl) : DEFAULT_TTL;
181
- const ttlMs = Duration.toMillis(ttl);
182
- const refreshInterval = config.refreshInterval
183
- ? Duration.decode(config.refreshInterval)
184
- : Duration.millis(ttlMs / 3);
185
- const retryInterval = config.retryInterval
186
- ? Duration.decode(config.retryInterval)
187
- : DEFAULT_RETRY_INTERVAL;
188
- const acquireTimeout = config.acquireTimeout
189
- ? Option.some(Duration.decode(config.acquireTimeout))
190
- : Option.none<Duration.Duration>();
228
+ const {
229
+ ttl,
230
+ refreshInterval,
231
+ acquireRetryInterval,
232
+ backingFailureRetryPolicy,
233
+ } = fullyResolveConfig(config);
234
+
235
+ const withMutexBackingErrorRetry = <A, E extends { _tag: string }, R>(
236
+ effect: Effect.Effect<A, E | MutexBackingError, R>
237
+ ) =>
238
+ effect.pipe(
239
+ Effect.retry({
240
+ while: (e) => e._tag === "MutexBackingError",
241
+ schedule: backingFailureRetryPolicy,
242
+ })
243
+ );
191
244
 
192
245
  // Keep the lock alive by refreshing TTL periodically.
193
246
  // This effect runs forever until interrupted (when scope closes).
194
247
  const keepAlive = Effect.repeat(
195
248
  Effect.gen(function* () {
196
- const refreshed = yield* backing.refresh(key, holderId, ttlMs);
249
+ const refreshed = yield* backing
250
+ .refresh(key, holderId, ttl)
251
+ .pipe(withMutexBackingErrorRetry);
252
+
197
253
  if (!refreshed) {
198
254
  return yield* new LockLostError({ key });
199
255
  }
200
256
  }),
201
257
  Schedule.spaced(refreshInterval)
202
- );
258
+ ).pipe(Effect.asVoid);
203
259
 
204
260
  // Try to acquire immediately, returns Option
205
261
  const tryAcquire: Effect.Effect<
206
- Option.Option<void>,
207
- LockLostError | BackingError,
262
+ Option.Option<Fiber.Fiber<void, LockLostError | MutexBackingError>>,
263
+ MutexBackingError,
208
264
  Scope.Scope
209
265
  > = Effect.gen(function* () {
210
- const acquired = yield* backing.tryAcquire(key, holderId, ttlMs);
266
+ const acquired = yield* backing
267
+ .tryAcquire(key, holderId, ttl)
268
+ .pipe(withMutexBackingErrorRetry);
211
269
  if (!acquired) {
212
270
  return Option.none();
213
271
  }
214
272
 
215
273
  // Start keepalive fiber, tied to this scope
216
- yield* Effect.forkScoped(keepAlive);
274
+ const keepAliveFiber = yield* Effect.forkScoped(keepAlive);
217
275
 
218
276
  // Add finalizer to release lock when scope closes
219
277
  yield* Effect.addFinalizer(() =>
220
- backing.release(key, holderId).pipe(Effect.ignore)
278
+ backing
279
+ .release(key, holderId)
280
+ .pipe(withMutexBackingErrorRetry, Effect.ignore)
221
281
  );
222
282
 
223
- return Option.some(undefined as void);
283
+ return Option.some(keepAliveFiber);
224
284
  });
225
285
 
226
286
  // Acquire with retry/timeout, returns void when acquired
227
287
  const acquire: Effect.Effect<
228
- void,
229
- AcquireTimeoutError | LockLostError | BackingError,
288
+ Fiber.Fiber<void, LockLostError | MutexBackingError>,
289
+ MutexBackingError,
230
290
  Scope.Scope
231
- > = Effect.gen(function* () {
232
- // Build retry schedule with optional timeout
233
- const schedule = Option.match(acquireTimeout, {
234
- onNone: () => Schedule.spaced(retryInterval),
235
- onSome: (timeout) =>
236
- Schedule.spaced(retryInterval).pipe(Schedule.upTo(timeout)),
237
- });
238
-
291
+ > =
239
292
  // Retry until we acquire the lock
240
- yield* Effect.retry(
241
- Effect.gen(function* () {
242
- const maybeAcquired = yield* tryAcquire;
243
- if (Option.isNone(maybeAcquired)) {
244
- return yield* new NotYetAcquiredError();
245
- }
293
+ Effect.gen(function* () {
294
+ const maybeAcquired = yield* tryAcquire;
295
+ if (Option.isNone(maybeAcquired)) {
296
+ return yield* new NotYetAcquiredError();
297
+ }
298
+ return maybeAcquired.value;
299
+ }).pipe(
300
+ Effect.retry({
301
+ while: (e) => e._tag === "NotYetAcquiredError",
302
+ schedule: Schedule.spaced(acquireRetryInterval),
246
303
  }),
247
- schedule
248
- ).pipe(
249
- Effect.catchTag(
250
- "NotYetAcquiredError",
251
- () =>
252
- new AcquireTimeoutError({
253
- key,
254
- timeoutMs: Option.match(acquireTimeout, {
255
- onNone: () => -1,
256
- onSome: Duration.toMillis,
257
- }),
258
- })
304
+ Effect.catchTag("NotYetAcquiredError", () =>
305
+ Effect.dieMessage(
306
+ "Invariant violated: `acquire` should never return `NotYetAcquiredError " +
307
+ "since it should be caught by the retry which should retry forever until the lock is acquired"
308
+ )
259
309
  )
260
310
  );
261
- });
262
311
 
263
312
  // Convenience: acquire lock, run effect, release when done
264
313
  const withLock = <A, E, R>(
265
314
  effect: Effect.Effect<A, E, R>
266
- ): Effect.Effect<
267
- A,
268
- E | AcquireTimeoutError | LockLostError | BackingError,
269
- R
270
- > =>
315
+ ): Effect.Effect<A, E | LockLostError | MutexBackingError, R> =>
271
316
  Effect.scoped(
272
317
  Effect.gen(function* () {
273
- yield* acquire;
274
- return yield* effect;
318
+ const keepAliveFiber = yield* acquire;
319
+ const taskFiber = yield* Effect.fork(effect);
320
+ return yield* Fiber.join(Fiber.zipLeft(taskFiber, keepAliveFiber));
275
321
  })
276
322
  );
277
323
 
278
324
  // Convenience: try to acquire, run effect if successful
279
325
  const withLockIfAvailable = <A, E, R>(
280
326
  effect: Effect.Effect<A, E, R>
281
- ): Effect.Effect<Option.Option<A>, E | LockLostError | BackingError, R> =>
327
+ ): Effect.Effect<
328
+ Option.Option<A>,
329
+ E | LockLostError | MutexBackingError,
330
+ R
331
+ > =>
282
332
  Effect.scoped(
283
333
  Effect.gen(function* () {
284
334
  const maybeAcquired = yield* tryAcquire;
285
335
  if (Option.isNone(maybeAcquired)) {
286
- return Option.none<A>();
336
+ return Option.none();
287
337
  }
288
- const result = yield* effect;
289
- return Option.some(result);
338
+ const keepAliveFiber = maybeAcquired.value;
339
+ const taskFiber = yield* Effect.fork(effect.pipe(Effect.asSome));
340
+ return yield* Fiber.join(Fiber.zipLeft(taskFiber, keepAliveFiber));
290
341
  })
291
342
  );
292
343
 
293
- const isLocked: Effect.Effect<boolean, BackingError> =
294
- backing.isLocked(key);
344
+ const isLocked: Effect.Effect<boolean, MutexBackingError> = backing
345
+ .isLocked(key)
346
+ .pipe(withMutexBackingErrorRetry);
295
347
 
296
348
  return {
297
349
  key,
package/src/Errors.ts CHANGED
@@ -1,29 +1,5 @@
1
1
  import { Data } from "effect";
2
2
 
3
- /**
4
- * Base error for all distributed mutex errors
5
- */
6
- export class DistributedMutexError extends Data.TaggedError(
7
- "DistributedMutexError"
8
- )<{
9
- readonly message: string;
10
- readonly cause?: unknown;
11
- }> {}
12
-
13
- /**
14
- * Failed to acquire the lock within the timeout period
15
- */
16
- export class AcquireTimeoutError extends Data.TaggedError(
17
- "AcquireTimeoutError"
18
- )<{
19
- readonly key: string;
20
- readonly timeoutMs: number;
21
- }> {
22
- get message() {
23
- return `Failed to acquire lock "${this.key}" within ${this.timeoutMs}ms`;
24
- }
25
- }
26
-
27
3
  /**
28
4
  * The lock was lost (TTL expired while we thought we held it)
29
5
  */
@@ -38,7 +14,7 @@ export class LockLostError extends Data.TaggedError("LockLostError")<{
38
14
  /**
39
15
  * Error from the backing store (Redis, etc.)
40
16
  */
41
- export class BackingError extends Data.TaggedError("BackingError")<{
17
+ export class MutexBackingError extends Data.TaggedError("MutexBackingError")<{
42
18
  readonly operation: string;
43
19
  readonly cause: unknown;
44
20
  }> {
@@ -1,7 +1,7 @@
1
- import { Effect, Layer, Option } from "effect";
2
- import type { Redis } from "ioredis";
1
+ import { Duration, Effect, Layer, Option } from "effect";
2
+ import { Redis } from "ioredis";
3
3
  import { DistributedMutexBacking } from "./DistributedMutex.js";
4
- import { BackingError } from "./Errors.js";
4
+ import { MutexBackingError } from "./Errors.js";
5
5
 
6
6
  /**
7
7
  * Lua script for atomic lock acquisition.
@@ -70,8 +70,8 @@ export const layer = (
70
70
  const tryAcquire = (
71
71
  key: string,
72
72
  holderId: string,
73
- ttlMs: number
74
- ): Effect.Effect<boolean, BackingError> =>
73
+ ttl: Duration.Duration
74
+ ): Effect.Effect<boolean, MutexBackingError> =>
75
75
  Effect.tryPromise({
76
76
  try: async () => {
77
77
  const result = await redis.eval(
@@ -79,17 +79,18 @@ export const layer = (
79
79
  1,
80
80
  prefixKey(key),
81
81
  holderId,
82
- ttlMs.toString()
82
+ Duration.toMillis(ttl).toString()
83
83
  );
84
84
  return result === 1;
85
85
  },
86
- catch: (cause) => new BackingError({ operation: "tryAcquire", cause }),
86
+ catch: (cause) =>
87
+ new MutexBackingError({ operation: "tryAcquire", cause }),
87
88
  });
88
89
 
89
90
  const release = (
90
91
  key: string,
91
92
  holderId: string
92
- ): Effect.Effect<boolean, BackingError> =>
93
+ ): Effect.Effect<boolean, MutexBackingError> =>
93
94
  Effect.tryPromise({
94
95
  try: async () => {
95
96
  const result = await redis.eval(
@@ -100,14 +101,14 @@ export const layer = (
100
101
  );
101
102
  return result === 1;
102
103
  },
103
- catch: (cause) => new BackingError({ operation: "release", cause }),
104
+ catch: (cause) => new MutexBackingError({ operation: "release", cause }),
104
105
  });
105
106
 
106
107
  const refresh = (
107
108
  key: string,
108
109
  holderId: string,
109
- ttlMs: number
110
- ): Effect.Effect<boolean, BackingError> =>
110
+ ttl: Duration.Duration
111
+ ): Effect.Effect<boolean, MutexBackingError> =>
111
112
  Effect.tryPromise({
112
113
  try: async () => {
113
114
  const result = await redis.eval(
@@ -115,31 +116,32 @@ export const layer = (
115
116
  1,
116
117
  prefixKey(key),
117
118
  holderId,
118
- ttlMs.toString()
119
+ Duration.toMillis(ttl).toString()
119
120
  );
120
121
  return result === 1;
121
122
  },
122
- catch: (cause) => new BackingError({ operation: "refresh", cause }),
123
+ catch: (cause) => new MutexBackingError({ operation: "refresh", cause }),
123
124
  });
124
125
 
125
- const isLocked = (key: string): Effect.Effect<boolean, BackingError> =>
126
+ const isLocked = (key: string): Effect.Effect<boolean, MutexBackingError> =>
126
127
  Effect.tryPromise({
127
128
  try: async () => {
128
129
  const exists = await redis.exists(prefixKey(key));
129
130
  return exists === 1;
130
131
  },
131
- catch: (cause) => new BackingError({ operation: "isLocked", cause }),
132
+ catch: (cause) => new MutexBackingError({ operation: "isLocked", cause }),
132
133
  });
133
134
 
134
135
  const getHolder = (
135
136
  key: string
136
- ): Effect.Effect<Option.Option<string>, BackingError> =>
137
+ ): Effect.Effect<Option.Option<string>, MutexBackingError> =>
137
138
  Effect.tryPromise({
138
139
  try: async () => {
139
140
  const holder = await redis.get(prefixKey(key));
140
141
  return holder ? Option.some(holder) : Option.none();
141
142
  },
142
- catch: (cause) => new BackingError({ operation: "getHolder", cause }),
143
+ catch: (cause) =>
144
+ new MutexBackingError({ operation: "getHolder", cause }),
143
145
  });
144
146
 
145
147
  return Layer.succeed(DistributedMutexBacking, {
@@ -150,119 +152,3 @@ export const layer = (
150
152
  getHolder,
151
153
  });
152
154
  };
153
-
154
- /**
155
- * Create a Redis backing from a connection URL.
156
- * This creates and manages the Redis connection lifecycle.
157
- */
158
- export const layerFromUrl = (
159
- url: string,
160
- keyPrefix = "dmutex:"
161
- ): Layer.Layer<DistributedMutexBacking, BackingError> =>
162
- Layer.scoped(
163
- DistributedMutexBacking,
164
- Effect.gen(function* () {
165
- // Dynamic import to avoid requiring ioredis at module load time
166
- const { default: Redis } = yield* Effect.tryPromise({
167
- try: () => import("ioredis"),
168
- catch: (cause) =>
169
- new BackingError({
170
- operation: "import",
171
- cause: `Failed to import ioredis: ${cause}`,
172
- }),
173
- });
174
-
175
- const redis = new Redis(url);
176
-
177
- // Ensure cleanup on scope close
178
- yield* Effect.addFinalizer(() =>
179
- Effect.promise(() => redis.quit().catch(() => {}))
180
- );
181
-
182
- const prefixKey = (key: string) => `${keyPrefix}${key}`;
183
-
184
- const tryAcquire = (
185
- key: string,
186
- holderId: string,
187
- ttlMs: number
188
- ): Effect.Effect<boolean, BackingError> =>
189
- Effect.tryPromise({
190
- try: async () => {
191
- const result = await redis.eval(
192
- ACQUIRE_SCRIPT,
193
- 1,
194
- prefixKey(key),
195
- holderId,
196
- ttlMs.toString()
197
- );
198
- return result === 1;
199
- },
200
- catch: (cause) =>
201
- new BackingError({ operation: "tryAcquire", cause }),
202
- });
203
-
204
- const release = (
205
- key: string,
206
- holderId: string
207
- ): Effect.Effect<boolean, BackingError> =>
208
- Effect.tryPromise({
209
- try: async () => {
210
- const result = await redis.eval(
211
- RELEASE_SCRIPT,
212
- 1,
213
- prefixKey(key),
214
- holderId
215
- );
216
- return result === 1;
217
- },
218
- catch: (cause) => new BackingError({ operation: "release", cause }),
219
- });
220
-
221
- const refresh = (
222
- key: string,
223
- holderId: string,
224
- ttlMs: number
225
- ): Effect.Effect<boolean, BackingError> =>
226
- Effect.tryPromise({
227
- try: async () => {
228
- const result = await redis.eval(
229
- REFRESH_SCRIPT,
230
- 1,
231
- prefixKey(key),
232
- holderId,
233
- ttlMs.toString()
234
- );
235
- return result === 1;
236
- },
237
- catch: (cause) => new BackingError({ operation: "refresh", cause }),
238
- });
239
-
240
- const isLocked = (key: string): Effect.Effect<boolean, BackingError> =>
241
- Effect.tryPromise({
242
- try: async () => {
243
- const exists = await redis.exists(prefixKey(key));
244
- return exists === 1;
245
- },
246
- catch: (cause) => new BackingError({ operation: "isLocked", cause }),
247
- });
248
-
249
- const getHolder = (
250
- key: string
251
- ): Effect.Effect<Option.Option<string>, BackingError> =>
252
- Effect.tryPromise({
253
- try: async () => {
254
- const holder = await redis.get(prefixKey(key));
255
- return holder ? Option.some(holder) : Option.none();
256
- },
257
- catch: (cause) => new BackingError({ operation: "getHolder", cause }),
258
- });
259
-
260
- return {
261
- tryAcquire,
262
- release,
263
- refresh,
264
- isLocked,
265
- getHolder,
266
- } satisfies DistributedMutexBacking;
267
- })
268
- );
package/src/index.ts CHANGED
@@ -5,7 +5,7 @@
5
5
  *
6
6
  * @example
7
7
  * ```ts
8
- * import { DistributedMutex, makeRedisBackingLayer } from "effect-distributed-lock";
8
+ * import { DistributedMutex, RedisBacking } from "effect-distributed-lock";
9
9
  * import { Effect } from "effect";
10
10
  * import Redis from "ioredis";
11
11
  *
@@ -28,7 +28,7 @@
28
28
  * });
29
29
  *
30
30
  * program.pipe(
31
- * Effect.provide(makeRedisBackingLayer(redis)),
31
+ * Effect.provide(RedisBacking.layer(redis)),
32
32
  * Effect.runPromise
33
33
  * );
34
34
  * ```
@@ -39,12 +39,7 @@
39
39
  // Core module (namespace with types and functions)
40
40
  export * as DistributedMutex from "./DistributedMutex.js";
41
41
  // Errors
42
- export {
43
- AcquireTimeoutError,
44
- BackingError,
45
- DistributedMutexError,
46
- LockLostError,
47
- } from "./Errors.js";
42
+ export { MutexBackingError, LockLostError } from "./Errors.js";
48
43
 
49
44
  // Redis backing
50
45
  export * as RedisBacking from "./RedisBacking.js";
package/tsconfig.json CHANGED
@@ -3,13 +3,13 @@
3
3
  // Environment setup & latest features
4
4
  "lib": ["ESNext"],
5
5
  "target": "ESNext",
6
- "module": "ESNext",
6
+ "module": "NodeNext",
7
7
  "moduleDetection": "force",
8
8
  "jsx": "react-jsx",
9
9
  "allowJs": true,
10
10
 
11
11
  // Bundler mode
12
- "moduleResolution": "bundler",
12
+ "moduleResolution": "nodenext",
13
13
  "verbatimModuleSyntax": true,
14
14
 
15
15
  // Build output