@lickle/lock 0.0.1-alpha.1 → 0.0.1-alpha.2

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 (91) hide show
  1. package/README.md +182 -76
  2. package/cjs.js +57 -57
  3. package/dist/cjs/core/guard.d.ts +50 -0
  4. package/dist/cjs/core/guard.d.ts.map +1 -0
  5. package/dist/cjs/{backend → core}/guard.js +43 -11
  6. package/dist/cjs/core/guard.js.map +1 -0
  7. package/dist/cjs/core/index.d.ts +8 -0
  8. package/dist/cjs/core/index.d.ts.map +1 -0
  9. package/dist/cjs/core/index.js +41 -0
  10. package/dist/cjs/core/index.js.map +1 -0
  11. package/dist/cjs/core/locking.d.ts +23 -0
  12. package/dist/cjs/core/locking.d.ts.map +1 -0
  13. package/dist/cjs/core/locking.js +93 -0
  14. package/dist/cjs/core/locking.js.map +1 -0
  15. package/dist/cjs/core/node.d.ts +19 -0
  16. package/dist/cjs/core/node.d.ts.map +1 -0
  17. package/dist/cjs/core/node.js +69 -0
  18. package/dist/cjs/core/node.js.map +1 -0
  19. package/dist/cjs/core/types.d.ts +49 -0
  20. package/dist/cjs/core/types.d.ts.map +1 -0
  21. package/dist/cjs/core/types.js +13 -0
  22. package/dist/cjs/core/types.js.map +1 -0
  23. package/dist/cjs/index.d.ts +43 -34
  24. package/dist/cjs/index.d.ts.map +1 -1
  25. package/dist/cjs/index.js +59 -39
  26. package/dist/cjs/index.js.map +1 -1
  27. package/dist/esm/core/guard.d.ts +50 -0
  28. package/dist/esm/core/guard.d.ts.map +1 -0
  29. package/dist/esm/{backend → core}/guard.js +40 -9
  30. package/dist/esm/core/guard.js.map +1 -0
  31. package/dist/esm/core/index.d.ts +8 -0
  32. package/dist/esm/core/index.d.ts.map +1 -0
  33. package/dist/esm/core/index.js +21 -0
  34. package/dist/esm/core/index.js.map +1 -0
  35. package/dist/esm/core/locking.d.ts +23 -0
  36. package/dist/esm/core/locking.d.ts.map +1 -0
  37. package/dist/esm/core/locking.js +56 -0
  38. package/dist/esm/core/locking.js.map +1 -0
  39. package/dist/esm/core/node.d.ts +19 -0
  40. package/dist/esm/core/node.d.ts.map +1 -0
  41. package/dist/esm/core/node.js +61 -0
  42. package/dist/esm/core/node.js.map +1 -0
  43. package/dist/esm/core/types.d.ts +49 -0
  44. package/dist/esm/core/types.d.ts.map +1 -0
  45. package/dist/esm/core/types.js +9 -0
  46. package/dist/esm/core/types.js.map +1 -0
  47. package/dist/esm/index.d.ts +43 -34
  48. package/dist/esm/index.d.ts.map +1 -1
  49. package/dist/esm/index.js +53 -34
  50. package/dist/esm/index.js.map +1 -1
  51. package/esm.js +58 -58
  52. package/index.d.ts +32 -8
  53. package/package.json +6 -6
  54. package/dist/cjs/backend/guard.d.ts +0 -31
  55. package/dist/cjs/backend/guard.d.ts.map +0 -1
  56. package/dist/cjs/backend/guard.js.map +0 -1
  57. package/dist/cjs/backend/index.d.ts +0 -8
  58. package/dist/cjs/backend/index.d.ts.map +0 -1
  59. package/dist/cjs/backend/index.js +0 -31
  60. package/dist/cjs/backend/index.js.map +0 -1
  61. package/dist/cjs/backend/lockable.d.ts +0 -15
  62. package/dist/cjs/backend/lockable.d.ts.map +0 -1
  63. package/dist/cjs/backend/lockable.js +0 -76
  64. package/dist/cjs/backend/lockable.js.map +0 -1
  65. package/dist/cjs/backend/node.d.ts +0 -36
  66. package/dist/cjs/backend/node.d.ts.map +0 -1
  67. package/dist/cjs/backend/node.js +0 -118
  68. package/dist/cjs/backend/node.js.map +0 -1
  69. package/dist/cjs/backend/types.d.ts +0 -36
  70. package/dist/cjs/backend/types.d.ts.map +0 -1
  71. package/dist/cjs/backend/types.js +0 -3
  72. package/dist/cjs/backend/types.js.map +0 -1
  73. package/dist/esm/backend/guard.d.ts +0 -31
  74. package/dist/esm/backend/guard.d.ts.map +0 -1
  75. package/dist/esm/backend/guard.js.map +0 -1
  76. package/dist/esm/backend/index.d.ts +0 -8
  77. package/dist/esm/backend/index.d.ts.map +0 -1
  78. package/dist/esm/backend/index.js +0 -13
  79. package/dist/esm/backend/index.js.map +0 -1
  80. package/dist/esm/backend/lockable.d.ts +0 -15
  81. package/dist/esm/backend/lockable.d.ts.map +0 -1
  82. package/dist/esm/backend/lockable.js +0 -39
  83. package/dist/esm/backend/lockable.js.map +0 -1
  84. package/dist/esm/backend/node.d.ts +0 -36
  85. package/dist/esm/backend/node.d.ts.map +0 -1
  86. package/dist/esm/backend/node.js +0 -109
  87. package/dist/esm/backend/node.js.map +0 -1
  88. package/dist/esm/backend/types.d.ts +0 -36
  89. package/dist/esm/backend/types.d.ts.map +0 -1
  90. package/dist/esm/backend/types.js +0 -2
  91. package/dist/esm/backend/types.js.map +0 -1
package/README.md CHANGED
@@ -5,14 +5,7 @@
5
5
  [![Version](https://img.shields.io/npm/v/@lickle/lock?style=flat&colorA=000000&colorB=000000)](https://www.npmjs.com/package/@lickle/lock)
6
6
  [![Downloads](https://img.shields.io/npm/dt/@lickle/lock.svg?style=flat&colorA=000000&colorB=000000)](https://www.npmjs.com/package/@lickle/lock)
7
7
 
8
- File-based locking for Node.js using **native OS locks** (`flock` on Unix, `LockFileEx` on Windows).
9
-
10
- Supports:
11
-
12
- - **Exclusive (write) locks**
13
- - **Shared (read) locks**
14
- - **Cross-process / cross-thread coordination**
15
- - **Automatic cleanup** on exit, signals, and garbage collection
8
+ File-based locking for Node.js using native OS locks (`flock` on Unix, `LockFileEx` on Windows).
16
9
 
17
10
  ---
18
11
 
@@ -24,14 +17,14 @@ npm install @lickle/lock
24
17
 
25
18
  ---
26
19
 
27
- # Quick Start
20
+ ## Quick Start
28
21
 
29
22
  ```ts
30
- import { exclusive } from '@lickle/lock'
23
+ import { openLock, Lock } from '@lickle/lock'
31
24
 
32
- await using guard = await exclusive('/tmp/my.lock')
25
+ await using guard = await openLock('/tmp/my.lock', Lock.Exclusive)
33
26
 
34
- // access the files handle through the guard
27
+ // access the file handle through the guard
35
28
  await guard.handle.readFile({ encoding: 'utf-8' })
36
29
 
37
30
  // lock automatically released when guard goes out of scope
@@ -41,18 +34,16 @@ Locks work **across processes and worker threads**, making them suitable for coo
41
34
 
42
35
  ---
43
36
 
44
- # API
45
-
46
- ## `exclusive(file, options?)`
37
+ ## API
47
38
 
48
- Acquire an **exclusive (write) lock**.
39
+ ### `openLock(file, type, options?)`
49
40
 
50
- Blocks until the lock becomes available.
41
+ Open a file and acquire a lock, polling until available. Returns a `FileLockGuard`.
51
42
 
52
43
  ```ts
53
- import { exclusive } from '@lickle/lock'
44
+ import { openLock, Lock } from '@lickle/lock'
54
45
 
55
- const guard = await exclusive('/tmp/my.lock')
46
+ const guard = await openLock('/tmp/my.lock', Lock.Exclusive)
56
47
 
57
48
  try {
58
49
  // critical section
@@ -61,77 +52,101 @@ try {
61
52
  }
62
53
  ```
63
54
 
64
- ---
65
-
66
- ## `shared(file, options?)`
67
-
68
- Acquire a **shared (read) lock**.
55
+ ### `tryOpenLock(file, type, options?)`
69
56
 
70
- Multiple shared locks can coexist, but they block exclusive locks.
57
+ Open a file and try to acquire a lock **without waiting**. Returns `undefined` if the lock is not available.
71
58
 
72
59
  ```ts
73
- import { shared } from '@lickle/lock'
60
+ import { tryOpenLock, Lock } from '@lickle/lock'
74
61
 
75
- const reader1 = await shared('/tmp/data.lock')
76
- const reader2 = await shared('/tmp/data.lock')
62
+ const guard = await tryOpenLock('/tmp/my.lock', Lock.Exclusive)
77
63
 
78
- await reader1.drop()
79
- await reader2.drop()
64
+ if (guard) {
65
+ // acquired lock
66
+ await guard.drop()
67
+ }
80
68
  ```
81
69
 
82
- ---
70
+ ### `lock(handle, type, options?)`
83
71
 
84
- ## `tryExclusive(file)`
72
+ Acquire a lock on an already-open file handle. Returns a `LockGuard` (does not close the file on drop).
85
73
 
86
- Attempt to acquire an exclusive lock **without waiting**.
74
+ ```ts
75
+ import fs from 'node:fs/promises'
76
+ import { lock, Lock } from '@lickle/lock'
77
+
78
+ const handle = await fs.open('/tmp/my.lock', 'r+')
79
+ await using guard = await lock(handle, Lock.Exclusive)
80
+ ```
87
81
 
88
- Returns `undefined` if the lock is already held.
82
+ ### `tryLock(handle, type, options?)`
83
+
84
+ Try to acquire a lock on an already-open file handle **without waiting**. Returns `undefined` if the lock is not available.
89
85
 
90
86
  ```ts
91
- import { tryExclusive } from '@lickle/lock'
87
+ import fs from 'node:fs/promises'
88
+ import { tryLock, Lock } from '@lickle/lock'
92
89
 
93
- const guard = await tryExclusive('/tmp/my.lock')
90
+ const handle = await fs.open('/tmp/my.lock', 'r+')
91
+ const guard = await tryLock(handle, Lock.Exclusive)
94
92
 
95
93
  if (guard) {
96
94
  // acquired lock
97
- await guard.drop()
98
95
  }
99
96
  ```
100
97
 
101
- ---
98
+ ### `unlock(handle, options?)`
102
99
 
103
- ## `tryShared(file)`
104
-
105
- Attempt to acquire a shared lock **without waiting**.
106
-
107
- Returns `undefined` if an exclusive lock is currently held.
100
+ Release a lock on a file descriptor or file handle.
108
101
 
109
102
  ```ts
110
- import { tryShared } from '@lickle/lock'
103
+ import fs from 'node:fs/promises'
104
+ import { lock, unlock, Lock } from '@lickle/lock'
111
105
 
112
- const guard = await tryShared('/tmp/data.lock')
106
+ const handle = await fs.open('/tmp/my.lock', 'r+')
107
+ await lock(handle, Lock.Exclusive)
108
+ // ... critical section ...
109
+ await unlock(handle)
113
110
  ```
114
111
 
115
112
  ---
116
113
 
117
- # Options
114
+ ## Options
118
115
 
119
- Both `exclusive` and `shared` accept:
116
+ `openLock` and `lock` accept `PollOptions`:
120
117
 
121
118
  ```ts
122
119
  {
123
120
  pollMs?: number // polling interval (default: 10ms)
124
121
  timeout?: number // max wait time before throwing
125
- backend?: Backend // custom backend
122
+ backoff?: number // multiplier applied to pollMs after each attempt (e.g. 2 = exponential)
123
+ }
124
+ ```
125
+
126
+ All functions accept `LockingOptions`:
127
+
128
+ ```ts
129
+ {
130
+ locking?: Locker // custom locker implementation
131
+ range?: LockRange // byte range to lock (see Range Locks)
132
+ }
133
+ ```
134
+
135
+ `openLock` and `tryOpenLock` additionally accept:
136
+
137
+ ```ts
138
+ {
139
+ fs?: Fs // custom filesystem for opening files
126
140
  }
127
141
  ```
128
142
 
129
143
  Example:
130
144
 
131
145
  ```ts
132
- await exclusive('/tmp/my.lock', {
133
- pollMs: 20,
134
- timeout: 1000,
146
+ await openLock('/tmp/my.lock', Lock.Exclusive, {
147
+ pollMs: 10,
148
+ backoff: 2,
149
+ timeout: 5000,
135
150
  })
136
151
  ```
137
152
 
@@ -143,66 +158,157 @@ Error: Timed out acquiring lock
143
158
 
144
159
  ---
145
160
 
146
- # FileGuard
161
+ ## Range Locks
162
+
163
+ Lock a specific byte range within a file instead of the entire file. This allows multiple processes to lock different regions concurrently.
164
+
165
+ ```ts
166
+ import { openLock, Lock } from '@lickle/lock'
167
+
168
+ // lock bytes 0–99
169
+ await using header = await openLock('/tmp/data.bin', Lock.Exclusive, {
170
+ range: { offset: 0, length: 100 },
171
+ })
172
+
173
+ // lock bytes 100–199 concurrently — no conflict
174
+ await using body = await openLock('/tmp/data.bin', Lock.Exclusive, {
175
+ range: { offset: 100, length: 100 },
176
+ })
177
+ ```
178
+
179
+ Range locks also work with `tryOpenLock`, `lock`, and `tryLock`.
180
+
181
+ ```ts
182
+ const guard = await tryOpenLock('/tmp/data.bin', Lock.Exclusive, {
183
+ range: { offset: 0, length: 512 },
184
+ })
185
+ ```
186
+
187
+ When no range is specified, the entire file is locked (the default).
188
+
189
+ See [Platform Notes](#platform-notes) for important platform-specific behavior of range locks.
190
+
191
+ ---
192
+
193
+ ## Guards
147
194
 
148
- Lock functions return a **`FileGuard`**.
195
+ Lock functions return guards that manage the lifetime of the lock.
149
196
 
150
- The guard manages the lifetime of the lock.
197
+ ### `FileLockGuard`
198
+
199
+ Returned by `openLock` and `tryOpenLock`. Owns both the lock and the file handle — dropping it unlocks and closes the file.
151
200
 
152
201
  ```ts
153
- const guard = await exclusive('/tmp/my.lock')
202
+ const guard = await openLock('/tmp/my.lock', Lock.Exclusive)
154
203
 
155
204
  guard.handle // fs.promises.FileHandle
156
205
  guard.fd // file descriptor
157
- guard.path // lock file path
158
206
  guard.dropped // boolean
159
207
 
160
208
  await guard.drop()
161
209
  ```
162
210
 
163
- ---
211
+ ### `LockGuard`
212
+
213
+ Returned by `lock` and `tryLock`. Owns only the lock — dropping it unlocks but does not close the file.
214
+
215
+ ```ts
216
+ const guard = await lock(handle, Lock.Exclusive)
164
217
 
165
- ## Automatic cleanup with `await using`
218
+ guard.fd // file descriptor
219
+ guard.dropped // boolean
220
+
221
+ await guard.drop()
222
+ ```
223
+
224
+ ### Automatic cleanup with `await using`
166
225
 
167
- `FileGuard` implements the **Explicit Resource Management** proposal.
226
+ Both guards implement the [Explicit Resource Management](https://github.com/tc39/proposal-explicit-resource-management) proposal.
168
227
 
169
228
  ```ts
170
- await using guard = await exclusive('/tmp/my.lock')
229
+ await using guard = await openLock('/tmp/my.lock', Lock.Exclusive)
171
230
 
172
231
  const text = await guard.handle.readFile('utf8')
173
232
  ```
174
233
 
175
234
  The lock is automatically released when the scope exits.
176
235
 
177
- Spec:
178
- [https://github.com/tc39/proposal-explicit-resource-management](https://github.com/tc39/proposal-explicit-resource-management)
179
-
180
236
  ---
181
237
 
182
- # Cleanup Guarantees
238
+ ## Customization
239
+
240
+ The default implementation uses native OS locks and Node.js `fs` for file operations. You can customize either piece independently.
183
241
 
184
- Locks are released automatically when:
242
+ ### `Fs` file opening
185
243
 
186
- - the process exits
187
- - `SIGINT` or `SIGTERM` is received
188
- - a worker thread shuts down
189
- - the guard is garbage collected
244
+ Controls how lock files are opened. Implement the `Fs` interface to use alternative file systems.
190
245
 
191
- This prevents stale locks if your program crashes.
246
+ ```ts
247
+ import { openLock, Lock, type Fs } from '@lickle/lock'
248
+
249
+ const myFs: Fs<MyHandle> = {
250
+ open: (file, type) => /* your open logic */,
251
+ }
252
+
253
+ await openLock('/tmp/my.lock', Lock.Exclusive, { fs: myFs })
254
+ ```
255
+
256
+ ### `Locker` — lock operations
257
+
258
+ Controls how locks are acquired and released. Use `createLocker()` with custom hooks or implement `Locker` directly.
259
+
260
+ ```ts
261
+ import { lock, Lock, createLocker } from '@lickle/lock'
262
+
263
+ const locker = createLocker(myHooks)
264
+ await lock(handle, Lock.Exclusive, { locking: locker })
265
+ ```
266
+
267
+ The default locker uses:
268
+
269
+ - **Unix:** `flock(2)` (whole-file), `fcntl(2)` (byte-range)
270
+ - **Windows:** `LockFileEx` / `UnlockFileEx` (both whole-file and byte-range)
192
271
 
193
272
  ---
194
273
 
195
- # Backends
274
+ ## Platform Notes
275
+
276
+ The locking primitives used by this library differ across operating systems. The table below summarizes the syscalls and their behavior.
277
+
278
+ | | Whole-file lock | Range lock | Range lock scope |
279
+ | --------------- | --------------- | ----------------------------------------- | -------------------- |
280
+ | **Linux** | `flock(2)` | `fcntl(2)` OFD locks (`F_OFD_SETLK`) | per file-description |
281
+ | **macOS / BSD** | `flock(2)` | `fcntl(2)` POSIX record locks (`F_SETLK`) | per process |
282
+ | **Windows** | `LockFileEx` | `LockFileEx` (with offset/length) | per handle |
283
+
284
+ ### Linux
285
+
286
+ Range locks use Open File Description (OFD) locks, available since Linux 3.15. OFD locks are scoped to the **file description** (the kernel object behind an `open()` call), not the process. This means different threads holding different file descriptors can hold independent range locks safely.
287
+
288
+ ### Windows
289
+
290
+ Both whole-file and range locks use `LockFileEx`/`UnlockFileEx`. Locks are scoped to the file handle and do not interfere across handles within the same process.
291
+
292
+ ### macOS / BSD — POSIX record lock caveat
293
+
294
+ On macOS and BSD, range locks use classic POSIX record locks (`fcntl` with `F_SETLK`/`F_SETLKW`). These locks have a well-documented flaw in the POSIX.1 specification:
295
+
296
+ > **Closing _any_ file descriptor for a given file releases _all_ locks the process holds on that file.**
297
+
298
+ If thread A holds a range lock on `data.db` via fd 5, and thread B independently opens `data.db`, reads a byte, and closes its fd, the kernel silently releases thread A's lock. This is specified behavior — not a bug.
299
+
300
+ This means **range locks on macOS are not safe for intra-process concurrency** when multiple threads or code paths may open the same file. The lock can vanish without warning.
196
301
 
197
- The default backend uses native OS locks:
302
+ Whole-file locks (`flock`) are **not** affected by this issue — `flock` and `fcntl` are independent locking systems.
198
303
 
199
- - **Unix:** `flock`
200
- - **Windows:** `LockFileEx`
304
+ **Recommendations:**
201
305
 
202
- You can implement custom backends for alternative file systems or environments.
306
+ - For **inter-process** locking (one lock holder per process), range locks work correctly on all platforms.
307
+ - For **intra-process** locking (multiple threads in the same process), range locks are safe on **Linux** (OFD locks) and **Windows**, but **not on macOS/BSD**.
308
+ - On macOS, if you need concurrent range locks within a single process, ensure only one file descriptor for the target file is open at a time across all threads, or use whole-file locks instead.
203
309
 
204
310
  ---
205
311
 
206
- # License
312
+ ## License
207
313
 
208
314
  MIT © Dan Beaven