@wangxiaocuo/dsh-session-trash 0.5.1 → 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/inventory.js CHANGED
@@ -1,12 +1,15 @@
1
1
  import fs from 'node:fs/promises'
2
2
  import path from 'node:path'
3
- import { entryKind, isSafeDirectoryPath, isSafeTrashId, samePath } from './fsops.js'
3
+ import { entryKind, isSafeDirectoryPath, isSafeMemberId, isSafeTrashId, samePath } from './fsops.js'
4
4
 
5
5
  /** Every state the durable index understands. */
6
6
  export const ENTRY_STATES = new Set(['trashing', 'trashed', 'restoring', 'purging', 'missing'])
7
7
 
8
8
  /** Sidecar schema version understood by this release. */
9
- const META_VERSION = 1
9
+ const META_VERSION = 2
10
+
11
+ /** Maximum directories one trashed item may own. */
12
+ const MAX_MEMBERS = 64
10
13
 
11
14
  /**
12
15
  * @param {unknown} value candidate timestamp.
@@ -16,6 +19,77 @@ function isFiniteNumber(value) {
16
19
  return typeof value === 'number' && Number.isFinite(value)
17
20
  }
18
21
 
22
+ /**
23
+ * @param {unknown} value candidate metric.
24
+ * @returns {boolean} whether the value is a usable number or absent.
25
+ */
26
+ function isMetric(value) {
27
+ return value === undefined || value === null || isFiniteNumber(value)
28
+ }
29
+
30
+ /**
31
+ * Validate one v2 member's absolute paths. The parent's sidecar is validated
32
+ * field by field so a hand-edited file is rejected instead of guessed at.
33
+ *
34
+ * @param {unknown} member candidate member.
35
+ * @returns {boolean} whether the member names safe, coherent paths.
36
+ */
37
+ function isValidMemberPaths(member) {
38
+ if (member === null || typeof member !== 'object' || Array.isArray(member)) return false
39
+ if (!isSafeMemberId(member.sessionId)) return false
40
+ if (member.origin !== undefined && member.origin !== null && member.origin !== 'subagent') return false
41
+ if (member.title !== undefined && typeof member.title !== 'string') return false
42
+ if (
43
+ member.formatStatus !== undefined &&
44
+ member.formatStatus !== 'current' &&
45
+ member.formatStatus !== 'migration-required'
46
+ ) {
47
+ return false
48
+ }
49
+ if (!isMetric(member.sizeBytes) || !isMetric(member.fileCount)) return false
50
+ if (!isSafeDirectoryPath(member.originalSessionDir)) return false
51
+ if (typeof member.originalLogPath !== 'string' || !path.isAbsolute(member.originalLogPath)) return false
52
+ if (
53
+ typeof member.persistenceProjectDirHint !== 'string' ||
54
+ !path.isAbsolute(member.persistenceProjectDirHint)
55
+ ) {
56
+ return false
57
+ }
58
+ if (!samePath(path.dirname(path.resolve(member.originalSessionDir)), path.resolve(member.persistenceProjectDirHint))) {
59
+ return false
60
+ }
61
+ if (!samePath(path.dirname(path.resolve(member.originalLogPath)), path.resolve(member.originalSessionDir))) {
62
+ return false
63
+ }
64
+ return true
65
+ }
66
+
67
+ /**
68
+ * Validate the v2 member list of one sidecar.
69
+ *
70
+ * @param {object} meta candidate sidecar.
71
+ * @returns {boolean} whether the list is coherent with its parent fields.
72
+ */
73
+ function isValidMetaMembers(meta) {
74
+ if (!Array.isArray(meta.members) || meta.members.length === 0 || meta.members.length > MAX_MEMBERS) return false
75
+ if (!meta.members.every((member) => isValidMemberPaths(member))) return false
76
+ const seen = new Set()
77
+ for (const member of meta.members) {
78
+ if (seen.has(member.sessionId)) return false
79
+ seen.add(member.sessionId)
80
+ }
81
+ const parent = meta.members[0]
82
+ if (parent.sessionId !== meta.sessionId) return false
83
+ if (!samePath(path.resolve(parent.originalSessionDir), path.resolve(meta.originalSessionDir))) return false
84
+ if (!samePath(path.resolve(parent.originalLogPath), path.resolve(meta.originalLogPath))) return false
85
+ if (
86
+ !samePath(path.resolve(parent.persistenceProjectDirHint), path.resolve(meta.persistenceProjectDirHint))
87
+ ) {
88
+ return false
89
+ }
90
+ return true
91
+ }
92
+
19
93
  /**
20
94
  * Validate one object sidecar strictly enough to be rebuilt into an index
21
95
  * entry. A malformed sidecar is reported as an unindexed object instead of
@@ -27,7 +101,7 @@ function isFiniteNumber(value) {
27
101
  */
28
102
  export function isValidMeta(meta, trashId) {
29
103
  if (meta === null || typeof meta !== 'object' || Array.isArray(meta)) return false
30
- if (meta.version !== undefined && meta.version !== META_VERSION) return false
104
+ if (meta.version !== undefined && meta.version !== META_VERSION && meta.version !== 1) return false
31
105
  if (typeof meta.sessionId !== 'string' || meta.sessionId === '' || meta.sessionId.length > 256) return false
32
106
  if (meta.trashId !== trashId) return false
33
107
  if (!ENTRY_STATES.has(meta.state)) return false
@@ -48,20 +122,58 @@ export function isValidMeta(meta, trashId) {
48
122
  ) {
49
123
  return false
50
124
  }
125
+ if (meta.version === META_VERSION) return isValidMetaMembers(meta)
51
126
  return true
52
127
  }
53
128
 
129
+ /**
130
+ * @param {Record<string, unknown>} member raw member fields.
131
+ * @param {string} fallbackTitle title used when the member carries none.
132
+ * @returns {object} one normalized v2 member.
133
+ */
134
+ function toMember(member, fallbackTitle) {
135
+ return {
136
+ sessionId: member.sessionId,
137
+ origin: member.origin === 'subagent' ? 'subagent' : null,
138
+ title: typeof member.title === 'string' && member.title !== '' ? member.title : fallbackTitle,
139
+ originalSessionDir: path.resolve(member.originalSessionDir),
140
+ originalLogPath: path.resolve(member.originalLogPath),
141
+ persistenceProjectDirHint: path.resolve(member.persistenceProjectDirHint),
142
+ formatStatus: member.formatStatus === 'migration-required' ? 'migration-required' : 'current',
143
+ sizeBytes: isFiniteNumber(member.sizeBytes) ? member.sizeBytes : null,
144
+ fileCount: isFiniteNumber(member.fileCount) ? member.fileCount : null,
145
+ }
146
+ }
147
+
54
148
  /**
55
149
  * @param {Record<string, unknown>} meta valid sidecar.
56
150
  * @returns {object} a clean index entry with defaulted optional fields.
57
151
  */
58
- function toEntry(meta) {
152
+ export function toEntry(meta) {
59
153
  const originalCwd = typeof meta.originalCwd === 'string' && meta.originalCwd !== '' ? meta.originalCwd : null
154
+ const title = typeof meta.title === 'string' && meta.title !== '' ? meta.title : meta.sessionId
155
+ const members =
156
+ Array.isArray(meta.members) && meta.members.length > 0
157
+ ? meta.members.map((member) => toMember(member, title))
158
+ : [
159
+ toMember(
160
+ {
161
+ sessionId: meta.sessionId,
162
+ originalSessionDir: meta.originalSessionDir,
163
+ originalLogPath: meta.originalLogPath,
164
+ persistenceProjectDirHint: meta.persistenceProjectDirHint,
165
+ formatStatus: meta.formatStatus,
166
+ sizeBytes: meta.sizeBytes,
167
+ fileCount: meta.fileCount,
168
+ },
169
+ title,
170
+ ),
171
+ ]
60
172
  return {
61
173
  sessionId: meta.sessionId,
62
174
  trashId: meta.trashId,
63
175
  state: meta.state,
64
- title: meta.title,
176
+ title,
65
177
  trashedAt: meta.trashedAt,
66
178
  startedAt: meta.startedAt,
67
179
  originalCwd,
@@ -75,25 +187,59 @@ function toEntry(meta) {
75
187
  formatStatus: meta.formatStatus === 'migration-required' ? 'migration-required' : 'current',
76
188
  sizeBytes: isFiniteNumber(meta.sizeBytes) ? meta.sizeBytes : null,
77
189
  fileCount: isFiniteNumber(meta.fileCount) ? meta.fileCount : null,
190
+ members,
78
191
  lastError: typeof meta.lastError === 'string' && meta.lastError !== '' ? meta.lastError : null,
79
192
  }
80
193
  }
81
194
 
195
+ /**
196
+ * Which of one entry's member directories currently exist inside its object.
197
+ *
198
+ * @param {import('./store.js').TrashStore} store trash store owning `objects/`.
199
+ * @param {object} entry index entry, v1 or v2 shaped.
200
+ * @returns {Promise<{ total: number, present: number, missing: string[] }>} presence summary.
201
+ */
202
+ export async function memberPresence(store, entry) {
203
+ const members =
204
+ Array.isArray(entry.members) && entry.members.length > 0
205
+ ? entry.members
206
+ : [{ sessionId: entry.sessionId, originalSessionDir: entry.originalSessionDir }]
207
+ const missing = []
208
+ let present = 0
209
+ for (let index = 0; index < members.length; index += 1) {
210
+ const member = members[index]
211
+ let kind = 'missing'
212
+ try {
213
+ kind = await entryKind(store.memberPath(entry.trashId, index, member.sessionId))
214
+ } catch {
215
+ kind = 'missing'
216
+ }
217
+ if (kind === 'directory') present += 1
218
+ else missing.push(typeof member.sessionId === 'string' ? member.sessionId : entry.sessionId)
219
+ }
220
+ return { total: members.length, present, missing }
221
+ }
222
+
82
223
  /**
83
224
  * Reconcile a rebuilt entry with the object directory observed on disk.
84
225
  *
85
226
  * @param {object} entry index-shaped entry.
86
- * @param {boolean} sessionExists whether `objects/<trashId>/session` is a directory.
227
+ * @param {{ total: number, present: number, missing: string[] }} presence member presence.
87
228
  * @param {number} now current timestamp.
88
229
  * @returns {object} the entry whose state matches the filesystem.
89
230
  */
90
- export function deriveEntry(entry, sessionExists, now) {
91
- if (!sessionExists) {
231
+ export function deriveEntry(entry, presence, now) {
232
+ const complete = presence.total > 0 && presence.present === presence.total
233
+ if (!complete) {
92
234
  if (entry.state === 'purging' || entry.state === 'missing') return { ...entry }
235
+ const lastError =
236
+ presence.present === 0
237
+ ? 'The trash copy of this session is missing.'
238
+ : `The trash copy is missing these member sessions: ${presence.missing.join(', ')}.`
93
239
  return {
94
240
  ...entry,
95
241
  state: 'missing',
96
- lastError: 'The trash copy of this session is missing.',
242
+ lastError,
97
243
  }
98
244
  }
99
245
  if (entry.state === 'missing') {
@@ -115,7 +261,7 @@ export function deriveEntry(entry, sessionExists, now) {
115
261
  *
116
262
  * @param {import('./store.js').TrashStore} store trash store owning `objects/`.
117
263
  * @param {{ warn: Function }} logger diagnostics sink.
118
- * @returns {Promise<{ objects: { trashId: string, sessionExists: boolean, meta: object | undefined, metaValid: boolean }[], unindexed: number }>} the inventory.
264
+ * @returns {Promise<{ objects: { trashId: string, sessionExists: boolean, meta: object | undefined, metaValid: boolean, entry?: object, presence?: object }[], unindexed: number }>} the inventory.
119
265
  */
120
266
  export async function scanObjects(store, logger) {
121
267
  let entries
@@ -150,13 +296,21 @@ export async function scanObjects(store, logger) {
150
296
  unindexed += 1
151
297
  continue
152
298
  }
153
- let sessionExists = false
299
+ let presence
154
300
  try {
155
- sessionExists = (await entryKind(store.sessionPath(trashId))) === 'directory'
301
+ presence = await memberPresence(store, toEntry(meta))
156
302
  } catch (error) {
157
303
  logger.warn(`cannot inspect the trash copy ${trashId}: ${error instanceof Error ? error.message : String(error)}`)
304
+ presence = { total: 0, present: 0, missing: [] }
158
305
  }
159
- objects.push({ trashId, sessionExists, meta, metaValid })
306
+ objects.push({
307
+ trashId,
308
+ sessionExists: presence.total > 0 && presence.present === presence.total,
309
+ meta,
310
+ metaValid,
311
+ entry: toEntry(meta),
312
+ presence,
313
+ })
160
314
  }
161
315
  return { objects, unindexed }
162
316
  }
@@ -197,7 +351,8 @@ export async function reconcileIndex({ store, logger, now = Date.now() }) {
197
351
  changed = true
198
352
  continue
199
353
  }
200
- if (entry.state === 'missing' && object.sessionExists) {
354
+ const presence = await memberPresence(store, entry)
355
+ if (entry.state === 'missing' && presence.total > 0 && presence.present === presence.total) {
201
356
  nextEntries[index] = {
202
357
  ...entry,
203
358
  state: 'trashed',
@@ -211,15 +366,15 @@ export async function reconcileIndex({ store, logger, now = Date.now() }) {
211
366
 
212
367
  for (const object of objects) {
213
368
  if (indexedTrashIds.has(object.trashId)) continue
214
- if (!object.metaValid || object.meta === undefined) continue
215
- const entry = toEntry(object.meta)
369
+ if (!object.metaValid || object.meta === undefined || object.entry === undefined || object.presence === undefined) continue
370
+ const entry = object.entry
216
371
  if (sessionIds.has(entry.sessionId)) {
217
372
  // Two object directories claim the same session: keep the indexed one.
218
373
  unindexed += 1
219
374
  logger.warn(`the trash object ${object.trashId} duplicates the indexed session ${entry.sessionId}`)
220
375
  continue
221
376
  }
222
- const derived = deriveEntry(entry, object.sessionExists, now)
377
+ const derived = deriveEntry(entry, object.presence, now)
223
378
  nextEntries.push(derived)
224
379
  indexedTrashIds.add(derived.trashId)
225
380
  sessionIds.add(derived.sessionId)
@@ -254,15 +409,15 @@ export async function rebuildIndex({ store, logger, now = Date.now() }) {
254
409
  const seen = new Set()
255
410
  let unindexed = invalidObjects
256
411
  for (const object of objects) {
257
- if (!object.metaValid || object.meta === undefined) continue
258
- const entry = toEntry(object.meta)
412
+ if (!object.metaValid || object.meta === undefined || object.entry === undefined || object.presence === undefined) continue
413
+ const entry = object.entry
259
414
  if (seen.has(entry.sessionId)) {
260
415
  unindexed += 1
261
416
  logger.warn(`the trash object ${object.trashId} duplicates the rebuilt session ${entry.sessionId}`)
262
417
  continue
263
418
  }
264
419
  seen.add(entry.sessionId)
265
- entries.push(deriveEntry(entry, object.sessionExists, now))
420
+ entries.push(deriveEntry(entry, object.presence, now))
266
421
  }
267
422
  entries.sort((left, right) => left.startedAt - right.startedAt || left.trashId.localeCompare(right.trashId))
268
423
  await store.commit(entries)
package/lib/members.js ADDED
@@ -0,0 +1,95 @@
1
+ /**
2
+ * One trashed item may own several session directories: the visible parent and
3
+ * every hidden `origin: 'subagent'` descendant. These helpers keep the v1
4
+ * single-directory shape readable while the durable index uses the v2
5
+ * `members` array.
6
+ *
7
+ * @module dsh-session-trash/members
8
+ */
9
+
10
+ /**
11
+ * @param {unknown} entry index entry (v1 or v2 shape).
12
+ * @returns {object|null} the parent member, or null when the entry has no directory data.
13
+ */
14
+ export function parentMember(entry) {
15
+ if (entry === null || typeof entry !== 'object') return null
16
+ if (typeof entry.sessionId !== 'string' || entry.sessionId === '') return null
17
+ return {
18
+ sessionId: entry.sessionId,
19
+ origin: null,
20
+ title: typeof entry.title === 'string' ? entry.title : entry.sessionId,
21
+ originalSessionDir: entry.originalSessionDir,
22
+ originalLogPath: entry.originalLogPath,
23
+ persistenceProjectDirHint: entry.persistenceProjectDirHint,
24
+ formatStatus: entry.formatStatus,
25
+ sizeBytes: entry.sizeBytes,
26
+ fileCount: entry.fileCount,
27
+ }
28
+ }
29
+
30
+ /**
31
+ * @param {unknown} entry index entry (v1 or v2 shape).
32
+ * @returns {object[]} every owned directory, parent first.
33
+ */
34
+ export function memberList(entry) {
35
+ if (entry !== null && typeof entry === 'object' && Array.isArray(entry.members) && entry.members.length > 0) {
36
+ return entry.members
37
+ }
38
+ const parent = parentMember(entry)
39
+ return parent === null ? [] : [parent]
40
+ }
41
+
42
+ /**
43
+ * @param {readonly object[]} members member list.
44
+ * @returns {{ sizeBytes: number, fileCount: number }} summed measurable totals.
45
+ */
46
+ export function aggregateMetrics(members) {
47
+ let sizeBytes = 0
48
+ let fileCount = 0
49
+ for (const member of members) {
50
+ if (typeof member?.sizeBytes === 'number') sizeBytes += member.sizeBytes
51
+ if (typeof member?.fileCount === 'number') fileCount += member.fileCount
52
+ }
53
+ return { sizeBytes, fileCount }
54
+ }
55
+
56
+ /**
57
+ * Shape-level validation shared by the durable index and object sidecars.
58
+ * Path strictness belongs to the sidecar validator.
59
+ *
60
+ * @param {unknown} member candidate member.
61
+ * @returns {boolean} whether the member carries the required v2 identity.
62
+ */
63
+ export function isValidMemberShape(member) {
64
+ if (member === null || typeof member !== 'object' || Array.isArray(member)) return false
65
+ if (typeof member.sessionId !== 'string' || member.sessionId === '' || member.sessionId.length > 256) return false
66
+ if (member.origin !== undefined && member.origin !== null && member.origin !== 'subagent') return false
67
+ if (member.title !== undefined && typeof member.title !== 'string') return false
68
+ if (member.formatStatus !== undefined && member.formatStatus !== 'current' && member.formatStatus !== 'migration-required') {
69
+ return false
70
+ }
71
+ if (member.sizeBytes !== undefined && member.sizeBytes !== null && typeof member.sizeBytes !== 'number') return false
72
+ if (member.fileCount !== undefined && member.fileCount !== null && typeof member.fileCount !== 'number') return false
73
+ for (const key of ['originalSessionDir', 'originalLogPath', 'persistenceProjectDirHint']) {
74
+ if (member[key] !== undefined && member[key] !== null && typeof member[key] !== 'string') return false
75
+ }
76
+ return true
77
+ }
78
+
79
+ /**
80
+ * Whether the v2 member list is internally consistent with its parent entry.
81
+ *
82
+ * @param {unknown} entry index entry carrying `members`.
83
+ * @returns {boolean} whether the list is acceptable.
84
+ */
85
+ export function isValidMemberList(entry) {
86
+ if (entry === null || typeof entry !== 'object') return false
87
+ if (!Array.isArray(entry.members) || entry.members.length === 0 || entry.members.length > 64) return false
88
+ const seen = new Set()
89
+ for (const member of entry.members) {
90
+ if (!isValidMemberShape(member)) return false
91
+ if (seen.has(member.sessionId)) return false
92
+ seen.add(member.sessionId)
93
+ }
94
+ return entry.members[0].sessionId === entry.sessionId
95
+ }
package/lib/state.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import fs from 'node:fs/promises'
2
+ import { memberList } from './members.js'
2
3
 
3
4
  /** Maximum concurrent filesystem probes while describing entries. */
4
5
  const PROBE_CONCURRENCY = 3
@@ -85,6 +86,7 @@ export function createStateService({ store, config, trashRootDisplay, nextSweepA
85
86
  const expiresAt =
86
87
  typeof entry.trashedAt === 'number' && entry.trashedAt > 0 ? entry.trashedAt + config.retentionDays * 86_400_000 : null
87
88
  const trashed = entry.state === 'trashed'
89
+ const members = memberList(entry)
88
90
  return {
89
91
  sessionId: entry.sessionId,
90
92
  title: entry.title,
@@ -103,6 +105,12 @@ export function createStateService({ store, config, trashRootDisplay, nextSweepA
103
105
  canForget: entry.state === 'missing',
104
106
  requiresOrphanRestore: cwd !== null && workspaceNowExists === false,
105
107
  lastError: entry.lastError ?? null,
108
+ subagentCount: Math.max(0, members.length - 1),
109
+ members: members.map((member) => ({
110
+ sessionId: member.sessionId,
111
+ origin: member.origin === 'subagent' ? 'subagent' : null,
112
+ sizeBytes: typeof member.sizeBytes === 'number' ? member.sizeBytes : null,
113
+ })),
106
114
  }
107
115
  }
108
116
 
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