@outfitter/file-ops 0.1.0-rc.1 → 0.1.0-rc.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.
package/dist/index.d.ts CHANGED
@@ -87,6 +87,33 @@ interface FileLock {
87
87
  timestamp: number;
88
88
  }
89
89
  /**
90
+ * Represents an acquired shared (reader) file lock.
91
+ *
92
+ * Extends FileLock with a lock type discriminator. Multiple processes can
93
+ * hold shared locks simultaneously, but shared locks block exclusive locks.
94
+ */
95
+ interface SharedFileLock extends FileLock {
96
+ /** Discriminator indicating this is a shared/reader lock */
97
+ lockType: "shared";
98
+ /** Unique identifier for this reader (allows multiple readers from same PID) */
99
+ readerId: string;
100
+ }
101
+ /**
102
+ * Options for lock acquisition.
103
+ */
104
+ interface LockOptions {
105
+ /**
106
+ * Maximum time in milliseconds to wait for lock acquisition.
107
+ * If not specified, fails immediately if lock cannot be acquired.
108
+ */
109
+ timeout?: number;
110
+ /**
111
+ * Interval in milliseconds between retry attempts when waiting.
112
+ * @defaultValue 50
113
+ */
114
+ retryInterval?: number;
115
+ }
116
+ /**
90
117
  * Finds the workspace root by searching upward for marker files/directories.
91
118
  *
92
119
  * Searches from startPath up to the filesystem root (or stopAt if specified),
@@ -193,20 +220,24 @@ declare function glob(pattern: string, options?: GlobOptions): Promise<Result<st
193
220
  */
194
221
  declare function globSync(pattern: string, options?: GlobOptions): Result<string[], InstanceType<typeof InternalError>>;
195
222
  /**
196
- * Acquires an advisory lock on a file.
223
+ * Acquires an exclusive advisory lock on a file.
197
224
  *
198
225
  * Creates a .lock file next to the target file with lock metadata (PID, timestamp).
199
226
  * Uses atomic file creation (wx flag) to prevent race conditions.
200
227
  *
228
+ * Exclusive locks block and are blocked by both shared and exclusive locks.
229
+ * Use shared locks (acquireSharedLock) for read-only operations.
230
+ *
201
231
  * Important: This is advisory locking. All processes must cooperate by using
202
232
  * these locking APIs. The filesystem does not enforce the lock.
203
233
  *
204
234
  * Prefer using withLock for automatic lock release.
205
235
  *
206
236
  * @param path - Absolute path to the file to lock
237
+ * @param options - Lock options including timeout and retry interval
207
238
  * @returns Result containing FileLock on success, or ConflictError if already locked
208
239
  */
209
- declare function acquireLock(path: string): Promise<Result<FileLock, InstanceType<typeof ConflictError>>>;
240
+ declare function acquireLock(path: string, options?: LockOptions): Promise<Result<FileLock, InstanceType<typeof ConflictError>>>;
210
241
  /**
211
242
  * Releases a file lock by removing the .lock file.
212
243
  *
@@ -273,4 +304,45 @@ declare function atomicWrite(path: string, content: string, options?: AtomicWrit
273
304
  * @returns Result indicating success, ValidationError if serialization fails, or InternalError on write failure
274
305
  */
275
306
  declare function atomicWriteJson<T>(path: string, data: T, options?: AtomicWriteOptions): Promise<Result<void, InstanceType<typeof InternalError> | InstanceType<typeof ValidationError>>>;
276
- export { withLock, securePath, resolveSafePath, releaseLock, isPathSafe, isLocked, isInsideWorkspace, globSync, glob, getRelativePath, findWorkspaceRoot, atomicWriteJson, atomicWrite, acquireLock, GlobOptions, FindWorkspaceRootOptions, FileLock, AtomicWriteOptions };
307
+ /**
308
+ * Acquires a shared (reader) lock on a file.
309
+ *
310
+ * Multiple readers can hold shared locks simultaneously. Shared locks are
311
+ * blocked by exclusive locks. Uses the same .lock file as exclusive locks
312
+ * with a JSON format that distinguishes lock types.
313
+ *
314
+ * Important: This is advisory locking. All processes must cooperate by using
315
+ * these locking APIs. The filesystem does not enforce the lock.
316
+ *
317
+ * @param path - Absolute path to the file to lock
318
+ * @param options - Lock options including timeout and retry interval
319
+ * @returns Result containing SharedFileLock on success, or ConflictError if blocked by exclusive lock
320
+ */
321
+ declare function acquireSharedLock(path: string, options?: LockOptions): Promise<Result<SharedFileLock, InstanceType<typeof ConflictError>>>;
322
+ /**
323
+ * Releases a shared (reader) lock.
324
+ *
325
+ * Removes this process from the list of readers in the lock file.
326
+ * If this is the last reader, the lock file is deleted.
327
+ *
328
+ * @param lock - Shared lock object returned from acquireSharedLock
329
+ * @returns Result indicating success, or InternalError if lock file cannot be modified
330
+ */
331
+ declare function releaseSharedLock(lock: SharedFileLock): Promise<Result<void, InstanceType<typeof InternalError>>>;
332
+ /**
333
+ * Executes a callback while holding a shared (reader) lock on a file.
334
+ *
335
+ * Lock is automatically released after callback completes, whether it
336
+ * succeeds or throws an error. This is the recommended way to use shared locks.
337
+ *
338
+ * Multiple readers can hold shared locks simultaneously. Use this for
339
+ * read-only operations that should not block other readers.
340
+ *
341
+ * @typeParam T - Return type of the callback
342
+ * @param path - Absolute path to the file to lock
343
+ * @param callback - Async callback to execute while holding lock
344
+ * @param options - Lock options including timeout and retry interval
345
+ * @returns Result containing callback return value, ConflictError if blocked, or InternalError on failure
346
+ */
347
+ declare function withSharedLock<T>(path: string, callback: () => Promise<T>, options?: LockOptions): Promise<Result<T, InstanceType<typeof ConflictError> | InstanceType<typeof InternalError>>>;
348
+ export { withSharedLock, withLock, securePath, resolveSafePath, releaseSharedLock, releaseLock, isPathSafe, isLocked, isInsideWorkspace, globSync, glob, getRelativePath, findWorkspaceRoot, atomicWriteJson, atomicWrite, acquireSharedLock, acquireLock, SharedFileLock, LockOptions, GlobOptions, FindWorkspaceRootOptions, FileLock, AtomicWriteOptions };
package/dist/index.js CHANGED
@@ -227,31 +227,64 @@ function globSync(pattern, options) {
227
227
  }));
228
228
  }
229
229
  }
230
- async function acquireLock(path) {
230
+ async function acquireLock(path, options) {
231
231
  const lockPath = `${path}.lock`;
232
- const lockFile = Bun.file(lockPath);
233
- if (await lockFile.exists()) {
234
- return Result.err(new ConflictError({
235
- message: `File is already locked: ${path}`
236
- }));
237
- }
238
- const lock = {
239
- path,
240
- lockPath,
241
- pid: process.pid,
242
- timestamp: Date.now()
243
- };
244
- try {
245
- await fsWriteFile(lockPath, JSON.stringify({ pid: lock.pid, timestamp: lock.timestamp }), { flag: "wx" });
246
- } catch (error) {
247
- if (error instanceof Error && "code" in error && error.code === "EEXIST") {
232
+ const startTime = Date.now();
233
+ const timeout = options?.timeout ?? 0;
234
+ const retryInterval = options?.retryInterval ?? 50;
235
+ while (true) {
236
+ const lockFile = Bun.file(lockPath);
237
+ if (await lockFile.exists()) {
238
+ try {
239
+ const lockContent = await lockFile.text();
240
+ const lockData = JSON.parse(lockContent);
241
+ if (lockData.type === "shared" && lockData.readers.length > 0) {
242
+ if (timeout > 0 && Date.now() - startTime < timeout) {
243
+ await Bun.sleep(retryInterval);
244
+ continue;
245
+ }
246
+ return Result.err(new ConflictError({
247
+ message: `File has shared locks: ${path}`
248
+ }));
249
+ }
250
+ } catch {}
251
+ if (timeout > 0 && Date.now() - startTime < timeout) {
252
+ await Bun.sleep(retryInterval);
253
+ continue;
254
+ }
248
255
  return Result.err(new ConflictError({
249
256
  message: `File is already locked: ${path}`
250
257
  }));
251
258
  }
252
- throw error;
259
+ const lock = {
260
+ path,
261
+ lockPath,
262
+ pid: process.pid,
263
+ timestamp: Date.now()
264
+ };
265
+ const exclusiveLockData = {
266
+ type: "exclusive",
267
+ pid: lock.pid,
268
+ timestamp: lock.timestamp
269
+ };
270
+ try {
271
+ await fsWriteFile(lockPath, JSON.stringify(exclusiveLockData), {
272
+ flag: "wx"
273
+ });
274
+ } catch (error) {
275
+ if (error instanceof Error && "code" in error && error.code === "EEXIST") {
276
+ if (timeout > 0 && Date.now() - startTime < timeout) {
277
+ await Bun.sleep(retryInterval);
278
+ continue;
279
+ }
280
+ return Result.err(new ConflictError({
281
+ message: `File is already locked: ${path}`
282
+ }));
283
+ }
284
+ throw error;
285
+ }
286
+ return Result.ok(lock);
253
287
  }
254
- return Result.ok(lock);
255
288
  }
256
289
  async function releaseLock(lock) {
257
290
  try {
@@ -331,10 +364,181 @@ async function atomicWriteJson(path, data, options) {
331
364
  }));
332
365
  }
333
366
  }
367
+ async function acquireSharedLock(path, options) {
368
+ const lockPath = `${path}.lock`;
369
+ const metaLockPath = `${lockPath}.meta`;
370
+ const startTime = Date.now();
371
+ const timeout = options?.timeout ?? 0;
372
+ const retryInterval = options?.retryInterval ?? 50;
373
+ while (true) {
374
+ const metaLockResult = await acquireLock(metaLockPath, {
375
+ timeout: retryInterval,
376
+ retryInterval: 10
377
+ });
378
+ if (metaLockResult.isErr()) {
379
+ if (timeout > 0 && Date.now() - startTime < timeout) {
380
+ await Bun.sleep(retryInterval);
381
+ continue;
382
+ }
383
+ return Result.err(new ConflictError({
384
+ message: `Failed to acquire shared lock: ${path}`
385
+ }));
386
+ }
387
+ const metaLock = metaLockResult.value;
388
+ const lockFile = Bun.file(lockPath);
389
+ const exists = await lockFile.exists();
390
+ if (exists) {
391
+ try {
392
+ const lockContent = await lockFile.text();
393
+ const lockData = JSON.parse(lockContent);
394
+ if (lockData.type === "exclusive") {
395
+ await releaseLock(metaLock);
396
+ if (timeout > 0 && Date.now() - startTime < timeout) {
397
+ await Bun.sleep(retryInterval);
398
+ continue;
399
+ }
400
+ return Result.err(new ConflictError({
401
+ message: `File is exclusively locked: ${path}`
402
+ }));
403
+ }
404
+ const readerId2 = Bun.randomUUIDv7();
405
+ const newReader = {
406
+ id: readerId2,
407
+ pid: process.pid,
408
+ timestamp: Date.now()
409
+ };
410
+ lockData.readers.push(newReader);
411
+ await fsWriteFile(lockPath, JSON.stringify(lockData));
412
+ await releaseLock(metaLock);
413
+ return Result.ok({
414
+ path,
415
+ lockPath,
416
+ pid: process.pid,
417
+ timestamp: newReader.timestamp,
418
+ lockType: "shared",
419
+ readerId: readerId2
420
+ });
421
+ } catch {
422
+ await releaseLock(metaLock);
423
+ if (timeout > 0 && Date.now() - startTime < timeout) {
424
+ await Bun.sleep(retryInterval);
425
+ continue;
426
+ }
427
+ return Result.err(new ConflictError({
428
+ message: `File is locked: ${path}`
429
+ }));
430
+ }
431
+ }
432
+ const readerId = Bun.randomUUIDv7();
433
+ const timestamp = Date.now();
434
+ const sharedLockData = {
435
+ type: "shared",
436
+ readers: [
437
+ {
438
+ id: readerId,
439
+ pid: process.pid,
440
+ timestamp
441
+ }
442
+ ]
443
+ };
444
+ try {
445
+ await fsWriteFile(lockPath, JSON.stringify(sharedLockData), {
446
+ flag: "wx"
447
+ });
448
+ await releaseLock(metaLock);
449
+ return Result.ok({
450
+ path,
451
+ lockPath,
452
+ pid: process.pid,
453
+ timestamp,
454
+ lockType: "shared",
455
+ readerId
456
+ });
457
+ } catch (error) {
458
+ await releaseLock(metaLock);
459
+ if (error instanceof Error && "code" in error && error.code === "EEXIST" && timeout > 0 && Date.now() - startTime < timeout) {
460
+ await Bun.sleep(retryInterval);
461
+ continue;
462
+ }
463
+ return Result.err(new ConflictError({
464
+ message: `Failed to acquire shared lock: ${path}`
465
+ }));
466
+ }
467
+ }
468
+ }
469
+ async function releaseSharedLock(lock) {
470
+ const metaLockPath = `${lock.lockPath}.meta`;
471
+ const metaLockResult = await acquireLock(metaLockPath, {
472
+ timeout: 5000,
473
+ retryInterval: 10
474
+ });
475
+ if (metaLockResult.isErr()) {
476
+ return Result.err(new InternalError({
477
+ message: "Failed to acquire meta-lock for shared lock release"
478
+ }));
479
+ }
480
+ const metaLock = metaLockResult.value;
481
+ try {
482
+ const lockFile = Bun.file(lock.lockPath);
483
+ const exists = await lockFile.exists();
484
+ if (!exists) {
485
+ await releaseLock(metaLock);
486
+ return Result.ok(undefined);
487
+ }
488
+ const lockContent = await lockFile.text();
489
+ const lockData = JSON.parse(lockContent);
490
+ if (lockData.type !== "shared") {
491
+ await releaseLock(metaLock);
492
+ return Result.err(new InternalError({
493
+ message: "Lock file is not a shared lock"
494
+ }));
495
+ }
496
+ lockData.readers = lockData.readers.filter((reader) => reader.id !== lock.readerId);
497
+ if (lockData.readers.length === 0) {
498
+ await unlink(lock.lockPath);
499
+ } else {
500
+ await fsWriteFile(lock.lockPath, JSON.stringify(lockData));
501
+ }
502
+ await releaseLock(metaLock);
503
+ return Result.ok(undefined);
504
+ } catch (error) {
505
+ await releaseLock(metaLock);
506
+ return Result.err(new InternalError({
507
+ message: error instanceof Error ? error.message : "Failed to release shared lock"
508
+ }));
509
+ }
510
+ }
511
+ async function withSharedLock(path, callback, options) {
512
+ const lockResult = await acquireSharedLock(path, options);
513
+ if (lockResult.isErr()) {
514
+ return lockResult;
515
+ }
516
+ const lock = lockResult.value;
517
+ try {
518
+ const result = await callback();
519
+ const releaseResult = await releaseSharedLock(lock);
520
+ if (releaseResult.isErr()) {
521
+ return releaseResult;
522
+ }
523
+ return Result.ok(result);
524
+ } catch (error) {
525
+ const releaseResult = await releaseSharedLock(lock);
526
+ if (releaseResult.isErr()) {
527
+ return Result.err(new InternalError({
528
+ message: `Callback failed: ${error instanceof Error ? error.message : "Unknown error"}; lock release also failed: ${releaseResult.error.message}`
529
+ }));
530
+ }
531
+ return Result.err(new InternalError({
532
+ message: error instanceof Error ? error.message : "Callback failed"
533
+ }));
534
+ }
535
+ }
334
536
  export {
537
+ withSharedLock,
335
538
  withLock,
336
539
  securePath,
337
540
  resolveSafePath,
541
+ releaseSharedLock,
338
542
  releaseLock,
339
543
  isPathSafe,
340
544
  isLocked,
@@ -345,5 +549,6 @@ export {
345
549
  findWorkspaceRoot,
346
550
  atomicWriteJson,
347
551
  atomicWrite,
552
+ acquireSharedLock,
348
553
  acquireLock
349
554
  };
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@outfitter/file-ops",
3
3
  "description": "Workspace detection, secure path handling, and file locking for Outfitter",
4
- "version": "0.1.0-rc.1",
4
+ "version": "0.1.0-rc.2",
5
5
  "type": "module",
6
6
  "files": [
7
7
  "dist"