@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 +75 -3
- package/dist/index.js +224 -19
- package/package.json +1 -1
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
|
-
|
|
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
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
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
|
-
|
|
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
|
};
|