dsh-conversation-recycle-bin 0.3.11 → 0.3.13

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/core/trash.js CHANGED
@@ -1,227 +1,245 @@
1
- /**
2
- * Plugin recycle bin ("trash") manifest store.
3
- *
4
- * One directory per deleted conversation under `<DSH_HOME>/delete-conversation/trash/`:
5
- *
6
- * <trashId>/
7
- * manifest.json the entry below (status, catalog snapshot, memory bundle refs)
8
- * session/ moved session-log directory (hard mode only)
9
- * memory/ moved memory files + per-adapter bundle.json
10
- *
11
- * The manifest is the recovery contract: restore reads it, the UI lists it, and
12
- * the audit trail references its `trashId`. A `pending` entry is written before
13
- * the transaction starts; only a committed cascade flips it to `trashed`, so a
14
- * crash mid-delete leaves an inspectable but non-restorable record instead of a
15
- * session silently missing from every list.
16
- */
17
- import { mkdir, readdir, readFile, rename, rm, writeFile } from 'node:fs/promises'
18
- import { join } from 'node:path'
19
- import { DeleteError } from './errors.js'
20
-
21
- const MANIFEST = 'manifest.json'
22
- const TRASH_ID_PATTERN = /^[0-9a-z]{2,40}-[0-9a-z]{2,20}$/
23
- /**
24
- * Manifest reads in flight during one directory scan. The work is dominated by
25
- * syscalls on many small files, so a small fixed pool removes the serial
26
- * round-trip chain without flooding the FS with hundreds of concurrent opens.
27
- */
28
- const SCAN_CONCURRENCY = 8
29
-
30
- /** Ordered `map` with at most `limit` promises in flight. */
31
- async function mapLimit(items, limit, fn) {
32
- const out = new Array(items.length)
33
- let next = 0
34
- const workers = Array.from({ length: Math.min(Math.max(1, limit), items.length) }, async () => {
35
- while (true) {
36
- const index = next
37
- next += 1
38
- if (index >= items.length) return
39
- out[index] = await fn(items[index], index)
40
- }
41
- })
42
- await Promise.all(workers)
43
- return out
44
- }
45
-
46
-
47
- /** Statuses a manifest may carry. `pending` and `failed` are never listed. */
48
- export const TRASH_STATUS = Object.freeze(['pending', 'trashed', 'failed', 'restoring', 'restored', 'purging'])
49
-
50
- export function isTrashId(value) {
51
- return typeof value === 'string' && TRASH_ID_PATTERN.test(value)
52
- }
53
-
54
- /**
55
- * @param {object} options
56
- * @param {string} options.rootDir absolute trash root
57
- * @param {() => number} [options.now]
58
- * @param {() => string} [options.newId] injectable id factory for tests
59
- * @param {object} [options.fsApi]
60
- */
61
- export function createTrashStore({ rootDir, now = Date.now, newId, fsApi = { mkdir, readdir, readFile, rename, rm, writeFile } }) {
62
- const idFactory = newId ?? createIdFactory(now)
63
-
64
- async function ensureRoot() {
65
- await fsApi.mkdir(rootDir, { recursive: true })
66
- }
67
-
68
- function entryDir(trashId) {
69
- if (!isTrashId(trashId)) {
70
- // A plain Error here escaped as `E_INTERNAL 500` and, worse, was
71
- // distinguishable from `E_TRASH_NOT_FOUND 404`, which made malformed ids
72
- // an existence probe. Keep it a typed, user-facing refusal.
73
- throw new DeleteError('E_BAD_REQUEST', 'The recycle-bin id is malformed.', {})
74
- }
75
- return join(rootDir, trashId)
76
- }
77
-
78
- async function writeManifestFile(dir, entry) {
79
- const temporary = join(dir, `${MANIFEST}.tmp`)
80
- await fsApi.writeFile(temporary, `${JSON.stringify(entry, null, 2)}\n`, 'utf8')
81
- await fsApi.rename(temporary, join(dir, MANIFEST))
82
- }
83
-
84
- return {
85
- rootDir,
86
-
87
- async allocate({ sessionId }) {
88
- await ensureRoot()
89
- const trashId = idFactory(sessionId)
90
- const dir = entryDir(trashId)
91
- await fsApi.mkdir(join(dir, 'memory'), { recursive: true })
92
- return { trashId, dir }
93
- },
94
-
95
- entryDir,
96
-
97
- async write(entry) {
98
- const dir = entryDir(entry.trashId)
99
- await fsApi.mkdir(dir, { recursive: true })
100
- await writeManifestFile(dir, entry)
101
- return entry
102
- },
103
-
104
- async update(trashId, patch) {
105
- const entry = await this.read(trashId)
106
- const next = { ...entry, ...patch, updatedAt: now() }
107
- await writeManifestFile(entryDir(trashId), next)
108
- return next
109
- },
110
-
111
- async read(trashId) {
112
- if (!isTrashId(trashId)) return null
113
- const dir = entryDir(trashId)
114
- let raw
115
- try {
116
- raw = await fsApi.readFile(join(dir, MANIFEST), 'utf8')
117
- } catch (error) {
118
- if (error?.code === 'ENOENT') return null
119
- throw error
120
- }
121
- let parsed
122
- try {
123
- parsed = JSON.parse(raw)
124
- } catch {
125
- return null
126
- }
127
- if (parsed === null || typeof parsed !== 'object' || parsed.format !== 1) return null
128
- if (!TRASH_STATUS.includes(parsed.status)) return null
129
- return parsed
130
- },
131
-
132
- /**
133
- * One directory pass over the recycle bin.
134
- *
135
- * `listAll()` and `scanUnreadable()` used to be called together by `/trash`
136
- * and each did its own `readdir` plus one `readFile` per entry, so every
137
- * listing cost 2N manifest reads (measured at ~0.31 ms per entry, 156 ms at
138
- * 500 entries). They now share this single pass: one `readdir`, one read per
139
- * entry, bounded concurrency instead of a serial `for await`.
140
- *
141
- * A missing or unreadable root yields empty lists rather than throwing: the
142
- * settings page must still render on a fresh install with no trash yet.
143
- * @returns {Promise<{ entries: object[], unreadable: Array<{ trashId: string, reason: 'missing-manifest'|'corrupt-manifest'|'unreadable-manifest' }> }>}
144
- */
145
- async scanAll() {
146
- await ensureRoot()
147
- let dirents = []
148
- try {
149
- dirents = await fsApi.readdir(rootDir, { withFileTypes: true })
150
- } catch {
151
- return { entries: [], unreadable: [] }
152
- }
153
- const names = []
154
- for (const dirent of dirents) {
155
- if (dirent.isDirectory() && isTrashId(dirent.name)) names.push(dirent.name)
156
- }
157
- const scanned = await mapLimit(names, SCAN_CONCURRENCY, async (trashId) => {
158
- let raw
159
- try {
160
- raw = await fsApi.readFile(join(rootDir, trashId, MANIFEST), 'utf8')
161
- } catch (error) {
162
- return { trashId, reason: error?.code === 'ENOENT' ? 'missing-manifest' : 'unreadable-manifest' }
163
- }
164
- let parsed
165
- try {
166
- parsed = JSON.parse(raw)
167
- } catch {
168
- return { trashId, reason: 'corrupt-manifest' }
169
- }
170
- if (parsed === null || typeof parsed !== 'object' || parsed.format !== 1 || !TRASH_STATUS.includes(parsed.status)) {
171
- return { trashId, reason: 'corrupt-manifest' }
172
- }
173
- return { entry: parsed }
174
- })
175
- const entries = []
176
- const unreadable = []
177
- for (const item of scanned) {
178
- if (item.entry !== undefined) entries.push(item.entry)
179
- else unreadable.push({ trashId: item.trashId, reason: item.reason })
180
- }
181
- // Newest first. `Array.prototype.sort` is stable, so equal timestamps keep
182
- // the directory order the scan produced.
183
- entries.sort((left, right) => (right.deletedAt ?? 0) - (left.deletedAt ?? 0))
184
- return { entries, unreadable }
185
- },
186
-
187
- /** Every readable manifest, newest first, regardless of status. */
188
- async listAll() {
189
- const { entries } = await this.scanAll()
190
- return entries
191
- },
192
-
193
- /** Newest first. Only committed entries are returned. */
194
- async list() {
195
- const entries = await this.listAll()
196
- return entries.filter((entry) => entry.status === 'trashed')
197
- },
198
-
199
- /**
200
- * Trash directories whose manifest cannot be read at all: missing
201
- * (crashed between allocate and write), corrupt, or unparseable. These
202
- * are invisible to restore/purge but must still be listable and
203
- * discardable, otherwise a single bad manifest strands the directory on
204
- * disk forever.
205
- * @returns {Promise<Array<{ trashId: string, reason: 'missing-manifest'|'corrupt-manifest'|'unreadable-manifest' }>>}
206
- */
207
- async scanUnreadable() {
208
- const { unreadable } = await this.scanAll()
209
- return unreadable
210
- },
211
-
212
- async remove(trashId) {
213
- await fsApi.rm(entryDir(trashId), { recursive: true, force: true })
214
- },
215
- }
216
- }
217
-
218
- function createIdFactory(now) {
219
- let counter = 0
220
- return (sessionId) => {
221
- counter = (counter + 1) % 1296
222
- const stamp = now().toString(36)
223
- const sessionPart = String(sessionId ?? '').replace(/[^a-zA-Z0-9]/g, '').slice(0, 10).toLowerCase() || 'session'
224
- const salt = `${counter.toString(36)}${Math.random().toString(36).slice(2, 6)}`
225
- return `${stamp}-${salt}${sessionPart.slice(0, 4)}`
226
- }
227
- }
1
+ /**
2
+ * Plugin recycle bin ("trash") manifest store.
3
+ *
4
+ * One directory per deleted conversation under `<DSH_HOME>/delete-conversation/trash/`:
5
+ *
6
+ * <trashId>/
7
+ * manifest.json the entry below (status, catalog snapshot, memory bundle refs)
8
+ * session/ moved session-log directory (hard mode only)
9
+ * memory/ moved memory files + per-adapter bundle.json
10
+ *
11
+ * The manifest is the recovery contract: restore reads it, the UI lists it, and
12
+ * the audit trail references its `trashId`. A `pending` entry is written before
13
+ * the transaction starts; only a committed cascade flips it to `trashed`, so a
14
+ * crash mid-delete leaves an inspectable but non-restorable record instead of a
15
+ * session silently missing from every list.
16
+ */
17
+ import { mkdir, readdir, readFile, rename, rm, writeFile } from 'node:fs/promises'
18
+ import { join } from 'node:path'
19
+ import { DeleteError } from './errors.js'
20
+ import { SESSION_ID_PATTERN } from './ids.js'
21
+
22
+ const MANIFEST = 'manifest.json'
23
+ const TRASH_ID_PATTERN = /^[0-9a-z]{2,40}-[0-9a-z]{2,20}$/
24
+ /**
25
+ * Manifest reads in flight during one directory scan. The work is dominated by
26
+ * syscalls on many small files, so a small fixed pool removes the serial
27
+ * round-trip chain without flooding the FS with hundreds of concurrent opens.
28
+ */
29
+ const SCAN_CONCURRENCY = 8
30
+
31
+ /** Ordered `map` with at most `limit` promises in flight. */
32
+ async function mapLimit(items, limit, fn) {
33
+ const out = new Array(items.length)
34
+ let next = 0
35
+ const workers = Array.from({ length: Math.min(Math.max(1, limit), items.length) }, async () => {
36
+ while (true) {
37
+ const index = next
38
+ next += 1
39
+ if (index >= items.length) return
40
+ out[index] = await fn(items[index], index)
41
+ }
42
+ })
43
+ await Promise.all(workers)
44
+ return out
45
+ }
46
+
47
+
48
+ /** Statuses a manifest may carry. `pending` and `failed` are never listed. */
49
+ export const TRASH_STATUS = Object.freeze(['pending', 'trashed', 'failed', 'restoring', 'restored', 'purging'])
50
+
51
+ /**
52
+ * `sessionId` may be absent (a memory-only entry) but must never be present and
53
+ * malformed: it ends up in a `join(workspaceRoot, sessionId, 'session.jsonl')`.
54
+ */
55
+ function isSessionIdField(value) {
56
+ return value === undefined || value === null || (typeof value === 'string' && SESSION_ID_PATTERN.test(value))
57
+ }
58
+
59
+ export function isTrashId(value) {
60
+ return typeof value === 'string' && TRASH_ID_PATTERN.test(value)
61
+ }
62
+
63
+ /**
64
+ * @param {object} options
65
+ * @param {string} options.rootDir absolute trash root
66
+ * @param {() => number} [options.now]
67
+ * @param {() => string} [options.newId] injectable id factory for tests
68
+ * @param {object} [options.fsApi]
69
+ */
70
+ export function createTrashStore({ rootDir, now = Date.now, newId, fsApi = { mkdir, readdir, readFile, rename, rm, writeFile } }) {
71
+ const idFactory = newId ?? createIdFactory(now)
72
+
73
+ async function ensureRoot() {
74
+ await fsApi.mkdir(rootDir, { recursive: true })
75
+ }
76
+
77
+ function entryDir(trashId) {
78
+ if (!isTrashId(trashId)) {
79
+ // A plain Error here escaped as `E_INTERNAL 500` and, worse, was
80
+ // distinguishable from `E_TRASH_NOT_FOUND 404`, which made malformed ids
81
+ // an existence probe. Keep it a typed, user-facing refusal.
82
+ throw new DeleteError('E_BAD_REQUEST', 'The recycle-bin id is malformed.', {})
83
+ }
84
+ return join(rootDir, trashId)
85
+ }
86
+
87
+ async function writeManifestFile(dir, entry) {
88
+ const temporary = join(dir, `${MANIFEST}.tmp`)
89
+ await fsApi.writeFile(temporary, `${JSON.stringify(entry, null, 2)}\n`, 'utf8')
90
+ await fsApi.rename(temporary, join(dir, MANIFEST))
91
+ }
92
+
93
+ return {
94
+ rootDir,
95
+
96
+ async allocate({ sessionId }) {
97
+ await ensureRoot()
98
+ const trashId = idFactory(sessionId)
99
+ const dir = entryDir(trashId)
100
+ await fsApi.mkdir(join(dir, 'memory'), { recursive: true })
101
+ return { trashId, dir }
102
+ },
103
+
104
+ entryDir,
105
+
106
+ async write(entry) {
107
+ const dir = entryDir(entry.trashId)
108
+ await fsApi.mkdir(dir, { recursive: true })
109
+ await writeManifestFile(dir, entry)
110
+ return entry
111
+ },
112
+
113
+ async update(trashId, patch) {
114
+ const entry = await this.read(trashId)
115
+ // A missing entry used to be spread into `{...null}` and written back as a
116
+ // fresh manifest with `trashId: null`: a directory nobody could ever list,
117
+ // restore or discard. Callers treat `null` as "already gone".
118
+ if (entry === null) return null
119
+ const next = { ...entry, ...patch, updatedAt: now() }
120
+ await writeManifestFile(entryDir(trashId), next)
121
+ return next
122
+ },
123
+
124
+ async read(trashId) {
125
+ if (!isTrashId(trashId)) return null
126
+ const dir = entryDir(trashId)
127
+ let raw
128
+ try {
129
+ raw = await fsApi.readFile(join(dir, MANIFEST), 'utf8')
130
+ } catch (error) {
131
+ if (error?.code === 'ENOENT') return null
132
+ throw error
133
+ }
134
+ let parsed
135
+ try {
136
+ parsed = JSON.parse(raw)
137
+ } catch {
138
+ return null
139
+ }
140
+ if (parsed === null || typeof parsed !== 'object' || parsed.format !== 1) return null
141
+ if (!TRASH_STATUS.includes(parsed.status)) return null
142
+ // `sessionId` is data, not truth: restore joins it into a workspace path,
143
+ // so a hand-edited manifest must not be able to smuggle `..` through the
144
+ // entry at all. A manifest that fails this is unreadable, not listable.
145
+ if (!isSessionIdField(parsed.sessionId)) return null
146
+ return parsed
147
+ },
148
+
149
+ /**
150
+ * One directory pass over the recycle bin.
151
+ *
152
+ * `listAll()` and `scanUnreadable()` used to be called together by `/trash`
153
+ * and each did its own `readdir` plus one `readFile` per entry, so every
154
+ * listing cost 2N manifest reads (measured at ~0.31 ms per entry, 156 ms at
155
+ * 500 entries). They now share this single pass: one `readdir`, one read per
156
+ * entry, bounded concurrency instead of a serial `for await`.
157
+ *
158
+ * A missing or unreadable root yields empty lists rather than throwing: the
159
+ * settings page must still render on a fresh install with no trash yet.
160
+ * @returns {Promise<{ entries: object[], unreadable: Array<{ trashId: string, reason: 'missing-manifest'|'corrupt-manifest'|'unreadable-manifest' }> }>}
161
+ */
162
+ async scanAll() {
163
+ await ensureRoot()
164
+ let dirents = []
165
+ try {
166
+ dirents = await fsApi.readdir(rootDir, { withFileTypes: true })
167
+ } catch {
168
+ return { entries: [], unreadable: [] }
169
+ }
170
+ const names = []
171
+ for (const dirent of dirents) {
172
+ if (dirent.isDirectory() && isTrashId(dirent.name)) names.push(dirent.name)
173
+ }
174
+ const scanned = await mapLimit(names, SCAN_CONCURRENCY, async (trashId) => {
175
+ let raw
176
+ try {
177
+ raw = await fsApi.readFile(join(rootDir, trashId, MANIFEST), 'utf8')
178
+ } catch (error) {
179
+ return { trashId, reason: error?.code === 'ENOENT' ? 'missing-manifest' : 'unreadable-manifest' }
180
+ }
181
+ let parsed
182
+ try {
183
+ parsed = JSON.parse(raw)
184
+ } catch {
185
+ return { trashId, reason: 'corrupt-manifest' }
186
+ }
187
+ if (parsed === null || typeof parsed !== 'object' || parsed.format !== 1 || !TRASH_STATUS.includes(parsed.status)
188
+ || !isSessionIdField(parsed.sessionId)) {
189
+ return { trashId, reason: 'corrupt-manifest' }
190
+ }
191
+ return { entry: parsed }
192
+ })
193
+ const entries = []
194
+ const unreadable = []
195
+ for (const item of scanned) {
196
+ if (item.entry !== undefined) entries.push(item.entry)
197
+ else unreadable.push({ trashId: item.trashId, reason: item.reason })
198
+ }
199
+ // Newest first. `Array.prototype.sort` is stable, so equal timestamps keep
200
+ // the directory order the scan produced.
201
+ entries.sort((left, right) => (right.deletedAt ?? 0) - (left.deletedAt ?? 0))
202
+ return { entries, unreadable }
203
+ },
204
+
205
+ /** Every readable manifest, newest first, regardless of status. */
206
+ async listAll() {
207
+ const { entries } = await this.scanAll()
208
+ return entries
209
+ },
210
+
211
+ /** Newest first. Only committed entries are returned. */
212
+ async list() {
213
+ const entries = await this.listAll()
214
+ return entries.filter((entry) => entry.status === 'trashed')
215
+ },
216
+
217
+ /**
218
+ * Trash directories whose manifest cannot be read at all: missing
219
+ * (crashed between allocate and write), corrupt, or unparseable. These
220
+ * are invisible to restore/purge but must still be listable and
221
+ * discardable, otherwise a single bad manifest strands the directory on
222
+ * disk forever.
223
+ * @returns {Promise<Array<{ trashId: string, reason: 'missing-manifest'|'corrupt-manifest'|'unreadable-manifest' }>>}
224
+ */
225
+ async scanUnreadable() {
226
+ const { unreadable } = await this.scanAll()
227
+ return unreadable
228
+ },
229
+
230
+ async remove(trashId) {
231
+ await fsApi.rm(entryDir(trashId), { recursive: true, force: true })
232
+ },
233
+ }
234
+ }
235
+
236
+ function createIdFactory(now) {
237
+ let counter = 0
238
+ return (sessionId) => {
239
+ counter = (counter + 1) % 1296
240
+ const stamp = now().toString(36)
241
+ const sessionPart = String(sessionId ?? '').replace(/[^a-zA-Z0-9]/g, '').slice(0, 10).toLowerCase() || 'session'
242
+ const salt = `${counter.toString(36)}${Math.random().toString(36).slice(2, 6)}`
243
+ return `${stamp}-${salt}${sessionPart.slice(0, 4)}`
244
+ }
245
+ }