@wangxiaocuo/dsh-session-trash 0.5.1 → 0.6.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/store.js CHANGED
@@ -2,10 +2,11 @@ import fs from 'node:fs/promises'
2
2
  import os from 'node:os'
3
3
  import path from 'node:path'
4
4
  import { TrashError } from './errors.js'
5
- import { isInside, removePathWithRetry, renameWithRetry, samePath } from './fsops.js'
5
+ import { isInside, isSafeMemberId, removePathWithRetry, renameWithRetry, samePath } from './fsops.js'
6
+ import { isValidMemberList, parentMember } from './members.js'
6
7
 
7
8
  /** On-disk index schema version understood by this release. */
8
- export const INDEX_VERSION = 1
9
+ export const INDEX_VERSION = 2
9
10
 
10
11
  /** A lock older than this is assumed abandoned and may be taken over. */
11
12
  export const LOCK_STALE_MS = 10 * 60 * 1000
@@ -21,6 +22,20 @@ export function emptyIndex(now = Date.now()) {
21
22
  return { version: INDEX_VERSION, updatedAt: now, entries: [] }
22
23
  }
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
+
24
39
  /**
25
40
  * Validate the persisted index shape strictly enough that a truncated or
26
41
  * hand-edited file enters protection mode instead of silently vanishing.
@@ -30,7 +45,7 @@ export function emptyIndex(now = Date.now()) {
30
45
  */
31
46
  export function isValidIndex(value) {
32
47
  if (value === null || typeof value !== 'object' || Array.isArray(value)) return false
33
- if (value.version !== INDEX_VERSION) return false
48
+ if (value.version !== INDEX_VERSION && value.version !== 1) return false
34
49
  if (typeof value.updatedAt !== 'number' || !Number.isFinite(value.updatedAt)) return false
35
50
  if (!Array.isArray(value.entries)) return false
36
51
  const seen = new Set()
@@ -42,6 +57,7 @@ export function isValidIndex(value) {
42
57
  if (typeof entry.trashedAt !== 'number' || !Number.isFinite(entry.trashedAt)) return false
43
58
  if (seen.has(entry.sessionId)) return false
44
59
  seen.add(entry.sessionId)
60
+ if (value.version === INDEX_VERSION && !isValidMemberList(entry)) return false
45
61
  }
46
62
  return true
47
63
  }
@@ -90,6 +106,8 @@ export class TrashStore {
90
106
  this.lockOwned = false
91
107
  this.lockTimer = null
92
108
  this.queue = Promise.resolve()
109
+ /** Whether the last load read an older index version that must be re-committed. */
110
+ this.needsUpgrade = false
93
111
  }
94
112
 
95
113
  /**
@@ -108,6 +126,10 @@ export class TrashStore {
108
126
  } else if (this.status === 'recovered') {
109
127
  this.logger.warn(`trash index recovered from backup: ${this.error}`)
110
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
+ }
111
133
  return this.describe()
112
134
  }
113
135
 
@@ -150,12 +172,34 @@ export class TrashStore {
150
172
 
151
173
  /**
152
174
  * @param {string} trashId object identity.
153
- * @returns {string} the moved session directory inside the object directory.
175
+ * @returns {string} the moved parent session directory inside the object directory.
154
176
  */
155
177
  sessionPath(trashId) {
156
178
  return path.join(this.objectDir(trashId), 'session')
157
179
  }
158
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
+
159
203
  /**
160
204
  * @param {string} trashId object identity.
161
205
  * @returns {string} the self-describing metadata path inside the object directory.
@@ -309,15 +353,17 @@ export class TrashStore {
309
353
  }
310
354
 
311
355
  /**
312
- * Load the index, falling back to the previous committed backup.
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}.
313
358
  *
314
359
  * @returns {Promise<void>} resolution once `index` and `status` are set.
315
360
  */
316
361
  async load() {
362
+ this.needsUpgrade = false
317
363
  try {
318
364
  const data = await this.readJson(this.indexPath)
319
365
  if (!isValidIndex(data)) throw new Error('unsupported index shape or version')
320
- this.index = data
366
+ this.index = this.normalizeIndex(data)
321
367
  this.status = 'ok'
322
368
  this.error = null
323
369
  return
@@ -332,7 +378,7 @@ export class TrashStore {
332
378
  try {
333
379
  const backup = await this.readJson(this.bakPath)
334
380
  if (!isValidIndex(backup)) throw new Error('unsupported backup shape or version')
335
- this.index = backup
381
+ this.index = this.normalizeIndex(backup)
336
382
  this.status = 'recovered'
337
383
  this.error = primary
338
384
  return
@@ -345,6 +391,19 @@ export class TrashStore {
345
391
  }
346
392
  }
347
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
+
348
407
  /**
349
408
  * Atomically commit the index: write a temporary file, preserve the previous
350
409
  * commit as `.bak`, then rename over the live index.
@@ -353,7 +412,8 @@ export class TrashStore {
353
412
  * @returns {Promise<{ version: number, updatedAt: number, entries: readonly object[] }>} the committed document.
354
413
  */
355
414
  async writeIndex(entries) {
356
- const data = { version: INDEX_VERSION, updatedAt: Date.now(), entries }
415
+ const normalized = entries.map((entry) => normalizeEntry(entry))
416
+ const data = { version: INDEX_VERSION, updatedAt: Date.now(), entries: normalized }
357
417
  const json = `${JSON.stringify(data, null, 2)}\n`
358
418
  try {
359
419
  const handle = await fs.open(this.tmpPath, 'w', 0o600)
@@ -379,6 +439,7 @@ export class TrashStore {
379
439
  this.index = data
380
440
  this.status = 'ok'
381
441
  this.error = null
442
+ this.needsUpgrade = false
382
443
  return data
383
444
  }
384
445