@wangxiaocuo/dsh-session-trash 0.0.0-stage → 0.5.1

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/lib/state.js ADDED
@@ -0,0 +1,147 @@
1
+ import fs from 'node:fs/promises'
2
+
3
+ /** Maximum concurrent filesystem probes while describing entries. */
4
+ const PROBE_CONCURRENCY = 3
5
+
6
+ /**
7
+ * @param {readonly T[]} items input items.
8
+ * @param {number} limit maximum in-flight operations.
9
+ * @param {(item: T) => Promise<R>} worker per-item operation.
10
+ * @returns {Promise<R[]>} results in input order.
11
+ * @template T, R
12
+ */
13
+ async function mapLimit(items, limit, worker) {
14
+ const results = new Array(items.length)
15
+ let next = 0
16
+ const runners = Array.from({ length: Math.min(limit, items.length) }, async () => {
17
+ while (next < items.length) {
18
+ const index = next
19
+ next += 1
20
+ results[index] = await worker(items[index])
21
+ }
22
+ })
23
+ await Promise.all(runners)
24
+ return results
25
+ }
26
+
27
+ /**
28
+ * @param {string | null | undefined} directory candidate workspace directory.
29
+ * @returns {Promise<boolean>} whether the directory exists right now.
30
+ */
31
+ async function directoryExists(directory) {
32
+ if (typeof directory !== 'string' || directory === '') return false
33
+ try {
34
+ const stats = await fs.stat(directory)
35
+ return stats.isDirectory()
36
+ } catch {
37
+ return false
38
+ }
39
+ }
40
+
41
+ /**
42
+ * Build the read-only `/state` projection from the committed index.
43
+ *
44
+ * @param {{
45
+ * store: import('./store.js').TrashStore,
46
+ * config: { retentionDays: number, sweepIntervalHours: number, enableAutoCleanup: boolean },
47
+ * trashRootDisplay: string,
48
+ * nextSweepAt?: () => number | null,
49
+ * cleanup?: () => object,
50
+ * }} deps state inputs.
51
+ * @returns {{ snapshot: () => Promise<object>, describe: (entry: object) => Promise<object> }} the state service.
52
+ */
53
+ export function createStateService({ store, config, trashRootDisplay, nextSweepAt = () => null, cleanup }) {
54
+ /**
55
+ * @returns {object} cleanup diagnostics, or a neutral fallback.
56
+ */
57
+ function cleanupDiagnostics() {
58
+ if (typeof cleanup === 'function') {
59
+ try {
60
+ const value = cleanup()
61
+ if (value !== null && typeof value === 'object') return value
62
+ } catch {
63
+ // Diagnostics must never break the read-only state projection.
64
+ }
65
+ }
66
+ return {
67
+ enabled: config.enableAutoCleanup,
68
+ timerBacked: false,
69
+ lastAttemptAt: null,
70
+ lastSuccessAt: null,
71
+ nextSweepAt: nextSweepAt(),
72
+ retryAt: null,
73
+ consecutiveFailures: 0,
74
+ lastRun: null,
75
+ }
76
+ }
77
+
78
+ /**
79
+ * @param {object} entry committed index entry.
80
+ * @returns {Promise<object>} the wire entry.
81
+ */
82
+ async function describeEntry(entry) {
83
+ const cwd = typeof entry.originalCwd === 'string' && entry.originalCwd !== '' ? entry.originalCwd : null
84
+ const workspaceNowExists = cwd === null ? null : await directoryExists(cwd)
85
+ const expiresAt =
86
+ typeof entry.trashedAt === 'number' && entry.trashedAt > 0 ? entry.trashedAt + config.retentionDays * 86_400_000 : null
87
+ const trashed = entry.state === 'trashed'
88
+ return {
89
+ sessionId: entry.sessionId,
90
+ title: entry.title,
91
+ trashedAt: entry.trashedAt,
92
+ expiresAt,
93
+ originalCwd: cwd,
94
+ workspaceExistsAtDeletion:
95
+ typeof entry.workspaceExistsAtDeletion === 'boolean' ? entry.workspaceExistsAtDeletion : null,
96
+ workspaceNowExists,
97
+ sizeBytes: typeof entry.sizeBytes === 'number' ? entry.sizeBytes : null,
98
+ fileCount: typeof entry.fileCount === 'number' ? entry.fileCount : null,
99
+ formatStatus: entry.formatStatus ?? null,
100
+ state: entry.state,
101
+ canRestore: trashed,
102
+ canPurge: trashed,
103
+ canForget: entry.state === 'missing',
104
+ requiresOrphanRestore: cwd !== null && workspaceNowExists === false,
105
+ lastError: entry.lastError ?? null,
106
+ }
107
+ }
108
+
109
+ return {
110
+ /**
111
+ * @param {object} entry committed index entry.
112
+ * @returns {Promise<object>} the wire entry.
113
+ */
114
+ describe: describeEntry,
115
+ /**
116
+ * @returns {Promise<object>} the complete `/state` response body.
117
+ */
118
+ async snapshot() {
119
+ const entries = await mapLimit(store.entries(), PROBE_CONCURRENCY, (entry) => describeEntry(entry))
120
+ let totalBytes = 0
121
+ let failedEntries = 0
122
+ for (const entry of entries) {
123
+ if (typeof entry.sizeBytes === 'number') totalBytes += entry.sizeBytes
124
+ if (typeof entry.lastError === 'string' && entry.lastError !== '') failedEntries += 1
125
+ }
126
+ const diagnostics = cleanupDiagnostics()
127
+ return {
128
+ version: 1,
129
+ config: {
130
+ retentionDays: config.retentionDays,
131
+ sweepIntervalHours: config.sweepIntervalHours,
132
+ enableAutoCleanup: config.enableAutoCleanup,
133
+ },
134
+ trashRootDisplay,
135
+ index: store.describe(),
136
+ entries,
137
+ stats: {
138
+ count: entries.length,
139
+ totalBytes,
140
+ failedEntries,
141
+ nextSweepAt: diagnostics.nextSweepAt ?? nextSweepAt(),
142
+ cleanup: diagnostics,
143
+ },
144
+ }
145
+ },
146
+ }
147
+ }
package/lib/store.js ADDED
@@ -0,0 +1,545 @@
1
+ import fs from 'node:fs/promises'
2
+ import os from 'node:os'
3
+ import path from 'node:path'
4
+ import { TrashError } from './errors.js'
5
+ import { isInside, removePathWithRetry, renameWithRetry, samePath } from './fsops.js'
6
+
7
+ /** On-disk index schema version understood by this release. */
8
+ export const INDEX_VERSION = 1
9
+
10
+ /** A lock older than this is assumed abandoned and may be taken over. */
11
+ export const LOCK_STALE_MS = 10 * 60 * 1000
12
+
13
+ /** How often a long-running operation refreshes its lock heartbeat. */
14
+ export const LOCK_REFRESH_MS = 60 * 1000
15
+
16
+ /**
17
+ * @param {number} [now] timestamp for the new index.
18
+ * @returns {{ version: number, updatedAt: number, entries: never[] }} an empty index document.
19
+ */
20
+ export function emptyIndex(now = Date.now()) {
21
+ return { version: INDEX_VERSION, updatedAt: now, entries: [] }
22
+ }
23
+
24
+ /**
25
+ * Validate the persisted index shape strictly enough that a truncated or
26
+ * hand-edited file enters protection mode instead of silently vanishing.
27
+ *
28
+ * @param {unknown} value parsed JSON document.
29
+ * @returns {boolean} whether the document is a supported index.
30
+ */
31
+ export function isValidIndex(value) {
32
+ if (value === null || typeof value !== 'object' || Array.isArray(value)) return false
33
+ if (value.version !== INDEX_VERSION) return false
34
+ if (typeof value.updatedAt !== 'number' || !Number.isFinite(value.updatedAt)) return false
35
+ if (!Array.isArray(value.entries)) return false
36
+ const seen = new Set()
37
+ for (const entry of value.entries) {
38
+ if (entry === null || typeof entry !== 'object' || Array.isArray(entry)) return false
39
+ if (typeof entry.sessionId !== 'string' || entry.sessionId === '') return false
40
+ if (typeof entry.trashId !== 'string' || !/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(entry.trashId)) return false
41
+ if (typeof entry.state !== 'string') return false
42
+ if (typeof entry.trashedAt !== 'number' || !Number.isFinite(entry.trashedAt)) return false
43
+ if (seen.has(entry.sessionId)) return false
44
+ seen.add(entry.sessionId)
45
+ }
46
+ return true
47
+ }
48
+
49
+ /**
50
+ * @param {string} file path whose permissions should be tightened.
51
+ * @param {number} mode desired permission bits.
52
+ * @param {{ warn: Function }} logger diagnostics sink.
53
+ * @returns {Promise<void>} resolution once the attempt completes.
54
+ */
55
+ async function tighten(file, mode, logger) {
56
+ try {
57
+ await fs.chmod(file, mode)
58
+ } catch (error) {
59
+ logger.warn(`cannot tighten permissions on ${file}: ${error.message}`)
60
+ }
61
+ }
62
+
63
+ /**
64
+ * @param {string} dir path whose permissions should be tightened.
65
+ * @param {{ warn: Function }} logger diagnostics sink.
66
+ * @returns {Promise<void>} resolution once the attempt completes.
67
+ */
68
+ async function ensurePrivateDir(dir, logger) {
69
+ await fs.mkdir(dir, { recursive: true, mode: 0o700 })
70
+ await tighten(dir, 0o700, logger)
71
+ }
72
+
73
+ /** Single-writer index, layout, and cross-process lock for one trash root. */
74
+ export class TrashStore {
75
+ /**
76
+ * @param {{ root: string, logger: { info: Function, warn: Function, error: Function, debug: Function } }} options trash root and diagnostics sink.
77
+ */
78
+ constructor({ root, logger }) {
79
+ this.root = root
80
+ this.logger = logger
81
+ this.indexPath = path.join(root, 'trash-index.json')
82
+ this.bakPath = path.join(root, 'trash-index.json.bak')
83
+ this.tmpPath = path.join(root, 'trash-index.json.tmp')
84
+ this.lockPath = path.join(root, '.lock')
85
+ this.objectsDir = path.join(root, 'objects')
86
+ this.index = emptyIndex()
87
+ this.status = 'missing'
88
+ this.error = null
89
+ this.unindexed = 0
90
+ this.lockOwned = false
91
+ this.lockTimer = null
92
+ this.queue = Promise.resolve()
93
+ }
94
+
95
+ /**
96
+ * Create the trash layout and load the durable index.
97
+ *
98
+ * @returns {Promise<{ status: string, updatedAt: number, error: string | null }>} the index status summary.
99
+ */
100
+ async init() {
101
+ await ensurePrivateDir(this.root, this.logger)
102
+ await ensurePrivateDir(this.objectsDir, this.logger)
103
+ await this.load()
104
+ if (this.status === 'missing') {
105
+ await this.writeIndex(this.index.entries)
106
+ } else if (this.status === 'corrupt') {
107
+ this.logger.error(`trash index is unreadable and no usable backup exists: ${this.error}`)
108
+ } else if (this.status === 'recovered') {
109
+ this.logger.warn(`trash index recovered from backup: ${this.error}`)
110
+ }
111
+ return this.describe()
112
+ }
113
+
114
+ /**
115
+ * @returns {{ status: string, updatedAt: number, error: string | null, unindexed: number }} the committed index summary.
116
+ */
117
+ describe() {
118
+ return { status: this.status, updatedAt: this.index.updatedAt, error: this.error, unindexed: this.unindexed }
119
+ }
120
+
121
+ /**
122
+ * @returns {readonly object[]} the committed entries.
123
+ */
124
+ entries() {
125
+ return this.index.entries
126
+ }
127
+
128
+ /**
129
+ * @param {string} sessionId session to look up.
130
+ * @returns {object | undefined} the index entry for this session.
131
+ */
132
+ findEntry(sessionId) {
133
+ return this.index.entries.find((entry) => entry.sessionId === sessionId)
134
+ }
135
+
136
+ /**
137
+ * @returns {boolean} whether writes are refused because the index is unreadable.
138
+ */
139
+ isProtected() {
140
+ return this.status === 'corrupt'
141
+ }
142
+
143
+ /**
144
+ * @param {string} trashId object identity.
145
+ * @returns {string} the object directory inside `objects/`.
146
+ */
147
+ objectDir(trashId) {
148
+ return path.join(this.objectsDir, trashId)
149
+ }
150
+
151
+ /**
152
+ * @param {string} trashId object identity.
153
+ * @returns {string} the moved session directory inside the object directory.
154
+ */
155
+ sessionPath(trashId) {
156
+ return path.join(this.objectDir(trashId), 'session')
157
+ }
158
+
159
+ /**
160
+ * @param {string} trashId object identity.
161
+ * @returns {string} the self-describing metadata path inside the object directory.
162
+ */
163
+ metaPath(trashId) {
164
+ return path.join(this.objectDir(trashId), 'meta.json')
165
+ }
166
+
167
+ /**
168
+ * Create one private object directory.
169
+ *
170
+ * @param {string} trashId object identity.
171
+ * @returns {Promise<void>} resolution once the directory exists.
172
+ */
173
+ async createObjectDir(trashId) {
174
+ await ensurePrivateDir(this.objectDir(trashId), this.logger)
175
+ }
176
+
177
+ /**
178
+ * Remove one object directory after proving it stays inside `objects/`.
179
+ *
180
+ * @param {string} trashId object identity.
181
+ * @returns {Promise<void>} resolution once the directory is gone.
182
+ */
183
+ async removeObjectDir(trashId) {
184
+ const target = path.resolve(this.objectDir(trashId))
185
+ const objects = path.resolve(this.objectsDir)
186
+ if (samePath(objects, target) || !isInside(objects, target)) {
187
+ throw new TrashError('IO_ERROR', 'Refusing to remove a trash object outside the trash root.')
188
+ }
189
+ await fs.rm(target, { recursive: true, force: true, maxRetries: 3, retryDelay: 100 })
190
+ }
191
+
192
+ /**
193
+ * @param {string} trashId object identity.
194
+ * @returns {Promise<object | undefined>} the parsed sidecar metadata, when present.
195
+ */
196
+ async readMeta(trashId) {
197
+ try {
198
+ return await this.readJson(this.metaPath(trashId))
199
+ } catch (error) {
200
+ if (error !== null && error !== undefined && error.code === 'ENOENT') return undefined
201
+ throw error
202
+ }
203
+ }
204
+
205
+ /**
206
+ * Remove the plugin-owned metadata of one forgotten object. Session files and
207
+ * any other unexpected entry are preserved; the object directory itself is
208
+ * removed only once it is empty.
209
+ *
210
+ * @param {string} trashId object identity.
211
+ * @returns {Promise<void>} resolution once the best-effort cleanup ends.
212
+ */
213
+ async removeObjectRemnants(trashId) {
214
+ const directory = this.objectDir(trashId)
215
+ let names
216
+ try {
217
+ names = await fs.readdir(directory)
218
+ } catch (error) {
219
+ if (error !== null && error !== undefined && error.code === 'ENOENT') return
220
+ throw error
221
+ }
222
+ for (const name of names) {
223
+ if (name !== 'meta.json' && name !== 'meta.json.tmp') continue
224
+ await fs.rm(path.join(directory, name), { force: true })
225
+ }
226
+ try {
227
+ await fs.rmdir(directory)
228
+ } catch (error) {
229
+ const code = error !== null && error !== undefined ? error.code : undefined
230
+ if (code !== 'ENOENT' && code !== 'ENOTEMPTY' && code !== 'EEXIST') throw error
231
+ }
232
+ }
233
+
234
+ /**
235
+ * Atomically write one object sidecar. Metadata is written before the session
236
+ * directory moves, so a damaged index can rebuild from it later.
237
+ *
238
+ * @param {object} entry index-shaped entry carrying `trashId`.
239
+ * @returns {Promise<void>} resolution once the sidecar is durable.
240
+ */
241
+ async writeMeta(entry) {
242
+ const target = this.metaPath(entry.trashId)
243
+ try {
244
+ const parent = await fs.stat(path.dirname(target))
245
+ if (!parent.isDirectory()) return
246
+ } catch (error) {
247
+ if (error !== null && error !== undefined && error.code === 'ENOENT') return
248
+ throw error
249
+ }
250
+ const temporary = `${target}.tmp`
251
+ const json = `${JSON.stringify({ version: INDEX_VERSION, ...entry }, null, 2)}\n`
252
+ try {
253
+ const handle = await fs.open(temporary, 'w', 0o600)
254
+ try {
255
+ await handle.writeFile(json, 'utf8')
256
+ await handle.sync()
257
+ } finally {
258
+ await handle.close()
259
+ }
260
+ await renameWithRetry(temporary, target)
261
+ } catch (error) {
262
+ await removePathWithRetry(temporary, { recursive: false, force: true }).catch(() => {})
263
+ throw error
264
+ }
265
+ await tighten(target, 0o600, this.logger)
266
+ }
267
+
268
+ /**
269
+ * Remove one original session directory after confirming it is a plain
270
+ * directory outside the trash root. Used only after a verified copy exists.
271
+ *
272
+ * @param {string} sessionDir original session-owned directory.
273
+ * @returns {Promise<void>} resolution once the directory is gone.
274
+ */
275
+ async removeSessionSource(sessionDir) {
276
+ const target = path.resolve(sessionDir)
277
+ if (isInside(this.root, target)) {
278
+ throw new TrashError('IO_ERROR', 'Refusing to remove a session directory inside the trash root.')
279
+ }
280
+ const stats = await fs.lstat(target)
281
+ if (!stats.isDirectory() || stats.isSymbolicLink()) {
282
+ throw new TrashError('UNSAFE_SESSION_DIR', 'Refusing to remove a session directory that is not a plain directory.')
283
+ }
284
+ await fs.rm(target, { recursive: true, force: false, maxRetries: 3, retryDelay: 100 })
285
+ }
286
+
287
+ /**
288
+ * Commit a complete entry list and refresh the in-memory projection.
289
+ *
290
+ * @param {readonly object[]} entries complete new entry list.
291
+ * @returns {Promise<void>} resolution once committed.
292
+ */
293
+ async commit(entries) {
294
+ await this.writeIndex(entries)
295
+ }
296
+
297
+ /** Reload the committed index from disk. Callers serializing writes use this
298
+ * after taking the lock so another process's commit is never clobbered. */
299
+ async refresh() {
300
+ await this.load()
301
+ }
302
+
303
+ /**
304
+ * @param {string} file index file to read.
305
+ * @returns {Promise<unknown>} parsed JSON.
306
+ */
307
+ async readJson(file) {
308
+ return JSON.parse(await fs.readFile(file, 'utf8'))
309
+ }
310
+
311
+ /**
312
+ * Load the index, falling back to the previous committed backup.
313
+ *
314
+ * @returns {Promise<void>} resolution once `index` and `status` are set.
315
+ */
316
+ async load() {
317
+ try {
318
+ const data = await this.readJson(this.indexPath)
319
+ if (!isValidIndex(data)) throw new Error('unsupported index shape or version')
320
+ this.index = data
321
+ this.status = 'ok'
322
+ this.error = null
323
+ return
324
+ } catch (error) {
325
+ if (error && error.code === 'ENOENT') {
326
+ this.index = emptyIndex()
327
+ this.status = 'missing'
328
+ this.error = null
329
+ return
330
+ }
331
+ const primary = error instanceof Error ? error.message : String(error)
332
+ try {
333
+ const backup = await this.readJson(this.bakPath)
334
+ if (!isValidIndex(backup)) throw new Error('unsupported backup shape or version')
335
+ this.index = backup
336
+ this.status = 'recovered'
337
+ this.error = primary
338
+ return
339
+ } catch (backupError) {
340
+ const secondary = backupError instanceof Error ? backupError.message : String(backupError)
341
+ this.index = emptyIndex()
342
+ this.status = 'corrupt'
343
+ this.error = `${primary}; backup unavailable: ${secondary}`
344
+ }
345
+ }
346
+ }
347
+
348
+ /**
349
+ * Atomically commit the index: write a temporary file, preserve the previous
350
+ * commit as `.bak`, then rename over the live index.
351
+ *
352
+ * @param {readonly object[]} entries the complete entry list to persist.
353
+ * @returns {Promise<{ version: number, updatedAt: number, entries: readonly object[] }>} the committed document.
354
+ */
355
+ async writeIndex(entries) {
356
+ const data = { version: INDEX_VERSION, updatedAt: Date.now(), entries }
357
+ const json = `${JSON.stringify(data, null, 2)}\n`
358
+ try {
359
+ const handle = await fs.open(this.tmpPath, 'w', 0o600)
360
+ try {
361
+ await handle.writeFile(json, 'utf8')
362
+ await handle.sync()
363
+ } finally {
364
+ await handle.close()
365
+ }
366
+ try {
367
+ await fs.copyFile(this.indexPath, this.bakPath)
368
+ await tighten(this.bakPath, 0o600, this.logger)
369
+ } catch (error) {
370
+ if (!error || error.code !== 'ENOENT') {
371
+ this.logger.warn(`cannot preserve the previous trash index: ${error instanceof Error ? error.message : String(error)}`)
372
+ }
373
+ }
374
+ await renameWithRetry(this.tmpPath, this.indexPath)
375
+ } catch (error) {
376
+ await removePathWithRetry(this.tmpPath, { recursive: false, force: true }).catch(() => {})
377
+ throw error
378
+ }
379
+ this.index = data
380
+ this.status = 'ok'
381
+ this.error = null
382
+ return data
383
+ }
384
+
385
+ /**
386
+ * Serialize mutations inside this process.
387
+ *
388
+ * @param {() => Promise<T>} operation critical section.
389
+ * @returns {Promise<T>} the operation result.
390
+ * @template T
391
+ */
392
+ runExclusive(operation) {
393
+ const run = this.queue.then(() => operation())
394
+ this.queue = run.then(
395
+ () => undefined,
396
+ () => undefined,
397
+ )
398
+ return run
399
+ }
400
+
401
+ /**
402
+ * Run one operation under both the in-process queue and the cross-process lock.
403
+ *
404
+ * @param {() => Promise<T>} operation critical section.
405
+ * @returns {Promise<T>} the operation result.
406
+ * @template T
407
+ */
408
+ withLock(operation) {
409
+ return this.runExclusive(async () => {
410
+ await this.acquireLock()
411
+ this.startLockHeartbeat()
412
+ try {
413
+ await this.refresh()
414
+ return await operation()
415
+ } finally {
416
+ this.stopLockHeartbeat()
417
+ await this.releaseLock()
418
+ }
419
+ })
420
+ }
421
+
422
+ /**
423
+ * Rewrite the lock timestamp while an operation outlives the stale window.
424
+ * A lock now owned by a different process is abandoned instead of stolen
425
+ * back.
426
+ *
427
+ * @returns {Promise<void>} resolution once the heartbeat attempt ends.
428
+ */
429
+ async refreshLock() {
430
+ if (!this.lockOwned) return
431
+ let current
432
+ try {
433
+ current = JSON.parse(await fs.readFile(this.lockPath, 'utf8'))
434
+ } catch {
435
+ this.stopLockHeartbeat()
436
+ this.lockOwned = false
437
+ return
438
+ }
439
+ if (current?.pid !== process.pid || current?.host !== os.hostname()) {
440
+ this.stopLockHeartbeat()
441
+ this.lockOwned = false
442
+ return
443
+ }
444
+ try {
445
+ await fs.writeFile(
446
+ this.lockPath,
447
+ JSON.stringify({ pid: process.pid, host: os.hostname(), startedAt: Date.now() }),
448
+ )
449
+ } catch (error) {
450
+ this.logger.warn(`cannot refresh the trash lock: ${error instanceof Error ? error.message : String(error)}`)
451
+ }
452
+ }
453
+
454
+ /**
455
+ * @returns {void}
456
+ */
457
+ startLockHeartbeat() {
458
+ this.stopLockHeartbeat()
459
+ const timer = setInterval(() => {
460
+ this.refreshLock().catch(() => {})
461
+ }, LOCK_REFRESH_MS)
462
+ timer.unref?.()
463
+ this.lockTimer = timer
464
+ }
465
+
466
+ /**
467
+ * @returns {void}
468
+ */
469
+ stopLockHeartbeat() {
470
+ if (this.lockTimer === null) return
471
+ clearInterval(this.lockTimer)
472
+ this.lockTimer = null
473
+ }
474
+
475
+ /**
476
+ * A lock is abandoned only when its recorded start time (or, for a partially
477
+ * written file, its mtime) is older than {@link LOCK_STALE_MS}.
478
+ *
479
+ * @returns {Promise<boolean>} whether an existing lock may be taken over.
480
+ */
481
+ async lockIsStale() {
482
+ let stats
483
+ try {
484
+ stats = await fs.stat(this.lockPath)
485
+ } catch (error) {
486
+ return error !== null && error !== undefined && error.code === 'ENOENT'
487
+ }
488
+ try {
489
+ const parsed = JSON.parse(await fs.readFile(this.lockPath, 'utf8'))
490
+ if (parsed !== null && typeof parsed === 'object' && typeof parsed.startedAt === 'number') {
491
+ return Date.now() - parsed.startedAt > LOCK_STALE_MS
492
+ }
493
+ } catch {
494
+ // A partially written lock falls through to the mtime check.
495
+ }
496
+ return Date.now() - stats.mtimeMs > LOCK_STALE_MS
497
+ }
498
+
499
+ /**
500
+ * @returns {Promise<void>} resolution once this process owns the lock.
501
+ */
502
+ async acquireLock() {
503
+ for (let attempt = 0; attempt < 3; attempt += 1) {
504
+ try {
505
+ const handle = await fs.open(this.lockPath, 'wx', 0o600)
506
+ try {
507
+ await handle.writeFile(JSON.stringify({ pid: process.pid, host: os.hostname(), startedAt: Date.now() }))
508
+ } finally {
509
+ await handle.close()
510
+ }
511
+ this.lockOwned = true
512
+ return
513
+ } catch (error) {
514
+ if (!error || error.code !== 'EEXIST') {
515
+ throw new TrashError('IO_ERROR', `Cannot create the trash lock: ${error instanceof Error ? error.message : String(error)}`)
516
+ }
517
+ if (!(await this.lockIsStale())) {
518
+ throw new TrashError('LOCKED', 'Another trash operation is in progress. Try again in a moment.')
519
+ }
520
+ try {
521
+ await removePathWithRetry(this.lockPath, { recursive: false, force: true, attempts: 5, delayMs: 20 })
522
+ } catch (removeError) {
523
+ throw new TrashError('LOCKED', 'A stale trash lock could not be removed. Try again in a moment.', {
524
+ code: removeError instanceof Error && typeof removeError.code === 'string' ? removeError.code : null,
525
+ })
526
+ }
527
+ }
528
+ }
529
+ throw new TrashError('LOCKED', 'Another trash operation is in progress. Try again in a moment.')
530
+ }
531
+
532
+ /**
533
+ * @returns {Promise<void>} resolution once this process no longer holds the lock.
534
+ */
535
+ async releaseLock() {
536
+ this.stopLockHeartbeat()
537
+ if (!this.lockOwned) return
538
+ this.lockOwned = false
539
+ try {
540
+ await removePathWithRetry(this.lockPath, { recursive: false, force: true, attempts: 5, delayMs: 20 })
541
+ } catch (error) {
542
+ this.logger.warn(`cannot release the trash lock: ${error instanceof Error ? error.message : String(error)}`)
543
+ }
544
+ }
545
+ }