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