dsh-conversation-recycle-bin 0.2.1 → 0.2.2

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.
@@ -9,21 +9,41 @@
9
9
  *
10
10
  * Deletion is a MOVE, never an unlink: node content files and full index
11
11
  * entries are copied into the recycle-bin bundle, the live index is rewritten
12
- * atomically, and the store lock is held for the whole read-modify-write. A
13
- * failure at any point restores the files and the original index before the
14
- * error leaves this module, so the caller's transaction sees an all-or-nothing
15
- * memory stage.
12
+ * atomically, and a plugin-private lock is held for the whole
13
+ * read-modify-write.
16
14
  *
17
- * Limitations are explicit: the adapter understands the documented index
18
- * shape and reference arrays (`edges`, `depends_on`, `derived_from`) by
19
- * best-effort inspection. If a store introduces another reference field, its
20
- * dangling references are preserved (not silently rewritten) and the adapter's
21
- * `probe()` remains available because the schema itself still matches.
15
+ * ## Why the lock is plugin-private
16
+ *
17
+ * The store's own `<index>.lock` belongs to md_cg and is a permanent zero-byte
18
+ * file guarded by an OS byte-range lock (`msvcrt.locking` on Windows, `flock`
19
+ * on POSIX). Its existence is NOT a lock, and removing it silently breaks the
20
+ * store's own mutual exclusion. This adapter therefore never opens, writes or
21
+ * deletes that file; it uses `<index>.conversation-recycle-bin.lock`, which
22
+ * only coordinates this plugin's Host processes. The store lock is left to
23
+ * md_cg.
24
+ *
25
+ * ## Index generations
26
+ *
27
+ * md_cg persists an index as a snapshot (`_index.json`) plus append-only
28
+ * shards under `_index_log/`, replaying the shards over the snapshot on every
29
+ * load. When `indexLogDir` is configured (the 灵枢 preset), this adapter
30
+ * appends its own records to a fresh shard: tombstones (`e: null`) for removed
31
+ * nodes and the updated records of peers whose references were stripped. That
32
+ * keeps a later snapshot+log replay from resurrecting a deleted node. The
33
+ * adapter still writes the snapshot itself so its own reads (counts, preview)
34
+ * are immediately correct.
35
+ *
36
+ * Limitations are explicit: a md_cg process that already holds the node in its
37
+ * unflushed `_dirty` set can still re-expose it until that process reloads; and
38
+ * a node updated by another writer after our tombstone wins. Callers must treat
39
+ * this as a best-effort offline adapter, not as md_cg's own `forget` protocol.
22
40
  */
23
41
  import * as defaultFs from 'node:fs/promises'
42
+ import { createHash, randomBytes } from 'node:crypto'
24
43
  import { dirname, isAbsolute, join, resolve, sep } from 'node:path'
25
44
  import { resolveJsonPointer } from '../config.js'
26
45
  import { DeleteError } from '../errors.js'
46
+ import { createFileLock } from '../file-lock.js'
27
47
 
28
48
  const REFERENCE_FIELDS = ['edges', 'depends_on', 'derived_from']
29
49
  const REFERENCE_KEYS = ['id', 'target', 'to', 'nodeId', 'node', 'targetId', 'target_id', 'ref']
@@ -56,6 +76,8 @@ function validateRelativeContentPath(rootDir, relative) {
56
76
  return target
57
77
  }
58
78
 
79
+ const sleepDefault = (ms) => new Promise((resolve) => setTimeout(resolve, ms))
80
+
59
81
  /**
60
82
  * @param {object} options
61
83
  * @param {string} options.id stable adapter id
@@ -65,8 +87,11 @@ function validateRelativeContentPath(rootDir, relative) {
65
87
  * @param {string} [options.sessionField]
66
88
  * @param {string} [options.nodesPointer]
67
89
  * @param {string|null} [options.contentField]
68
- * @param {boolean} [options.useStoreLock] use `<indexFile>.lock` (the store's own lock) instead of a plugin-private lock
90
+ * @param {string|null} [options.indexLogDir] md_cg `_index_log` directory; null = no shard records
69
91
  * @param {(index: any) => string | null} [options.validateIndex] return an error string to mark the store unsupported
92
+ * @param {number} [options.lockTimeoutMs]
93
+ * @param {number} [options.staleLockMs]
94
+ * @param {number} [options.lockHeartbeatMs]
70
95
  * @param {() => number} [options.now]
71
96
  * @param {object} [options.fsApi]
72
97
  */
@@ -79,22 +104,40 @@ export function createJsonIndexMemoryAdapter(options) {
79
104
  sessionField = 'session',
80
105
  nodesPointer = '/nodes',
81
106
  contentField = 'path',
82
- useStoreLock = false,
107
+ indexLogDir = null,
83
108
  validateIndex = null,
84
- now = Date.now,
85
- lockTimeoutMs = 6_000,
109
+ lockTimeoutMs = 15_000,
86
110
  staleLockMs = 120_000,
87
- // A zero-byte lock carries no owner pid. If it is older than this, the
88
- // writer almost certainly died; waiting the full two minutes would make
89
- // every delete fail while the dialog says there is nothing to export.
90
- ownerlessLockStaleMs = 5_000,
111
+ lockHeartbeatMs = 5_000,
112
+ now = Date.now,
113
+ sleep = sleepDefault,
91
114
  fsApi = defaultFs,
92
115
  } = options
93
116
 
94
- const lockFile = useStoreLock ? `${indexFile}.lock` : `${indexFile}.delete-conversation.lock`
117
+ // Deliberately NOT `<indexFile>.lock`: that file belongs to md_cg.
118
+ const lockPath = `${indexFile}.conversation-recycle-bin.lock`
95
119
  const bundleFileName = 'bundle.json'
120
+ const lock = createFileLock({
121
+ lockPath,
122
+ timeoutMs: lockTimeoutMs,
123
+ staleMs: staleLockMs,
124
+ heartbeatMs: lockHeartbeatMs,
125
+ now,
126
+ sleep,
127
+ fsApi,
128
+ })
96
129
 
97
- async function readIndex() {
130
+ function withLock(operation) {
131
+ return lock.acquire().then(async (release) => {
132
+ try {
133
+ return await operation()
134
+ } finally {
135
+ await release()
136
+ }
137
+ })
138
+ }
139
+
140
+ async function readSnapshot() {
98
141
  const raw = await fsApi.readFile(indexFile, 'utf8')
99
142
  let index
100
143
  try {
@@ -115,7 +158,12 @@ export function createJsonIndexMemoryAdapter(options) {
115
158
  if (nodes === null || typeof nodes !== 'object') {
116
159
  throw new DeleteError('E_MEMORY_FAILED', `Memory index has no node map at ${nodesPointer}`, { indexFile, nodesPointer })
117
160
  }
118
- return { index, nodes }
161
+ return { index, nodes, sig: createHash('sha256').update(raw).digest('hex') }
162
+ }
163
+
164
+ async function readIndex() {
165
+ const snapshot = await readSnapshot()
166
+ return { index: snapshot.index, nodes: snapshot.nodes }
119
167
  }
120
168
 
121
169
  async function writeIndexAtomic(index) {
@@ -124,65 +172,62 @@ export function createJsonIndexMemoryAdapter(options) {
124
172
  await fsApi.rename(temporary, indexFile)
125
173
  }
126
174
 
127
- /** Acquire the store lock; a stale lock is broken after `staleLockMs`. */
128
- async function withLock(operation) {
129
- const started = now()
130
- let handle = null
131
- // A zero-byte lock is the crashed-writer signal, and the grace that proves
132
- // it must fit INSIDE the wait budget. A content-bearing lock keeps the
133
- // caller's (shorter) timeout, because it is a live writer, not a corpse.
134
- let waitBudgetMs = lockTimeoutMs
135
- while (handle === null) {
136
- try {
137
- handle = await fsApi.open(lockFile, 'wx')
138
- } catch (error) {
139
- if (error?.code !== 'EEXIST') throw error
140
- let stale = false
141
- try {
142
- const info = await fsApi.stat(lockFile)
143
- stale = now() - info.mtimeMs > staleLockMs
144
- if (info.size === 0) {
145
- waitBudgetMs = Math.max(waitBudgetMs, ownerlessLockStaleMs + 500)
146
- if (!stale && now() - info.mtimeMs > ownerlessLockStaleMs) stale = true
147
- }
148
- } catch {
149
- stale = false
150
- }
151
- if (stale) {
152
- await fsApi.rm(lockFile, { force: true }).catch(() => {})
153
- continue
154
- }
155
- if (now() - started > waitBudgetMs) {
156
- throw new DeleteError('E_MEMORY_FAILED', 'The memory store is busy; try again later.', { lockFile })
157
- }
158
- await new Promise((resolvePromise) => setTimeout(resolvePromise, 25))
159
- continue
160
- }
161
- // The file now exists. A failed owner write must not leave a lock this
162
- // process will never release (the exact zero-byte lock that blocked every
163
- // later delete until it aged out).
164
- try {
165
- await handle.writeFile(`${process.pid}\n${now()}\n`, 'utf8')
166
- } catch (error) {
167
- try {
168
- await handle.close()
169
- } catch {
170
- // already broken; the lock file is removed below either way
171
- }
172
- await fsApi.rm(lockFile, { force: true }).catch(() => {})
173
- throw error
174
- }
175
- }
175
+ async function newestLogMtimeMs() {
176
+ if (indexLogDir === null) return 0
177
+ let entries
176
178
  try {
177
- return await operation()
178
- } finally {
179
+ entries = await fsApi.readdir(indexLogDir, { withFileTypes: true })
180
+ } catch {
181
+ return 0
182
+ }
183
+ let newest = 0
184
+ for (const entry of entries) {
185
+ if (!entry.isFile() || !entry.name.endsWith('.log')) continue
179
186
  try {
180
- await handle?.close()
187
+ const info = await fsApi.stat(join(indexLogDir, entry.name))
188
+ if (info.mtimeMs > newest) newest = info.mtimeMs
181
189
  } catch {
182
- // closing a lock handle must never mask the operation outcome
190
+ // A shard cleared by a concurrent compact is simply absent.
183
191
  }
184
- await fsApi.rm(lockFile, { force: true }).catch(() => {})
185
192
  }
193
+ return newest
194
+ }
195
+
196
+ /**
197
+ * Append records to a fresh md_cg shard.
198
+ *
199
+ * The shard format is the one `md_cg.fsutil.ShardedLog` writes:
200
+ * `{ id, e, _t, _s }` per line. `e: null` is a tombstone; a dict is an
201
+ * upsert. A fresh shard avoids sharing a write point with md_cg, and `_t`
202
+ * starts after the newest existing shard so the records replay last.
203
+ *
204
+ * @param {Array<{ id: string, e: object | null }>} records
205
+ * @returns {Promise<string|null>} shard path, or null when disabled/empty
206
+ */
207
+ async function appendIndexLog(records) {
208
+ if (indexLogDir === null || records.length === 0) return null
209
+ await fsApi.mkdir(indexLogDir, { recursive: true })
210
+ const newest = await newestLogMtimeMs()
211
+ const base = Math.max(now() / 1000, newest / 1000 + 0.001)
212
+ const shardPath = join(indexLogDir, `${process.pid}-${randomBytes(4).toString('hex')}.log`)
213
+ const lines = records.map((record, index) => JSON.stringify({
214
+ id: record.id,
215
+ e: record.e,
216
+ _t: base + index / 1_000_000,
217
+ _s: index + 1,
218
+ }))
219
+ try {
220
+ await fsApi.writeFile(shardPath, `${lines.join('\n')}\n`, 'utf8')
221
+ return shardPath
222
+ } catch (error) {
223
+ await fsApi.rm(shardPath, { force: true }).catch(() => {})
224
+ throw error
225
+ }
226
+ }
227
+
228
+ async function removeQuietly(target) {
229
+ if (target === null || target === undefined) return
230
+ await fsApi.rm(target, { recursive: true, force: true }).catch(() => {})
186
231
  }
187
232
 
188
233
  return {
@@ -190,6 +235,7 @@ export function createJsonIndexMemoryAdapter(options) {
190
235
  label,
191
236
  indexFile,
192
237
  rootDir,
238
+ lockPath,
193
239
 
194
240
  async probe() {
195
241
  try {
@@ -213,7 +259,7 @@ export function createJsonIndexMemoryAdapter(options) {
213
259
  const { nodes } = await readIndex()
214
260
  const wanted = new Set(sessionIds)
215
261
  const counts = new Map()
216
- for (const id of wanted) counts.set(id, 0)
262
+ for (const wantedId of wanted) counts.set(wantedId, 0)
217
263
  let protectedCount = 0
218
264
  for (const node of Object.values(nodes)) {
219
265
  if (node === null || typeof node !== 'object') continue
@@ -245,20 +291,27 @@ export function createJsonIndexMemoryAdapter(options) {
245
291
  links: [],
246
292
  })
247
293
 
248
- // Fast path: a conversation with nothing to move must not depend on the
249
- // store lock. A stale lock left by a crashed writer (or a long write)
250
- // used to make "delete this conversation" fail with "memory store is
251
- // busy" even though the dialog showed zero linked memories.
252
- const before = await readIndex()
294
+ // Fast path: a conversation with nothing to move must not take the lock.
295
+ const before = await readSnapshot()
253
296
  const hasLinked = Object.values(before.nodes).some(
254
297
  (node) => node !== null && typeof node === 'object' && node[sessionField] === sessionId && !isProtected(node),
255
298
  )
256
299
  if (!hasLinked) return emptyBundle()
257
300
 
258
301
  return withLock(async () => {
259
- const { index, nodes } = await readIndex()
260
- // Snapshot before ANY mutation: compensation must be able to put the
261
- // original index back byte-for-byte in meaning (key order preserved).
302
+ const current = await readSnapshot()
303
+ // Another writer changed the snapshot while we waited for the lock.
304
+ // Recomputing against `current` would silently miss a node added after
305
+ // `before`; refusing is the only safe answer.
306
+ if (current.sig !== before.sig) {
307
+ throw new DeleteError('E_MEMORY_FAILED', 'The memory index changed while waiting; retry the deletion.', {
308
+ indexFile,
309
+ retryable: true,
310
+ })
311
+ }
312
+ const { index, nodes } = current
313
+ // Snapshot before ANY mutation: compensation must put the original
314
+ // index back byte-for-byte in meaning (key order preserved).
262
315
  const pristine = structuredClone(index)
263
316
  const bundleDir = join(targetDir, 'memory', id)
264
317
  const filesDir = join(bundleDir, 'files')
@@ -274,6 +327,7 @@ export function createJsonIndexMemoryAdapter(options) {
274
327
 
275
328
  const movedFiles = []
276
329
  let indexWritten = false
330
+ let logShard = null
277
331
  try {
278
332
  await fsApi.mkdir(filesDir, { recursive: true })
279
333
  for (const nodeId of ids) {
@@ -321,15 +375,27 @@ export function createJsonIndexMemoryAdapter(options) {
321
375
  bundle.bundlePath = bundleFile
322
376
  // Persist the restore bundle BEFORE removing the entries from the
323
377
  // live index: a crash in between leaves a recoverable bundle plus an
324
- // index that still lists its nodes, which is inspectable. The reverse
325
- // order could drop entries whose only copy was the rewritten index.
378
+ // index that still lists its nodes, which is inspectable.
326
379
  await fsApi.writeFile(bundleFile, `${JSON.stringify(bundle, null, 2)}\n`, 'utf8')
327
380
  await writeIndexAtomic(index)
328
381
  indexWritten = true
382
+
383
+ // Snapshot taken. Now make the deletion survive a md_cg
384
+ // snapshot+log replay: tombstones for the removed nodes and the
385
+ // updated records of the peers whose references we stripped.
386
+ const records = [...ids].map((nodeId) => ({ id: nodeId, e: null }))
387
+ const seenPeers = new Set()
388
+ for (const link of bundle.links) {
389
+ if (seenPeers.has(link.nodeId) || nodes[link.nodeId] === undefined) continue
390
+ seenPeers.add(link.nodeId)
391
+ records.push({ id: link.nodeId, e: nodes[link.nodeId] })
392
+ }
393
+ logShard = await appendIndexLog(records)
329
394
  return bundle
330
395
  } catch (error) {
331
- // Compensate inside the memory stage: the caller must see either a
396
+ // Compensation inside the memory stage: the caller must see either a
332
397
  // fully moved session or an untouched store.
398
+ await removeQuietly(logShard)
333
399
  if (indexWritten) {
334
400
  await writeIndexAtomic(pristine).catch(() => {})
335
401
  }
@@ -337,6 +403,10 @@ export function createJsonIndexMemoryAdapter(options) {
337
403
  await fsApi.mkdir(dirname(moved.from), { recursive: true }).catch(() => {})
338
404
  await fsApi.rename(moved.to, moved.from).catch(() => {})
339
405
  }
406
+ // A bundle written before the failure is not referenced by any
407
+ // manifest; leaving it behind made the failed entry impossible to
408
+ // discard (the discard guard sees files the manifest does not know).
409
+ await removeQuietly(bundleDir)
340
410
  throw error instanceof DeleteError
341
411
  ? error
342
412
  : new DeleteError('E_MEMORY_FAILED', 'Failed to move memory entries to the recycle bin.', {
@@ -352,7 +422,9 @@ export function createJsonIndexMemoryAdapter(options) {
352
422
  async restoreBundle(bundle) {
353
423
  if (bundle === null || typeof bundle !== 'object' || !Array.isArray(bundle.entries) || bundle.entries.length === 0) return
354
424
  await withLock(async () => {
355
- const { index, nodes } = await readIndex()
425
+ const current = await readSnapshot()
426
+ const { index, nodes } = current
427
+ const pristine = structuredClone(index)
356
428
  const alreadyRestored = new Set()
357
429
  for (const entry of bundle.entries) {
358
430
  const destination = entry.storedFile !== null && entry.originalPath !== null && rootDir !== null
@@ -383,6 +455,8 @@ export function createJsonIndexMemoryAdapter(options) {
383
455
  }
384
456
  }
385
457
  const movedBack = []
458
+ let indexWritten = false
459
+ let logShard = null
386
460
  try {
387
461
  for (const entry of bundle.entries) {
388
462
  if (alreadyRestored.has(entry.nodeId)) continue
@@ -412,7 +486,28 @@ export function createJsonIndexMemoryAdapter(options) {
412
486
  }
413
487
  }
414
488
  await writeIndexAtomic(index)
489
+ indexWritten = true
490
+
491
+ // Mirror the restored records into md_cg's shard log so a later
492
+ // snapshot+log replay keeps them (a stale tombstone would otherwise
493
+ // win). Peers whose links came back are re-recorded too.
494
+ const records = []
495
+ for (const entry of bundle.entries) {
496
+ if (alreadyRestored.has(entry.nodeId)) continue
497
+ records.push({ id: entry.nodeId, e: entry.node })
498
+ }
499
+ const seenPeers = new Set()
500
+ for (const link of bundle.links ?? []) {
501
+ if (seenPeers.has(link.nodeId) || nodes[link.nodeId] === undefined) continue
502
+ seenPeers.add(link.nodeId)
503
+ records.push({ id: link.nodeId, e: nodes[link.nodeId] })
504
+ }
505
+ logShard = await appendIndexLog(records)
415
506
  } catch (error) {
507
+ await removeQuietly(logShard)
508
+ if (indexWritten) {
509
+ await writeIndexAtomic(pristine).catch(() => {})
510
+ }
416
511
  for (const moved of movedBack.reverse()) {
417
512
  await fsApi.rename(moved.from, moved.to).catch(() => {})
418
513
  }
@@ -15,15 +15,21 @@ import { createJsonIndexMemoryAdapter } from './json-index-adapter.js'
15
15
  export function createLingshuAdapter({ rootDir, now, fsApi }) {
16
16
  return createJsonIndexMemoryAdapter({
17
17
  id: 'lingshu',
18
- label: '灵枢认知图 (md_cg)',
18
+ // Neutral label: the Client localizes by adapter id, so a hard-coded
19
+ // Chinese/English mix here showed up untranslated in the other language.
20
+ label: 'lingshu (md_cg)',
19
21
  indexFile: join(rootDir, '_index.json'),
20
22
  rootDir,
21
23
  sessionField: 'session',
22
24
  nodesPointer: '/nodes',
23
25
  contentField: 'path',
24
- // md_cg serializes its own writers with this exact lock file; taking it is
25
- // the cooperative way to avoid a lost update against the Python process.
26
- useStoreLock: true,
26
+ // md_cg persists the index as `_index.json` + append-only shards under
27
+ // `_index_log/` and replays the shards on every load. Our tombstones and
28
+ // restored-node records go into a fresh shard so that replay cannot
29
+ // resurrect a deleted node. We deliberately do NOT touch the store's
30
+ // `_index.json.lock`: that is md_cg's permanent OS-lock file, not a
31
+ // presence lock.
32
+ indexLogDir: join(rootDir, '_index_log'),
27
33
  validateIndex: (index) => {
28
34
  if (index.schema === 2) return null
29
35
  return `unsupported md_cg index schema ${String(index.schema)} (expected 2)`
@@ -9,9 +9,11 @@
9
9
  export function createNoneAdapter() {
10
10
  return {
11
11
  id: 'none',
12
- label: 'No memory store',
12
+ // Neutral label + a stable reason key: the Client localizes both by id, so
13
+ // an English sentence here used to appear untranslated in a Chinese UI.
14
+ label: 'none',
13
15
  async probe() {
14
- return { available: false, reason: 'No conversation-linked memory store was detected.', storePath: null }
16
+ return { available: false, reason: 'no-memory-store', storePath: null }
15
17
  },
16
18
  async countSessions(sessionIds) {
17
19
  const counts = new Map()
@@ -163,6 +163,7 @@ export function createMemoryService({ adapters, config, now = Date.now }) {
163
163
  /** Restore every adapter bundle recorded in a trash manifest memory summary. */
164
164
  async restoreEntry(summary) {
165
165
  if (summary === null || typeof summary !== 'object' || !Array.isArray(summary.adapters)) return
166
+ const consumed = []
166
167
  for (const entry of [...summary.adapters].reverse()) {
167
168
  if (typeof entry.bundlePath !== 'string' || entry.bundlePath === '') continue
168
169
  const adapter = byId.get(entry.id)
@@ -174,12 +175,14 @@ export function createMemoryService({ adapters, config, now = Date.now }) {
174
175
  }
175
176
  const bundle = JSON.parse(await readFile(entry.bundlePath, 'utf8'))
176
177
  await adapter.restoreBundle(bundle)
177
- // The payload is back in the live store, so the copy in the recycle bin
178
- // is redundant. Removing it matters: `performDiscard` refuses an
179
- // incomplete entry that still holds memory files, and a leftover
180
- // `bundle.json` made every failed delete (memory included) permanently
181
- // un-cleanable even though its data had been put back.
182
- await rm(dirname(entry.bundlePath), { recursive: true, force: true }).catch(() => {})
178
+ consumed.push(entry.bundlePath)
179
+ }
180
+ // Remove the consumed bundles only after EVERY adapter succeeded. An
181
+ // eager per-adapter removal made a later adapter's failure
182
+ // unretryable: the first bundle was already gone while the manifest still
183
+ // referenced it.
184
+ for (const bundlePath of consumed) {
185
+ await rm(dirname(bundlePath), { recursive: true, force: true }).catch(() => {})
183
186
  }
184
187
  },
185
188
  }
@@ -19,10 +19,10 @@ import { DeleteError } from './errors.js'
19
19
  export function normalizePathForCompare(input) {
20
20
  if (typeof input !== 'string' || input === '') return ''
21
21
  let value = input.replace(/\\/g, '/').replace(/\/+$/g, '')
22
- // A drive-lettered path is Windows: comparisons there are case-insensitive.
23
- // POSIX paths stay case-sensitive so a root check cannot become broader than
24
- // the filesystem's own identity rules.
25
- if (/^[A-Za-z]:\//.test(value)) value = value.toLowerCase()
22
+ // Windows paths compare case-insensitively: drive-lettered paths and UNC
23
+ // roots (`\\server\share`, `\\?\C:\...`). POSIX paths stay case-sensitive so
24
+ // a root check cannot become broader than the filesystem's own identity rules.
25
+ if (/^[A-Za-z]:\//.test(value) || value.startsWith('//')) value = value.toLowerCase()
26
26
  return value
27
27
  }
28
28
 
@@ -0,0 +1,66 @@
1
+ /**
2
+ * Redact host filesystem paths from error details before they cross the HTTP
3
+ * boundary.
4
+ *
5
+ * The Client contract is "ids, counts and status — no filesystem handles".
6
+ * Deleting and memory failures legitimately carry diagnostic paths in their
7
+ * `details`/`cause`; those are useful in the Host log, not in the browser.
8
+ */
9
+ import { basename } from 'node:path'
10
+
11
+ const PATH_KEYS = new Set([
12
+ 'indexFile',
13
+ 'lockFile',
14
+ 'bundlePath',
15
+ 'filePath',
16
+ 'path',
17
+ 'from',
18
+ 'to',
19
+ 'cwd',
20
+ 'trashDir',
21
+ 'auditFile',
22
+ 'dataDir',
23
+ 'rootDir',
24
+ ])
25
+
26
+ const WINDOWS_ABSOLUTE = /(?:[A-Za-z]:[\\/]|\\\\)[^\s"'<>|]+/g
27
+ const POSIX_ABSOLUTE = /(^|[\s("'=])\/(?:[^\s"'<>/]+\/)+[^\s"'<>]+/g
28
+
29
+ function shortPath(value) {
30
+ const normalized = String(value).replace(/\\/g, '/').replace(/\/+$/g, '')
31
+ if (normalized === '') return ''
32
+ return basename(normalized)
33
+ }
34
+
35
+ function redactText(value) {
36
+ if (typeof value !== 'string') return value
37
+ return value
38
+ .replace(WINDOWS_ABSOLUTE, (match) => shortPath(match))
39
+ .replace(POSIX_ABSOLUTE, (match, prefix) => `${prefix}${shortPath(match.slice(prefix.length))}`)
40
+ }
41
+
42
+ /**
43
+ * Recursively redact path-like values. Unknown keys are preserved so the
44
+ * Client can still branch on structured fields (counts, ids, codes).
45
+ * @param {unknown} value
46
+ * @returns {unknown}
47
+ */
48
+ export function redactDetails(value) {
49
+ if (value === null || value === undefined) return value
50
+ if (typeof value === 'string') return redactText(value)
51
+ if (Array.isArray(value)) return value.map((item) => (typeof item === 'string' ? redactText(item) : redactDetails(item)))
52
+ if (typeof value !== 'object') return value
53
+ const result = {}
54
+ for (const [key, item] of Object.entries(value)) {
55
+ if (PATH_KEYS.has(key) && typeof item === 'string') {
56
+ result[key] = shortPath(item)
57
+ continue
58
+ }
59
+ if (key === 'cause' || key === 'message') {
60
+ result[key] = typeof item === 'string' ? redactText(item) : redactDetails(item)
61
+ continue
62
+ }
63
+ result[key] = typeof item === 'string' ? item : redactDetails(item)
64
+ }
65
+ return result
66
+ }