@wangxiaocuo/dsh-session-trash 0.6.3 → 0.6.5

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/errors.js CHANGED
@@ -43,7 +43,7 @@ export class TrashError extends Error {
43
43
  /**
44
44
  * @param {string} code stable error code.
45
45
  * @param {string} message owner-facing explanation.
46
- * @param {object} [details] optional structured context.
46
+ * @param {Record<string, unknown>} [details] optional structured context.
47
47
  */
48
48
  constructor(code, message, details) {
49
49
  super(message)
@@ -87,25 +87,28 @@ const SAFE_DETAIL_KEYS = new Set([
87
87
  * only their numeric counters so no path can ride along.
88
88
  *
89
89
  * @param {unknown} value candidate metrics object.
90
- * @returns {object | undefined} the numeric metrics.
90
+ * @returns {{ sizeBytes?: number, fileCount?: number, symlinkCount?: number } | undefined} the numeric metrics.
91
91
  */
92
92
  function sanitizeMetrics(value) {
93
93
  if (value === null || typeof value !== 'object' || Array.isArray(value)) return undefined
94
+ const record = /** @type {Record<string, unknown>} */ (value)
95
+ /** @type {Record<string, unknown>} */
94
96
  const safe = {}
95
97
  for (const key of ['sizeBytes', 'fileCount', 'symlinkCount']) {
96
- if (typeof value[key] === 'number') safe[key] = value[key]
98
+ if (typeof record[key] === 'number') safe[key] = record[key]
97
99
  }
98
100
  return Object.keys(safe).length === 0 ? undefined : safe
99
101
  }
100
102
 
101
103
  /**
102
104
  * @param {unknown} details arbitrary structured context.
103
- * @returns {object | undefined} the same context restricted to whitelisted keys.
105
+ * @returns {Record<string, unknown> | undefined} the same context restricted to whitelisted keys.
104
106
  */
105
107
  export function sanitizeDetails(details) {
106
108
  if (details === null || typeof details !== 'object' || Array.isArray(details)) return undefined
109
+ /** @type {Record<string, unknown>} */
107
110
  const safe = {}
108
- for (const [key, value] of Object.entries(details)) {
111
+ for (const [key, value] of Object.entries(/** @type {Record<string, unknown>} */ (details))) {
109
112
  if (!SAFE_DETAIL_KEYS.has(key)) continue
110
113
  if (key === 'expected' || key === 'measured') {
111
114
  const metrics = sanitizeMetrics(value)
@@ -123,7 +126,7 @@ export function sanitizeDetails(details) {
123
126
 
124
127
  /**
125
128
  * @param {unknown} error arbitrary thrown value.
126
- * @returns {{ code: string, message: string, details?: object }} the wire error shape.
129
+ * @returns {{ code: string, message: string, details?: Record<string, unknown> }} the wire error shape.
127
130
  */
128
131
  export function toWireError(error) {
129
132
  if (error instanceof TrashError) {
package/lib/fsops.js CHANGED
@@ -40,7 +40,9 @@ const COPY_OPTIONS = {
40
40
  * @returns {string | undefined} the Node error code.
41
41
  */
42
42
  export function errorCode(error) {
43
- return error !== null && typeof error === 'object' && typeof error.code === 'string' ? error.code : undefined
43
+ if (error === null || typeof error !== 'object') return undefined
44
+ const code = /** @type {Record<string, unknown>} */ (error).code
45
+ return typeof code === 'string' ? code : undefined
44
46
  }
45
47
 
46
48
  /**
@@ -286,8 +288,8 @@ export async function measureDirectory(directory) {
286
288
  }
287
289
 
288
290
  /**
289
- * @param {{ sizeBytes?: number | null, fileCount?: number | null } | null | undefined} entry index entry.
290
- * @param {{ sizeBytes: number, fileCount: number }} measured current metrics.
291
+ * @param {{ sizeBytes?: number | null, fileCount?: number | null, symlinkCount?: number | null } | null | undefined} entry index entry.
292
+ * @param {{ sizeBytes: number, fileCount: number, symlinkCount?: number }} measured current metrics.
291
293
  * @returns {boolean} whether the recorded metrics are absent or match exactly.
292
294
  */
293
295
  export function metricsMatch(entry, measured) {
@@ -303,7 +305,7 @@ export function metricsMatch(entry, measured) {
303
305
  }
304
306
 
305
307
  /**
306
- * @param {{ sizeBytes?: number | null, fileCount?: number | null } | null | undefined} entry index entry.
308
+ * @param {{ sizeBytes?: number | null, fileCount?: number | null, symlinkCount?: number | null } | null | undefined} entry index entry.
307
309
  * @returns {boolean} whether the entry carries a complete move snapshot.
308
310
  */
309
311
  export function hasMetrics(entry) {
@@ -339,7 +341,7 @@ export function createTrashId() {
339
341
  * @param {string} destination final directory path.
340
342
  * @param {{ sizeBytes: number, fileCount: number }} expected recorded metrics.
341
343
  * @param {(from: string, to: string) => Promise<void>} rename rename implementation for tests.
342
- * @param {(source: string, destination: string, options: object) => Promise<void>} copy copy implementation for tests.
344
+ * @param {(source: string, destination: string, options: import('node:fs').CopyOptions) => Promise<void>} copy copy implementation for tests.
343
345
  * @returns {Promise<void>} resolution after the destination is published.
344
346
  */
345
347
  async function copyAcrossDevices(source, destination, expected, rename, copy) {
@@ -382,7 +384,7 @@ async function copyAcrossDevices(source, destination, expected, rename, copy) {
382
384
  * @param {{
383
385
  * logger?: { warn: Function },
384
386
  * rename?: (from: string, to: string) => Promise<void>,
385
- * copy?: (source: string, destination: string, options: object) => Promise<void>,
387
+ * copy?: (source: string, destination: string, options: import('node:fs').CopyOptions) => Promise<void>,
386
388
  * }} [options] diagnostics sink and test seams.
387
389
  * @returns {Promise<void>} resolution after the directory lives at `destination`.
388
390
  */
package/lib/http.js CHANGED
@@ -45,7 +45,7 @@ function headerBag(req) {
45
45
  /**
46
46
  * @param {import('node:http').ServerResponse} res response to complete.
47
47
  * @param {number} status HTTP status.
48
- * @param {unknown} body JSON-serializable body.
48
+ * @param {any} body JSON-serializable body.
49
49
  * @returns {void}
50
50
  */
51
51
  function sendJson(res, status, body) {
@@ -62,7 +62,7 @@ function sendJson(res, status, body) {
62
62
 
63
63
  /**
64
64
  * @param {import('node:http').ServerResponse} res response to complete.
65
- * @param {unknown} error thrown failure.
65
+ * @param {any} error thrown failure.
66
66
  * @returns {void}
67
67
  */
68
68
  function sendError(res, error) {
@@ -151,20 +151,25 @@ function requireSessionId(body) {
151
151
  * @param {{
152
152
  * ctx: import('@deepseek-ai/cordis').Context,
153
153
  * eligibility: { evaluate: Function, invalidate: Function },
154
- * stateService: { snapshot: () => Promise<object>, describe: (entry: object) => Promise<object> },
154
+ * stateService: { snapshot: () => Promise<any>, describe: (entry: any) => Promise<any> },
155
155
  * trashService: {
156
- * trash: (sessionId: string) => Promise<object>,
157
- * restore: (sessionId: string, mode?: string) => Promise<object>,
158
- * purge: (sessionId: string) => Promise<object>,
159
- * purgeAll: () => Promise<object>,
160
- * forget: (sessionId: string) => Promise<object>,
161
- * reveal: (sessionId: string, signal?: AbortSignal) => Promise<object>,
162
- * rebuild: () => Promise<object>,
156
+ * trash: (sessionId: string) => Promise<any>,
157
+ * restore: (sessionId: string, mode?: string) => Promise<any>,
158
+ * purge: (sessionId: string) => Promise<any>,
159
+ * purgeAll: () => Promise<any>,
160
+ * forget: (sessionId: string) => Promise<any>,
161
+ * reveal: (sessionId: string, signal?: AbortSignal) => Promise<any>,
162
+ * rebuild: () => Promise<any>,
163
+ * purgeEmptyOrphans: () => Promise<{ removed: number }>,
164
+ * },
165
+ * cleanupService?: {
166
+ * runNow: (options?: any) => Promise<any>,
167
+ * sweepOnPageOpen: () => Promise<{ ran: boolean, reason?: string, result?: any }>,
168
+ * diagnostics?: () => any,
163
169
  * },
164
- * cleanupService?: { runNow: (options?: object) => Promise<object>, maybeSweepOnPageOpen: () => void },
165
170
  * ready: Promise<unknown>,
166
- * config: object,
167
- * logger: object,
171
+ * config: any,
172
+ * logger: any,
168
173
  * }} deps host services.
169
174
  * @returns {void}
170
175
  */
@@ -260,11 +265,28 @@ export function registerApi({ ctx, eligibility, stateService, trashService, clea
260
265
 
261
266
  route('state', 'GET', async (_req, res) => {
262
267
  sendJson(res, 200, await stateService.snapshot())
263
- try {
264
- cleanupService?.maybeSweepOnPageOpen()
265
- } catch (error) {
266
- logger.warn(`cannot schedule the page-open trash sweep: ${error instanceof Error ? error.message : String(error)}`)
267
- }
268
+ })
269
+
270
+ route('sweep', 'POST', async (_req, res) => {
271
+ const outcome = await cleanupService.sweepOnPageOpen()
272
+ const result = outcome.result !== null && outcome.result !== undefined ? outcome.result : null
273
+ sendJson(res, 200, {
274
+ ok: true,
275
+ ran: outcome.ran === true,
276
+ ...(outcome.reason === undefined ? {} : { reason: outcome.reason }),
277
+ ...(result === null
278
+ ? {}
279
+ : {
280
+ purged: result.purged ?? 0,
281
+ failed: result.failed ?? 0,
282
+ settled: result.changed ?? 0,
283
+ }),
284
+ })
285
+ })
286
+
287
+ route('orphans/forget', 'POST', async (_req, res) => {
288
+ const result = await trashService.purgeEmptyOrphans()
289
+ sendJson(res, 200, { ok: true, removed: result.removed ?? 0 })
268
290
  })
269
291
 
270
292
  route('preflight', 'POST', async (req, res) => {
@@ -292,7 +314,11 @@ export function registerApi({ ctx, eligibility, stateService, trashService, clea
292
314
  route('restore', 'POST', async (req, res) => {
293
315
  const body = await readJsonRequest(req)
294
316
  const sessionId = requireSessionId(body)
295
- const mode = body.mode
317
+ const rawMode = body.mode
318
+ if (rawMode !== undefined && typeof rawMode !== 'string') {
319
+ throw new TrashError('INVALID_REQUEST', 'mode must be "normal" or "orphan".')
320
+ }
321
+ const mode = typeof rawMode === 'string' ? rawMode : undefined
296
322
  if (mode !== undefined && mode !== 'normal' && mode !== 'orphan') {
297
323
  throw new TrashError('INVALID_REQUEST', 'mode must be "normal" or "orphan".')
298
324
  }
package/lib/inventory.js CHANGED
@@ -17,7 +17,7 @@ const META_VERSION = 2
17
17
  const MAX_MEMBERS = 64
18
18
 
19
19
  /**
20
- * @param {unknown} value candidate timestamp.
20
+ * @param {any} value candidate timestamp.
21
21
  * @returns {boolean} whether the value is a finite number.
22
22
  */
23
23
  function isFiniteNumber(value) {
@@ -25,7 +25,7 @@ function isFiniteNumber(value) {
25
25
  }
26
26
 
27
27
  /**
28
- * @param {unknown} value candidate metric.
28
+ * @param {any} value candidate metric.
29
29
  * @returns {boolean} whether the value is a usable number or absent.
30
30
  */
31
31
  function isMetric(value) {
@@ -36,7 +36,7 @@ function isMetric(value) {
36
36
  * Validate one v2 member's absolute paths. The parent's sidecar is validated
37
37
  * field by field so a hand-edited file is rejected instead of guessed at.
38
38
  *
39
- * @param {unknown} member candidate member.
39
+ * @param {any} member candidate member.
40
40
  * @returns {boolean} whether the member names safe, coherent paths.
41
41
  */
42
42
  function isValidMemberPaths(member) {
@@ -72,7 +72,7 @@ function isValidMemberPaths(member) {
72
72
  /**
73
73
  * Validate the v2 member list of one sidecar.
74
74
  *
75
- * @param {object} meta candidate sidecar.
75
+ * @param {any} meta candidate sidecar.
76
76
  * @returns {boolean} whether the list is coherent with its parent fields.
77
77
  */
78
78
  function isValidMetaMembers(meta) {
@@ -100,7 +100,7 @@ function isValidMetaMembers(meta) {
100
100
  * entry. A malformed sidecar is reported as an unindexed object instead of
101
101
  * being guessed at.
102
102
  *
103
- * @param {unknown} meta parsed `meta.json`.
103
+ * @param {any} meta parsed `meta.json`.
104
104
  * @param {string} trashId object directory identity.
105
105
  * @returns {boolean} whether the sidecar can rebuild one entry.
106
106
  */
@@ -132,9 +132,9 @@ export function isValidMeta(meta, trashId) {
132
132
  }
133
133
 
134
134
  /**
135
- * @param {Record<string, unknown>} member raw member fields.
135
+ * @param {any} member raw member fields.
136
136
  * @param {string} fallbackTitle title used when the member carries none.
137
- * @returns {object} one normalized v2 member.
137
+ * @returns {any} one normalized v2 member.
138
138
  */
139
139
  function toMember(member, fallbackTitle) {
140
140
  return {
@@ -151,8 +151,8 @@ function toMember(member, fallbackTitle) {
151
151
  }
152
152
 
153
153
  /**
154
- * @param {Record<string, unknown>} meta valid sidecar.
155
- * @returns {object} a clean index entry with defaulted optional fields.
154
+ * @param {any} meta valid sidecar.
155
+ * @returns {any} a clean index entry with defaulted optional fields.
156
156
  */
157
157
  export function toEntry(meta) {
158
158
  const originalCwd = typeof meta.originalCwd === 'string' && meta.originalCwd !== '' ? meta.originalCwd : null
@@ -201,7 +201,7 @@ export function toEntry(meta) {
201
201
  * Which of one entry's member directories currently exist inside its object.
202
202
  *
203
203
  * @param {import('./store.js').TrashStore} store trash store owning `objects/`.
204
- * @param {object} entry index entry, v1 or v2 shaped.
204
+ * @param {any} entry index entry, v1 or v2 shaped.
205
205
  * @returns {Promise<{ total: number, present: number, missing: string[] }>} presence summary.
206
206
  */
207
207
  export async function memberPresence(store, entry) {
@@ -229,8 +229,8 @@ export async function memberPresence(store, entry) {
229
229
  * Whether two member lists name the same directories in the same order, so a
230
230
  * presence probe taken for one can be reused for the other.
231
231
  *
232
- * @param {readonly object[]} left first member list.
233
- * @param {readonly object[]} right second member list.
232
+ * @param {readonly any[]} left first member list.
233
+ * @param {readonly any[]} right second member list.
234
234
  * @returns {boolean} whether both lists describe the same members.
235
235
  */
236
236
  function sameMembers(left, right) {
@@ -244,10 +244,10 @@ function sameMembers(left, right) {
244
244
  /**
245
245
  * Reconcile a rebuilt entry with the object directory observed on disk.
246
246
  *
247
- * @param {object} entry index-shaped entry.
247
+ * @param {any} entry index-shaped entry.
248
248
  * @param {{ total: number, present: number, missing: string[] }} presence member presence.
249
249
  * @param {number} now current timestamp.
250
- * @returns {object} the entry whose state matches the filesystem.
250
+ * @returns {any} the entry whose state matches the filesystem.
251
251
  */
252
252
  export function deriveEntry(entry, presence, now) {
253
253
  const complete = presence.total > 0 && presence.present === presence.total
@@ -282,7 +282,7 @@ export function deriveEntry(entry, presence, now) {
282
282
  *
283
283
  * @param {import('./store.js').TrashStore} store trash store owning `objects/`.
284
284
  * @param {{ warn: Function }} logger diagnostics sink.
285
- * @returns {Promise<{ objects: { trashId: string, sessionExists: boolean, meta: object | undefined, metaValid: boolean, entry?: object, presence?: object }[], unindexed: number }>} the inventory.
285
+ * @returns {Promise<{ objects: { trashId: string, sessionExists: boolean, meta: any | undefined, metaValid: boolean, entry?: any, presence?: object }[], unindexed: number }>} the inventory.
286
286
  */
287
287
  export async function scanObjects(store, logger) {
288
288
  let entries
@@ -336,13 +336,37 @@ export async function scanObjects(store, logger) {
336
336
  return { objects, unindexed }
337
337
  }
338
338
 
339
+ /**
340
+ * Plugin-owned sidecar names that may remain inside an otherwise empty object
341
+ * directory. Everything else (session/, children/, stray files) is payload.
342
+ */
343
+ const PLUGIN_OWNED_OBJECT_NAMES = new Set(['meta.json', 'meta.json.tmp'])
344
+
345
+ /**
346
+ * Whether an object directory holds no session payload, only plugin metadata
347
+ * that can be recreated. Used to offer a safe cleanup for unindexed leftovers.
348
+ *
349
+ * @param {import('./store.js').TrashStore} store trash store owning `objects/`.
350
+ * @param {string} trashId object identity.
351
+ * @returns {Promise<boolean>} whether the directory is safe to remove.
352
+ */
353
+ export async function isEmptyObjectDir(store, trashId) {
354
+ if (!isSafeTrashId(trashId)) return false
355
+ try {
356
+ const names = await fs.readdir(store.objectDir(trashId))
357
+ return names.every((name) => PLUGIN_OWNED_OBJECT_NAMES.has(name))
358
+ } catch {
359
+ return false
360
+ }
361
+ }
362
+
339
363
  /**
340
364
  * Import object directories the index does not know about and mark entries
341
365
  * whose object directory vanished. Never deletes a session file or an object
342
366
  * directory.
343
367
  *
344
368
  * @param {{ store: import('./store.js').TrashStore, logger: { warn: Function }, now?: number }} input trash store and diagnostics.
345
- * @returns {Promise<{ imported: number, markedMissing: number, unindexed: number, changed: number, presence: Map<string, object> }>} reconcile summary plus the presence probe per object.
369
+ * @returns {Promise<{ imported: number, markedMissing: number, unindexed: number, changed: number, presence: Map<string, any> }>} reconcile summary plus the presence probe per object.
346
370
  */
347
371
  export async function reconcileIndex({ store, logger, now = Date.now() }) {
348
372
  if (store.isProtected()) {
package/lib/members.js CHANGED
@@ -8,7 +8,7 @@
8
8
  */
9
9
 
10
10
  /**
11
- * @param {unknown} entry index entry (v1 or v2 shape).
11
+ * @param {any} entry index entry (v1 or v2 shape).
12
12
  * @returns {object|null} the parent member, or null when the entry has no directory data.
13
13
  */
14
14
  export function parentMember(entry) {
@@ -28,8 +28,8 @@ export function parentMember(entry) {
28
28
  }
29
29
 
30
30
  /**
31
- * @param {unknown} entry index entry (v1 or v2 shape).
32
- * @returns {object[]} every owned directory, parent first.
31
+ * @param {any} entry index entry (v1 or v2 shape).
32
+ * @returns {any[]} every owned directory, parent first.
33
33
  */
34
34
  export function memberList(entry) {
35
35
  if (entry !== null && typeof entry === 'object' && Array.isArray(entry.members) && entry.members.length > 0) {
@@ -40,7 +40,7 @@ export function memberList(entry) {
40
40
  }
41
41
 
42
42
  /**
43
- * @param {readonly object[]} members member list.
43
+ * @param {readonly any[]} members member list.
44
44
  * @returns {{ sizeBytes: number, fileCount: number }} summed measurable totals.
45
45
  */
46
46
  export function aggregateMetrics(members) {
@@ -57,7 +57,7 @@ export function aggregateMetrics(members) {
57
57
  * Shape-level validation shared by the durable index and object sidecars.
58
58
  * Path strictness belongs to the sidecar validator.
59
59
  *
60
- * @param {unknown} member candidate member.
60
+ * @param {any} member candidate member.
61
61
  * @returns {boolean} whether the member carries the required v2 identity.
62
62
  */
63
63
  export function isValidMemberShape(member) {
@@ -79,7 +79,7 @@ export function isValidMemberShape(member) {
79
79
  /**
80
80
  * Whether the v2 member list is internally consistent with its parent entry.
81
81
  *
82
- * @param {unknown} entry index entry carrying `members`.
82
+ * @param {any} entry index entry carrying `members`.
83
83
  * @returns {boolean} whether the list is acceptable.
84
84
  */
85
85
  export function isValidMemberList(entry) {
package/lib/state.js CHANGED
@@ -10,6 +10,7 @@ const PROBE_CONCURRENCY = 3
10
10
  * stays an English diagnostic for logs and tooltips; the code is what the
11
11
  * Client localizes.
12
12
  */
13
+ /** @type {[RegExp, string][]} */
13
14
  const LAST_ERROR_KINDS = [
14
15
  [/^The trash copy of this session is missing\./, 'TRASH_COPY_MISSING'],
15
16
  [/^The trash copy is missing these member sessions:/, 'TRASH_COPY_MISSING_MEMBERS'],
@@ -22,7 +23,7 @@ const LAST_ERROR_KINDS = [
22
23
  ]
23
24
 
24
25
  /**
25
- * @param {unknown} message raw `lastError` text.
26
+ * @param {any} message raw `lastError` text.
26
27
  * @returns {string | null} a stable failure family, or null when none was recorded.
27
28
  */
28
29
  export function lastErrorCode(message) {
@@ -55,9 +56,10 @@ async function directoryExists(directory) {
55
56
  * config: { retentionDays: number, sweepIntervalHours: number, enableAutoCleanup: boolean },
56
57
  * trashRootDisplay: string,
57
58
  * nextSweepAt?: () => number | null,
58
- * cleanup?: () => object,
59
+ * cleanup?: () => any,
60
+ * directoryExists?: (directory?: any) => Promise<boolean>,
59
61
  * }} deps state inputs.
60
- * @returns {{ snapshot: () => Promise<object>, describe: (entry: object) => Promise<object> }} the state service.
62
+ * @returns {{ snapshot: () => Promise<any>, describe: (entry: any) => Promise<any> }} the state service.
61
63
  */
62
64
  export function createStateService({
63
65
  store,
@@ -68,7 +70,7 @@ export function createStateService({
68
70
  directoryExists: probeDirectory = directoryExists,
69
71
  }) {
70
72
  /**
71
- * @returns {object} cleanup diagnostics, or a neutral fallback.
73
+ * @returns {any} cleanup diagnostics, or a neutral fallback.
72
74
  */
73
75
  function cleanupDiagnostics() {
74
76
  if (typeof cleanup === 'function') {
@@ -92,10 +94,10 @@ export function createStateService({
92
94
  }
93
95
 
94
96
  /**
95
- * @param {object} entry committed index entry.
97
+ * @param {any} entry committed index entry.
96
98
  * @param {Map<string, Promise<boolean>>} [probeCache] workspace probes already
97
99
  * started for this snapshot, so entries sharing a cwd probe it once.
98
- * @returns {Promise<object>} the wire entry.
100
+ * @returns {Promise<any>} the wire entry.
99
101
  */
100
102
  async function describeEntry(entry, probeCache = new Map()) {
101
103
  const cwd = typeof entry.originalCwd === 'string' && entry.originalCwd !== '' ? entry.originalCwd : null
@@ -138,12 +140,12 @@ export function createStateService({
138
140
 
139
141
  return {
140
142
  /**
141
- * @param {object} entry committed index entry.
142
- * @returns {Promise<object>} the wire entry.
143
+ * @param {any} entry committed index entry.
144
+ * @returns {Promise<any>} the wire entry.
143
145
  */
144
146
  describe: describeEntry,
145
147
  /**
146
- * @returns {Promise<object>} the complete `/state` response body.
148
+ * @returns {Promise<any>} the complete `/state` response body.
147
149
  */
148
150
  async snapshot() {
149
151
  const probeCache = new Map()
package/lib/store.js CHANGED
@@ -14,9 +14,25 @@ export const LOCK_STALE_MS = 10 * 60 * 1000
14
14
  /** How often a long-running operation refreshes its lock heartbeat. */
15
15
  export const LOCK_REFRESH_MS = 60 * 1000
16
16
 
17
+ /** How many times a caller retries a lock held by another live process. */
18
+ export const LOCK_WAIT_ATTEMPTS = 3
19
+
20
+ /** Base delay between live-lock retries. */
21
+ export const LOCK_WAIT_DELAY_MS = 200
22
+
23
+ /**
24
+ * @param {number} milliseconds delay length.
25
+ * @returns {Promise<void>} resolution after the delay.
26
+ */
27
+ function sleep(milliseconds) {
28
+ return new Promise((resolve) => {
29
+ setTimeout(resolve, milliseconds)
30
+ })
31
+ }
32
+
17
33
  /**
18
34
  * @param {number} [now] timestamp for the new index.
19
- * @returns {{ version: number, updatedAt: number, entries: never[] }} an empty index document.
35
+ * @returns {{ version: number, updatedAt: number, entries: any[] }} an empty index document.
20
36
  */
21
37
  export function emptyIndex(now = Date.now()) {
22
38
  return { version: INDEX_VERSION, updatedAt: now, entries: [] }
@@ -26,8 +42,8 @@ export function emptyIndex(now = Date.now()) {
26
42
  * Coerce one durable entry to the v2 shape. A v1 entry names a single
27
43
  * directory, which becomes the parent member of a one-member tree.
28
44
  *
29
- * @param {object} entry entry from either durable shape.
30
- * @returns {object} the v2 entry.
45
+ * @param {any} entry entry from either durable shape.
46
+ * @returns {any} the v2 entry.
31
47
  */
32
48
  function normalizeEntry(entry) {
33
49
  if (entry === null || typeof entry !== 'object') return entry
@@ -40,7 +56,7 @@ function normalizeEntry(entry) {
40
56
  * Validate the persisted index shape strictly enough that a truncated or
41
57
  * hand-edited file enters protection mode instead of silently vanishing.
42
58
  *
43
- * @param {unknown} value parsed JSON document.
59
+ * @param {any} value parsed JSON document.
44
60
  * @returns {boolean} whether the document is a supported index.
45
61
  */
46
62
  export function isValidIndex(value) {
@@ -141,7 +157,7 @@ export class TrashStore {
141
157
  }
142
158
 
143
159
  /**
144
- * @returns {readonly object[]} the committed entries.
160
+ * @returns {readonly any[]} the committed entries.
145
161
  */
146
162
  entries() {
147
163
  return this.index.entries
@@ -149,7 +165,7 @@ export class TrashStore {
149
165
 
150
166
  /**
151
167
  * @param {string} sessionId session to look up.
152
- * @returns {object | undefined} the index entry for this session.
168
+ * @returns {any | undefined} the index entry for this session.
153
169
  */
154
170
  findEntry(sessionId) {
155
171
  return this.index.entries.find((entry) => entry.sessionId === sessionId)
@@ -235,7 +251,7 @@ export class TrashStore {
235
251
 
236
252
  /**
237
253
  * @param {string} trashId object identity.
238
- * @returns {Promise<object | undefined>} the parsed sidecar metadata, when present.
254
+ * @returns {Promise<any | undefined>} the parsed sidecar metadata, when present.
239
255
  */
240
256
  async readMeta(trashId) {
241
257
  try {
@@ -279,7 +295,7 @@ export class TrashStore {
279
295
  * Atomically write one object sidecar. Metadata is written before the session
280
296
  * directory moves, so a damaged index can rebuild from it later.
281
297
  *
282
- * @param {object} entry index-shaped entry carrying `trashId`.
298
+ * @param {any} entry index-shaped entry carrying `trashId`.
283
299
  * @returns {Promise<void>} resolution once the sidecar is durable.
284
300
  */
285
301
  async writeMeta(entry) {
@@ -331,7 +347,7 @@ export class TrashStore {
331
347
  /**
332
348
  * Commit a complete entry list and refresh the in-memory projection.
333
349
  *
334
- * @param {readonly object[]} entries complete new entry list.
350
+ * @param {readonly any[]} entries complete new entry list.
335
351
  * @returns {Promise<void>} resolution once committed.
336
352
  */
337
353
  async commit(entries) {
@@ -346,7 +362,7 @@ export class TrashStore {
346
362
 
347
363
  /**
348
364
  * @param {string} file index file to read.
349
- * @returns {Promise<unknown>} parsed JSON.
365
+ * @returns {Promise<any>} parsed JSON.
350
366
  */
351
367
  async readJson(file) {
352
368
  return JSON.parse(await fs.readFile(file, 'utf8'))
@@ -392,8 +408,8 @@ export class TrashStore {
392
408
  }
393
409
 
394
410
  /**
395
- * @param {{ version: number, updatedAt: number, entries: readonly object[] }} data parsed index document.
396
- * @returns {{ version: number, updatedAt: number, entries: object[] }} the v2 document.
411
+ * @param {{ version: number, updatedAt: number, entries: readonly any[] }} data parsed index document.
412
+ * @returns {{ version: number, updatedAt: number, entries: any[] }} the v2 document.
397
413
  */
398
414
  normalizeIndex(data) {
399
415
  if (data.version !== INDEX_VERSION) this.needsUpgrade = true
@@ -408,8 +424,8 @@ export class TrashStore {
408
424
  * Atomically commit the index: write a temporary file, preserve the previous
409
425
  * commit as `.bak`, then rename over the live index.
410
426
  *
411
- * @param {readonly object[]} entries the complete entry list to persist.
412
- * @returns {Promise<{ version: number, updatedAt: number, entries: readonly object[] }>} the committed document.
427
+ * @param {readonly any[]} entries the complete entry list to persist.
428
+ * @returns {Promise<{ version: number, updatedAt: number, entries: readonly any[] }>} the committed document.
413
429
  */
414
430
  async writeIndex(entries) {
415
431
  const normalized = entries.map((entry) => normalizeEntry(entry))
@@ -558,10 +574,15 @@ export class TrashStore {
558
574
  }
559
575
 
560
576
  /**
577
+ * Acquire the cross-process lock. A lock held by another live process is
578
+ * retried for a short window first, so two windows clicking at the same time
579
+ * do not surface a spurious LOCKED error.
580
+ *
561
581
  * @returns {Promise<void>} resolution once this process owns the lock.
562
582
  */
563
583
  async acquireLock() {
564
- for (let attempt = 0; attempt < 3; attempt += 1) {
584
+ let waits = 0
585
+ for (let attempt = 0; attempt < 6; attempt += 1) {
565
586
  try {
566
587
  const handle = await fs.open(this.lockPath, 'wx', 0o600)
567
588
  try {
@@ -576,13 +597,21 @@ export class TrashStore {
576
597
  throw new TrashError('IO_ERROR', `Cannot create the trash lock: ${error instanceof Error ? error.message : String(error)}`)
577
598
  }
578
599
  if (!(await this.lockIsStale())) {
600
+ if (waits < LOCK_WAIT_ATTEMPTS - 1) {
601
+ waits += 1
602
+ await sleep(LOCK_WAIT_DELAY_MS * waits)
603
+ continue
604
+ }
579
605
  throw new TrashError('LOCKED', 'Another trash operation is in progress. Try again in a moment.')
580
606
  }
581
607
  try {
582
608
  await removePathWithRetry(this.lockPath, { recursive: false, force: true, attempts: 5, delayMs: 20 })
583
609
  } catch (removeError) {
584
610
  throw new TrashError('LOCKED', 'A stale trash lock could not be removed. Try again in a moment.', {
585
- code: removeError instanceof Error && typeof removeError.code === 'string' ? removeError.code : null,
611
+ code:
612
+ removeError instanceof Error && typeof /** @type {any} */ (removeError).code === 'string'
613
+ ? /** @type {any} */ (removeError).code
614
+ : null,
586
615
  })
587
616
  }
588
617
  }