@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/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
+ }