dsh-memu-memory 1.1.2 → 1.1.3

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.
Files changed (49) hide show
  1. package/AGENTS.md +8 -6
  2. package/README.md +21 -5
  3. package/README.zh.md +21 -5
  4. package/docs/contributing.md +5 -3
  5. package/docs/env-and-commands.md +13 -0
  6. package/docs/host-api.md +35 -3
  7. package/docs/memory-semantics.md +32 -5
  8. package/docs/troubleshooting.md +36 -7
  9. package/lib/activity-log.js +190 -0
  10. package/lib/backup.js +1103 -0
  11. package/lib/binding-diagnostics.js +630 -0
  12. package/lib/client.js +697 -49
  13. package/lib/curator.js +81 -37
  14. package/lib/cycle.js +3 -0
  15. package/lib/embedding-catalog.js +763 -0
  16. package/lib/embedding-download.js +407 -0
  17. package/lib/embedding-runtime.js +710 -0
  18. package/lib/engine.js +2 -2
  19. package/lib/index.js +36 -0
  20. package/lib/inject.js +131 -30
  21. package/lib/local-embedding-entry.js +145 -0
  22. package/lib/local-embedding-manager.js +249 -0
  23. package/lib/local-embedding-protocol.js +173 -0
  24. package/lib/local-embedding-worker.js +652 -0
  25. package/lib/local-vector-service.js +283 -0
  26. package/lib/memory.js +385 -45
  27. package/lib/memu-config.js +10 -22
  28. package/lib/memu.js +53 -19
  29. package/lib/paths.js +24 -0
  30. package/lib/private.js +139 -13
  31. package/lib/rpc.js +45 -4
  32. package/lib/state.js +109 -161
  33. package/lib/tools.js +9 -5
  34. package/lib/vector-migration.js +878 -0
  35. package/lib/vector-profile.js +941 -0
  36. package/package.json +3 -2
  37. package/scripts/acceptance-local.mjs +5 -4
  38. package/scripts/chain-matrix.mjs +527 -0
  39. package/scripts/check-all.mjs +19 -0
  40. package/scripts/check-doc-drift.mjs +353 -0
  41. package/scripts/diagnose-bindings.mjs +176 -0
  42. package/scripts/local-runtime-install.mjs +142 -0
  43. package/scripts/local-runtime-verify.mjs +56 -0
  44. package/scripts/local-vector-verify.mjs +93 -0
  45. package/scripts/recall-bench.mjs +813 -0
  46. package/scripts/repetition-ab-scenarios.mjs +93 -0
  47. package/scripts/repetition-detector-snapshot.mjs +314 -0
  48. package/scripts/repetition-eval-live.mjs +360 -0
  49. package/scripts/repetition-eval.mjs +702 -0
package/lib/backup.js ADDED
@@ -0,0 +1,1103 @@
1
+ import crypto from 'node:crypto'
2
+ import fs from 'node:fs'
3
+ import path from 'node:path'
4
+ import { spawnSync } from 'node:child_process'
5
+ import {
6
+ backupsDir, dataDir, ensureDir, indexDir, isSafeId, librariesDir,
7
+ readJson, warn, withFileLock, writeJson,
8
+ } from './paths.js'
9
+
10
+ /**
11
+ * 本地向量施工文档 §14.9 - safe latest-three backup retention.
12
+ *
13
+ * One rule, one place. Every managed backup of the current data root is created
14
+ * by createBackup(), published atomically, and only then may the oldest
15
+ * complete backups beyond BACKUP_KEEP be deleted. Create, delete, restore and
16
+ * prune share a single lock, so the safety backup a restore just made can never
17
+ * collect the very directory that restore is reading from.
18
+ *
19
+ * Invariants:
20
+ * 1. A failed or interrupted creation deletes nothing. The backup is built in
21
+ * backups/.staging/<name>.<pid>.<rand>, hashed file by file, given its
22
+ * manifest last (the completion marker) and only then renamed into place.
23
+ * 2. Only directories that prove themselves complete are ever deleted.
24
+ * Corrupt, half-written, foreign, linked or unknown entries are reported
25
+ * and left exactly where they are.
26
+ * 3. Ordering is the verified manifest createdAt plus a unique id - never an
27
+ * mtime that a copy or a clock change can reorder.
28
+ * 4. A lease pins the directory a restore (or migration) is about to read, so
29
+ * prune cannot remove it mid-flight; going briefly over the limit is
30
+ * allowed and is reclaimed as soon as the operation finishes.
31
+ * 5. A recursive delete re-checks realpath containment and refuses links
32
+ * before it removes a single byte.
33
+ * 6. SQLite is snapshotted only by SQLite's own backup API, run by the
34
+ * plugin's isolated engine/<version>/venv interpreter. A live db+wal+shm is
35
+ * never copied by hand, and a global Python is never borrowed: if the
36
+ * managed engine cannot prove a consistent snapshot, the backup fails
37
+ * instead of publishing a copy that would restore as silent corruption.
38
+ * Only a zero-byte placeholder (no page, no WAL frame) may be copied.
39
+ */
40
+
41
+ export const BACKUP_KEEP = 3
42
+ export const BACKUP_SCHEMA = 2
43
+
44
+ // Timestamp name of a published backup, optionally suffixed for uniqueness
45
+ // within the same millisecond. Anything else in backups/ is not a managed
46
+ // backup and is never counted or deleted by retention.
47
+ const MANAGED_NAME = /^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}-[0-9]{2}-[0-9]{2}-[0-9]{3}Z(-[A-Za-z0-9]+)*$/
48
+ const SQLITE_MAGIC = 'SQLite format 3' + String.fromCharCode(0)
49
+ const BACKSLASH = String.fromCharCode(92)
50
+ const NEWLINE = String.fromCharCode(10)
51
+ const STAGING_TTL_MS = 30 * 60 * 1000
52
+ const LEASE_TTL_MS = 15 * 60 * 1000
53
+ const RETRY_MS = 8
54
+ const PYTHON_TIMEOUT_MS = 120000
55
+ const PROBE_TIMEOUT_MS = 20000
56
+
57
+ const stagingRoot = () => path.join(backupsDir(), '.staging')
58
+ const leaseFile = () => path.join(backupsDir(), '.leases.json')
59
+ const lockAnchor = () => path.join(backupsDir(), '.backup')
60
+ const stateFile = () => path.join(dataDir(), 'state.json')
61
+ const globalDbFile = () => path.join(dataDir(), 'global.sqlite3')
62
+ const vectorActiveFile = () => path.join(indexDir(), 'vector-active.json')
63
+ const engineCurrentFile = () => path.join(dataDir(), 'engine', 'current.json')
64
+
65
+ function isoStamp() { return new Date().toISOString() }
66
+ function pause(ms) { Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms) }
67
+
68
+ function statOrNull(file) {
69
+ try { return fs.lstatSync(file) } catch { return null }
70
+ }
71
+
72
+ function readdirSafe(dir, options) {
73
+ try { return fs.readdirSync(dir, options) } catch { return [] }
74
+ }
75
+
76
+ function realpathOrNull(file) {
77
+ try { return fs.realpathSync.native(file) } catch { return null }
78
+ }
79
+
80
+ function samePath(a, b) {
81
+ const left = path.resolve(String(a))
82
+ const right = path.resolve(String(b))
83
+ return process.platform === 'win32' ? left.toLowerCase() === right.toLowerCase() : left === right
84
+ }
85
+
86
+ function readJsonQuiet(file) {
87
+ try { return JSON.parse(fs.readFileSync(file, 'utf8')) } catch { return null }
88
+ }
89
+
90
+ function sha256File(file) {
91
+ return crypto.createHash('sha256').update(fs.readFileSync(file)).digest('hex')
92
+ }
93
+
94
+ function fileSize(file) {
95
+ const info = statOrNull(file)
96
+ return info ? info.size : -1
97
+ }
98
+
99
+ function hasSqliteHeader(file) {
100
+ let fd
101
+ try {
102
+ fd = fs.openSync(file, 'r')
103
+ const buffer = Buffer.alloc(SQLITE_MAGIC.length)
104
+ const read = fs.readSync(fd, buffer, 0, buffer.length, 0)
105
+ return read === buffer.length && buffer.toString('latin1') === SQLITE_MAGIC
106
+ } catch {
107
+ return false
108
+ } finally {
109
+ if (fd !== undefined) { try { fs.closeSync(fd) } catch {} }
110
+ }
111
+ }
112
+
113
+ // ---------------------------------------------------------------- lock & leases
114
+
115
+ /**
116
+ * The one lock that coordinates create/delete/restore/prune. It is reentrant
117
+ * inside this process (withFileLock keeps a held set), so a restore may create
118
+ * its own safety backup and prune while still holding it.
119
+ */
120
+ export function withBackupLock(action) {
121
+ ensureDir(backupsDir())
122
+ return withFileLock(lockAnchor(), action)
123
+ }
124
+
125
+ function pidAlive(pid) {
126
+ if (!Number.isSafeInteger(pid) || pid <= 0) return false
127
+ if (pid === process.pid) return true
128
+ try { process.kill(pid, 0); return true } catch (error) { return error?.code === 'EPERM' }
129
+ }
130
+
131
+ function readLeases() {
132
+ const raw = readJson(leaseFile(), {})
133
+ return raw && typeof raw === 'object' && !Array.isArray(raw) ? raw : {}
134
+ }
135
+
136
+ /** Leases of operations that are still running right now. */
137
+ export function activeLeases() {
138
+ const now = Date.now()
139
+ const live = {}
140
+ for (const [name, lease] of Object.entries(readLeases())) {
141
+ if (!lease || typeof lease !== 'object') continue
142
+ if (now - Number(lease.at || 0) > LEASE_TTL_MS) continue
143
+ if (!pidAlive(Number(lease.pid))) continue
144
+ live[name] = lease
145
+ }
146
+ return live
147
+ }
148
+
149
+ function writeLease(name, lease) {
150
+ const leases = readLeases()
151
+ if (lease) leases[name] = lease
152
+ else delete leases[name]
153
+ writeJson(leaseFile(), leases)
154
+ }
155
+
156
+ // ------------------------------------------------------------- sqlite snapshots
157
+ //
158
+ // One rule: a live SQLite database is only ever snapshotted by SQLite itself,
159
+ // through the plugin's own isolated interpreter (engine/<version>/venv). There
160
+ // is no global-Python path and no byte-copy fallback. A copy of a live database
161
+ // - or of a database plus its WAL - is torn the moment a writer commits between
162
+ // the two reads, and a torn copy restores as silent corruption, which is worse
163
+ // than no backup at all. So when the managed engine cannot produce a provable
164
+ // snapshot, the whole backup FAILS and nothing is published.
165
+ //
166
+ // The one file that may be copied verbatim is a zero-byte placeholder: no page
167
+ // was ever written and there are no WAL frames, so there is nothing to tear.
168
+
169
+ function engineRoot() { return path.join(dataDir(), 'engine') }
170
+
171
+ function engineCurrent() {
172
+ const raw = readJsonQuiet(engineCurrentFile())
173
+ if (!raw || typeof raw !== 'object') return null
174
+ return {
175
+ version: raw.version || null,
176
+ schemaVersion: raw.schemaVersion || null,
177
+ python: typeof raw.python === 'string' && raw.python ? raw.python : null,
178
+ venv: typeof raw.venv === 'string' && raw.venv ? raw.venv : null,
179
+ }
180
+ }
181
+
182
+ function venvPythonPath(venv) {
183
+ return process.platform === 'win32'
184
+ ? path.join(venv, 'Scripts', 'python.exe')
185
+ : path.join(venv, 'bin', 'python3')
186
+ }
187
+
188
+ /** True when both paths exist and `child` resolves inside `parent`. */
189
+ function insideDir(child, parent) {
190
+ if (typeof child !== 'string' || !child) return false
191
+ const left = realpathOrNull(child)
192
+ const right = realpathOrNull(parent)
193
+ if (!left || !right) return false
194
+ const a = process.platform === 'win32' ? left.toLowerCase() : left
195
+ const b = process.platform === 'win32' ? right.toLowerCase() : right
196
+ return a === b || a.startsWith(b.endsWith(path.sep) ? b : b + path.sep)
197
+ }
198
+
199
+ /**
200
+ * Every interpreter the plugin itself installed: whatever `engine/current.json`
201
+ * points at first (that file is only written after the engine was verified
202
+ * against the real databases), then any engine/<version>/venv* directory found
203
+ * on disk, newest first. The on-disk scan is what makes an engine repair
204
+ * possible: while a new engine installs, its venv is still `venv.tmp-*` and
205
+ * current.json still points at the broken one.
206
+ */
207
+ function engineVenvCandidates() {
208
+ const root = engineRoot()
209
+ const out = []
210
+ const seen = new Set()
211
+ const add = (venv, version, source) => {
212
+ if (typeof venv !== 'string' || !venv) return
213
+ const key = process.platform === 'win32' ? venv.toLowerCase() : venv
214
+ if (seen.has(key)) return
215
+ seen.add(key)
216
+ out.push({ venv, version: version || null, source })
217
+ }
218
+ const current = engineCurrent()
219
+ add(current?.venv, current?.version, 'engine/current.json')
220
+ const mtimeOf = (file) => { const info = statOrNull(file); return info ? info.mtimeMs : 0 }
221
+ const versions = readdirSafe(root, { withFileTypes: true })
222
+ .filter((item) => item.isDirectory())
223
+ .map((item) => ({ name: item.name, at: mtimeOf(path.join(root, item.name)) }))
224
+ .sort((a, b) => b.at - a.at)
225
+ for (const version of versions) {
226
+ const dir = path.join(root, version.name)
227
+ const venvs = readdirSafe(dir, { withFileTypes: true })
228
+ .filter((item) => item.isDirectory() && item.name.indexOf('venv') === 0)
229
+ .map((item) => ({ name: item.name, at: mtimeOf(path.join(dir, item.name)) }))
230
+ .sort((a, b) => b.at - a.at)
231
+ for (const venv of venvs) add(path.join(dir, venv.name), version.name, 'engine/' + version.name + '/' + venv.name)
232
+ }
233
+ return out
234
+ }
235
+
236
+ // The probe answers two questions at once: does this interpreter have sqlite3,
237
+ // and is it really the isolated venv (sys.prefix) rather than a shim that hands
238
+ // the work to a global Python.
239
+ const PYTHON_PROBE_SCRIPT = [
240
+ 'import json, sys',
241
+ 'out = {"ok": False, "error": "unknown"}',
242
+ 'try:',
243
+ ' import sqlite3',
244
+ ' out = {"ok": True, "prefix": sys.prefix, "sqlite": getattr(sqlite3, "sqlite_version", "unknown")}',
245
+ 'except Exception as error:',
246
+ ' out = {"ok": False, "prefix": sys.prefix, "error": str(error)}',
247
+ 'sys.stdout.write(json.dumps(out))',
248
+ ].join(NEWLINE)
249
+
250
+ const SQLITE_SNAPSHOT_SCRIPT = [
251
+ 'import json, sqlite3, sys',
252
+ 'src, dst = sys.argv[1], sys.argv[2]',
253
+ 'out = {"ok": False, "error": "unknown"}',
254
+ 'try:',
255
+ ' source = sqlite3.connect(src, timeout=10)',
256
+ ' try:',
257
+ ' target = sqlite3.connect(dst)',
258
+ ' try:',
259
+ ' source.backup(target)',
260
+ ' row = target.execute("pragma quick_check").fetchone()',
261
+ ' out = {"ok": True, "quickCheck": (row[0] if row else "unknown")}',
262
+ ' finally:',
263
+ ' target.close()',
264
+ ' finally:',
265
+ ' source.close()',
266
+ 'except Exception as error:',
267
+ ' out = {"ok": False, "error": str(error)}',
268
+ 'sys.stdout.write(json.dumps(out))',
269
+ ].join(NEWLINE)
270
+
271
+ let enginePythonCache = null
272
+
273
+ /** Forget the cached interpreter probe (tests use this between scenarios). */
274
+ export function resetBackupPythonProbe() { enginePythonCache = null }
275
+
276
+ /**
277
+ * Runs an interpreter. `exec` is a seam for tests only (a fake interpreter);
278
+ * production always spawns the verified venv binary and nothing else.
279
+ */
280
+ function runPython(exec, cmd, args, timeout) {
281
+ const runner = typeof exec === 'function' ? exec : spawnSync
282
+ try {
283
+ return runner(cmd, args, { timeout, windowsHide: true, encoding: 'utf8', maxBuffer: 4 * 1024 * 1024 })
284
+ } catch (error) {
285
+ return { error, status: null, stdout: '', stderr: '' }
286
+ }
287
+ }
288
+
289
+ function jsonOutcome(result) {
290
+ const text = String(result?.stdout || '').trim()
291
+ if (!text) return null
292
+ try { return JSON.parse(text) } catch { return null }
293
+ }
294
+
295
+ /**
296
+ * Resolves the engine interpreter a snapshot may use, or reports why there is
297
+ * none. Never falls back to a global interpreter: a missing, foreign or broken
298
+ * venv is a refusal, not a downgrade.
299
+ */
300
+ function enginePythonForBackup(exec) {
301
+ const disabled = process.env.DSH_MEMU_BACKUP_NO_PYTHON === '1'
302
+ const root = engineRoot()
303
+ const candidates = disabled ? [] : engineVenvCandidates()
304
+ const key = JSON.stringify({ disabled, root, candidates })
305
+ if (enginePythonCache && enginePythonCache.key === key) return enginePythonCache.value
306
+ const value = { python: null, rejected: [] }
307
+ for (const candidate of candidates) {
308
+ const python = venvPythonPath(candidate.venv)
309
+ if (!fs.existsSync(python)) {
310
+ value.rejected.push(candidate.source + ': no interpreter on disk')
311
+ continue
312
+ }
313
+ if (!insideDir(candidate.venv, root) || !insideDir(python, root)) {
314
+ value.rejected.push(candidate.source + ': outside ' + root)
315
+ continue
316
+ }
317
+ const probe = runPython(exec, python, ['-c', PYTHON_PROBE_SCRIPT], PROBE_TIMEOUT_MS)
318
+ const outcome = jsonOutcome(probe)
319
+ if (!outcome || outcome.ok !== true) {
320
+ const reason = outcome?.error || probe?.error?.message || (probe?.status != null ? 'exited ' + probe.status : 'no answer')
321
+ value.rejected.push(candidate.source + ': ' + String(reason).slice(0, 200))
322
+ continue
323
+ }
324
+ if (!insideDir(outcome.prefix, root)) {
325
+ value.rejected.push(candidate.source + ': interpreter reports sys.prefix ' + String(outcome.prefix) + ', outside engine/')
326
+ continue
327
+ }
328
+ value.python = {
329
+ cmd: python,
330
+ venv: candidate.venv,
331
+ version: candidate.version,
332
+ source: candidate.source,
333
+ sqlite: outcome.sqlite ? String(outcome.sqlite) : null,
334
+ }
335
+ break
336
+ }
337
+ enginePythonCache = { key, value }
338
+ return value
339
+ }
340
+
341
+ function backupRefusal(code, message) {
342
+ const error = new Error(message)
343
+ error.code = code
344
+ return error
345
+ }
346
+
347
+ function removeQuietly(file) {
348
+ try { fs.rmSync(file, { force: true }) } catch {}
349
+ }
350
+
351
+ /**
352
+ * Asks the engine interpreter to run SQLite's online backup API: it folds any
353
+ * live WAL into one self-contained file and hands back a proven-consistent
354
+ * snapshot. Anything short of a clean `quick_check` is a failure, never a
355
+ * reason to copy the bytes ourselves.
356
+ */
357
+ function pythonSnapshot(exec, python, from, to) {
358
+ const result = runPython(exec, python.cmd, ['-c', SQLITE_SNAPSHOT_SCRIPT, from, to], PYTHON_TIMEOUT_MS)
359
+ if (result?.error) return { ok: false, error: String(result.error.message || result.error) }
360
+ if (result?.status !== 0) {
361
+ return { ok: false, error: String(result?.stderr || ('engine interpreter exited ' + result?.status)).trim().slice(0, 400) }
362
+ }
363
+ const payload = jsonOutcome(result)
364
+ if (!payload || payload.ok !== true) {
365
+ return { ok: false, error: String(payload?.error || 'sqlite3 backup returned no result').slice(0, 400) }
366
+ }
367
+ if (payload.quickCheck !== 'ok') {
368
+ return { ok: false, error: 'snapshot failed pragma quick_check: ' + String(payload.quickCheck || 'unknown').slice(0, 200) }
369
+ }
370
+ if (!hasSqliteHeader(to) || fileSize(to) <= 0) {
371
+ return { ok: false, error: 'snapshot has no SQLite database header' }
372
+ }
373
+ return { ok: true, quickCheck: 'ok' }
374
+ }
375
+
376
+ /**
377
+ * Produces one consistent copy of a live SQLite database inside the staging
378
+ * directory. Two outcomes only: a snapshot the engine proved consistent, or a
379
+ * thrown refusal that aborts the whole backup.
380
+ */
381
+ function snapshotDatabase(from, to, exec) {
382
+ const before = statOrNull(from)
383
+ if (!before) return { mode: 'missing' }
384
+ if (before.isSymbolicLink()) throw new Error('backup refuses symbolic links')
385
+ if (!before.isFile()) throw new Error('backup refuses non-file: ' + path.basename(from))
386
+ const name = path.basename(from)
387
+ const walSize = fileSize(from + '-wal')
388
+ const walContent = walSize > 0
389
+ if (before.size === 0 && !walContent) {
390
+ // A placeholder no engine has written a page to: copying it cannot tear.
391
+ fs.copyFileSync(from, to)
392
+ return { mode: 'empty-placeholder', quickCheck: 'empty' }
393
+ }
394
+ if (!hasSqliteHeader(from)) {
395
+ throw backupRefusal('DSH_MEMU_BACKUP_NOT_SQLITE', 'backup refuses ' + name
396
+ + ': it is neither an empty placeholder nor a SQLite database, so its contents cannot be verified'
397
+ + (walContent ? ' (it has a non-empty WAL)' : ''))
398
+ }
399
+ const resolved = enginePythonForBackup(exec)
400
+ const python = resolved.python
401
+ if (!python) {
402
+ const why = resolved.rejected.length ? ' (' + resolved.rejected.join('; ') + ')' : ''
403
+ throw backupRefusal('DSH_MEMU_BACKUP_ENGINE_REQUIRED', 'backup of ' + name
404
+ + " needs the plugin engine: no verified engine/<version>/venv Python with sqlite3 was found under "
405
+ + engineRoot() + why + '. Install or repair the engine, then retry. '
406
+ + 'Nothing was copied: a live database copied by hand can restore as silent corruption.')
407
+ }
408
+ const snapshot = pythonSnapshot(exec, python, from, to)
409
+ if (!snapshot.ok) {
410
+ removeQuietly(to)
411
+ removeQuietly(to + '-wal')
412
+ removeQuietly(to + '-shm')
413
+ throw backupRefusal('DSH_MEMU_BACKUP_SNAPSHOT_FAILED', 'backup could not snapshot ' + name + ' with '
414
+ + python.source + ' (engine ' + String(python.version || 'unknown') + '): ' + snapshot.error)
415
+ }
416
+ return {
417
+ mode: 'sqlite-backup-api',
418
+ engine: python.version ? String(python.version) : null,
419
+ via: python.source,
420
+ sqlite: python.sqlite,
421
+ quickCheck: snapshot.quickCheck,
422
+ }
423
+ }
424
+
425
+ // ------------------------------------------------------------------- inventory
426
+
427
+ function copyRegular(from, to) {
428
+ const info = statOrNull(from)
429
+ if (!info) return false
430
+ if (info.isSymbolicLink()) throw new Error('backup refuses symbolic links')
431
+ if (!info.isFile()) throw new Error('backup refuses non-file: ' + path.basename(from))
432
+ ensureDir(path.dirname(to))
433
+ fs.copyFileSync(from, to)
434
+ return true
435
+ }
436
+
437
+ function copyTree(from, to) {
438
+ const entries = readdirSafe(from, { withFileTypes: true })
439
+ if (!entries.length && !statOrNull(from)) return
440
+ ensureDir(to)
441
+ for (const item of entries) {
442
+ if (item.isSymbolicLink()) throw new Error('backup refuses symbolic links')
443
+ const source = path.join(from, item.name)
444
+ const target = path.join(to, item.name)
445
+ if (item.isDirectory()) copyTree(source, target)
446
+ else copyRegular(source, target)
447
+ }
448
+ }
449
+
450
+ function collectFiles(dir, prefix = '', out = {}) {
451
+ for (const item of readdirSafe(dir, { withFileTypes: true })) {
452
+ const full = path.join(dir, item.name)
453
+ const rel = prefix ? prefix + '/' + item.name : item.name
454
+ const info = statOrNull(full)
455
+ if (!info) continue
456
+ if (info.isSymbolicLink()) throw new Error('backup refuses symbolic links')
457
+ if (info.isDirectory()) { collectFiles(full, rel, out); continue }
458
+ if (!info.isFile()) continue
459
+ out[rel] = { bytes: info.size, sha256: sha256File(full) }
460
+ }
461
+ return out
462
+ }
463
+
464
+ // --------------------------------------------------------------- backup creation
465
+
466
+ function nextBackupName() {
467
+ const base = isoStamp().replace(/[:.]/g, '-')
468
+ let name = base
469
+ for (let attempt = 0; fs.existsSync(path.join(backupsDir(), name)); attempt += 1) {
470
+ if (attempt >= 64) throw new Error('cannot allocate a unique backup name')
471
+ name = base + '-' + process.pid.toString(36) + attempt.toString(36)
472
+ }
473
+ return name
474
+ }
475
+
476
+ function vectorSummary() {
477
+ const raw = readJsonQuiet(vectorActiveFile())
478
+ if (!raw || typeof raw !== 'object') return { present: false }
479
+ const active = raw.active && typeof raw.active === 'object' ? raw.active : null
480
+ const profile = active && active.profile && typeof active.profile === 'object' ? active.profile : null
481
+ const libraries = active && active.databases && active.databases.libraries && typeof active.databases.libraries === 'object'
482
+ ? Object.keys(active.databases.libraries).length
483
+ : 0
484
+ return {
485
+ present: true,
486
+ generation: Number.isSafeInteger(raw.generation) ? raw.generation : null,
487
+ active: active
488
+ ? {
489
+ generation: Number.isSafeInteger(active.generation) ? active.generation : null,
490
+ fingerprint: typeof active.fingerprint === 'string' ? active.fingerprint : null,
491
+ catalogId: typeof profile?.catalogId === 'string' ? profile.catalogId : null,
492
+ revision: typeof profile?.revision === 'string' ? profile.revision : null,
493
+ dimension: Number.isSafeInteger(profile?.dimension) ? profile.dimension : null,
494
+ libraries,
495
+ }
496
+ : null,
497
+ pending: raw.pending && typeof raw.pending === 'object' ? String(raw.pending.status || '') : null,
498
+ }
499
+ }
500
+
501
+ function engineSummary() {
502
+ const current = engineCurrent()
503
+ return current ? { version: current.version, schemaVersion: current.schemaVersion } : null
504
+ }
505
+
506
+ /**
507
+ * Copies every store a restore needs: plugin state, the global index and the
508
+ * per-library index files, the active vector profile journal, prompt overrides
509
+ * and their manifest, attachment references, and every MemU SQLite database.
510
+ * Deliberately absent: engine/, downloads/, logs/, tmp/ (large and
511
+ * re-downloadable) and any key material - the manifest carries identity, never
512
+ * a credential.
513
+ *
514
+ * `exec` only exists so tests can stand in for the engine interpreter; the
515
+ * default path spawns the verified venv binary and nothing else.
516
+ */
517
+ function writeStores(staging, exec) {
518
+ ensureDir(path.join(staging, 'libraries'))
519
+ ensureDir(path.join(staging, 'index'))
520
+ ensureDir(path.join(staging, 'prompts'))
521
+ copyRegular(stateFile(), path.join(staging, 'state.json'))
522
+ for (const name of readdirSafe(indexDir())) {
523
+ if (name.endsWith('.json')) copyRegular(path.join(indexDir(), name), path.join(staging, 'index', name))
524
+ }
525
+ copyRegular(path.join(dataDir(), 'prompts', 'manifest.json'), path.join(staging, 'prompts', 'manifest.json'))
526
+ copyTree(path.join(dataDir(), 'prompts'), path.join(staging, 'prompts'))
527
+ copyTree(path.join(dataDir(), 'attachments'), path.join(staging, 'attachments'))
528
+ const databases = []
529
+ const globalInfo = statOrNull(globalDbFile())
530
+ if (globalInfo && globalInfo.isFile()) databases.push({ src: globalDbFile(), rel: 'global.sqlite3' })
531
+ for (const item of readdirSafe(librariesDir(), { withFileTypes: true })) {
532
+ if (!item.isFile() || !item.name.endsWith('.sqlite3')) continue
533
+ databases.push({ src: path.join(librariesDir(), item.name), rel: 'libraries/' + item.name })
534
+ }
535
+ const snapshots = []
536
+ for (const database of databases) {
537
+ const target = path.join(staging, database.rel.split('/').join(path.sep))
538
+ ensureDir(path.dirname(target))
539
+ const result = snapshotDatabase(database.src, target, exec)
540
+ snapshots.push({
541
+ file: database.rel,
542
+ mode: result.mode,
543
+ engine: result.engine || null,
544
+ quickCheck: result.quickCheck || null,
545
+ })
546
+ }
547
+ return snapshots
548
+ }
549
+
550
+ function publishStaging(staging, name) {
551
+ const dir = path.join(backupsDir(), name)
552
+ if (fs.existsSync(dir)) throw new Error('backup directory already exists: ' + name)
553
+ try {
554
+ fs.renameSync(staging, dir)
555
+ } catch (error) {
556
+ throw new Error('could not publish backup ' + name + ': ' + (error?.code || 'io error'))
557
+ }
558
+ return dir
559
+ }
560
+
561
+ function discardStaging(staging) {
562
+ try { fs.rmSync(staging, { recursive: true, force: true, maxRetries: 3 }) } catch (error) {
563
+ warn('backups: could not discard staging directory (' + (error?.code || 'io error') + ')')
564
+ }
565
+ }
566
+
567
+ /** Staging leftovers of crashed runs are reclaimed; a live sibling is kept. */
568
+ function cleanupStaging() {
569
+ const root = stagingRoot()
570
+ const now = Date.now()
571
+ for (const item of readdirSafe(root, { withFileTypes: true })) {
572
+ if (!item.isDirectory()) continue
573
+ const full = path.join(root, item.name)
574
+ const parts = item.name.split('.')
575
+ const pid = Number(parts.length > 1 ? parts[parts.length - 2] : NaN)
576
+ const info = statOrNull(full)
577
+ const age = info ? now - info.mtimeMs : 0
578
+ const mine = Number.isSafeInteger(pid) && pid === process.pid
579
+ const stale = mine ? age > STAGING_TTL_MS : (!Number.isSafeInteger(pid) || !pidAlive(pid) || age > STAGING_TTL_MS)
580
+ if (!stale) continue
581
+ try { fs.rmSync(full, { recursive: true, force: true, maxRetries: 3 }) } catch {}
582
+ }
583
+ }
584
+
585
+ /**
586
+ * Creates one managed backup of the current data root, then keeps the newest
587
+ * BACKUP_KEEP complete ones. Returns a report whose dir is the published
588
+ * directory, so every existing caller that treats the return value as a path
589
+ * keeps working.
590
+ *
591
+ * `pythonExec` is a test seam for the engine interpreter (see writeStores).
592
+ * A database that cannot be snapshotted by the verified engine throws, and the
593
+ * throw happens before anything is published, so no partial or torn backup can
594
+ * ever appear in backups/.
595
+ */
596
+ export function createBackup({ label = 'auto', keep = BACKUP_KEEP, flushState = null, pythonExec = null } = {}) {
597
+ return withBackupLock(() => {
598
+ ensureDir(backupsDir())
599
+ cleanupStaging()
600
+ const name = nextBackupName()
601
+ const staging = path.join(stagingRoot(), name + '.' + process.pid + '.' + crypto.randomBytes(4).toString('hex'))
602
+ ensureDir(staging)
603
+ try {
604
+ // Plugin state is flushed first: a backup must never capture the pending
605
+ // bookkeeping edits that are still only in memory.
606
+ if (typeof flushState === 'function') flushState()
607
+ const snapshots = writeStores(staging, pythonExec)
608
+ const files = collectFiles(staging)
609
+ const totals = { files: 0, bytes: 0 }
610
+ for (const rel of Object.keys(files)) {
611
+ totals.files += 1
612
+ totals.bytes += Number(files[rel].bytes || 0)
613
+ }
614
+ const createdAt = isoStamp()
615
+ const manifest = {
616
+ version: BACKUP_SCHEMA,
617
+ kind: 'dsh-memu-memory/backup',
618
+ name,
619
+ uniqueId: name + '-' + crypto.randomBytes(4).toString('hex'),
620
+ createdAt,
621
+ createdAtMs: Date.parse(createdAt) || Date.now(),
622
+ label: String(label || 'auto'),
623
+ complete: true,
624
+ engine: engineSummary(),
625
+ vector: vectorSummary(),
626
+ snapshot: { databases: snapshots },
627
+ totals,
628
+ files,
629
+ }
630
+ // Written last and atomically: a finished manifest is the completion
631
+ // marker that prune requires before it may delete anything.
632
+ writeJson(path.join(staging, 'manifest.json'), manifest)
633
+ const dir = publishStaging(staging, name)
634
+ const prune = pruneBackups({ keep })
635
+ return {
636
+ name,
637
+ dir,
638
+ createdAt,
639
+ files: totals.files,
640
+ bytes: totals.bytes,
641
+ snapshots: snapshots.map(item => ({ file: item.file, mode: item.mode, engine: item.engine || null })),
642
+ pruned: prune.deleted,
643
+ pruneFailed: prune.failed,
644
+ pendingCleanup: prune.pendingCleanup,
645
+ notice: prune.notice,
646
+ }
647
+ } catch (error) {
648
+ discardStaging(staging)
649
+ throw error
650
+ }
651
+ })
652
+ }
653
+
654
+ // -------------------------------------------------------------- listing / scan
655
+
656
+ function isManagedName(name) {
657
+ return typeof name === 'string' && isSafeId(name) && MANAGED_NAME.test(name)
658
+ }
659
+
660
+ /** A relative path inside a backup that we are willing to copy back. */
661
+ function isSafeRelative(rel) {
662
+ if (typeof rel !== 'string' || !rel) return false
663
+ if (path.isAbsolute(rel)) return false
664
+ if (rel.indexOf(BACKSLASH) !== -1) return false
665
+ for (const part of rel.split('/')) {
666
+ if (!part || part === '.' || part === '..') return false
667
+ }
668
+ return true
669
+ }
670
+
671
+ function inventoryIssues(dir, files) {
672
+ const issues = []
673
+ const rels = Object.keys(files)
674
+ if (!rels.length) return ['manifest lists no files']
675
+ for (const rel of rels) {
676
+ if (!isSafeRelative(rel)) { issues.push('unsafe path in manifest: ' + rel.slice(0, 120)); continue }
677
+ if (issues.length >= 5) { issues.push('and more'); break }
678
+ const full = path.join(dir, rel.split('/').join(path.sep))
679
+ const info = statOrNull(full)
680
+ if (!info) { issues.push('missing: ' + rel); continue }
681
+ if (!info.isFile()) { issues.push('not a file: ' + rel); continue }
682
+ const expected = Number(files[rel]?.bytes)
683
+ if (Number.isFinite(expected) && expected !== info.size) issues.push('size mismatch: ' + rel)
684
+ }
685
+ return issues
686
+ }
687
+
688
+ function buildEntry(name, dir, manifest, status, issues) {
689
+ const files = manifest?.files && typeof manifest.files === 'object' ? manifest.files : null
690
+ let bytes = null
691
+ if (files) {
692
+ bytes = 0
693
+ for (const rel of Object.keys(files)) bytes += Number(files[rel]?.bytes || 0)
694
+ }
695
+ const createdAt = typeof manifest?.createdAt === 'string' ? manifest.createdAt : null
696
+ const info = statOrNull(dir)
697
+ const parsed = Date.parse(createdAt || '')
698
+ return {
699
+ name,
700
+ dir,
701
+ createdAt: createdAt || (info ? new Date(info.mtimeMs).toISOString() : null),
702
+ createdAtMs: Number.isFinite(Number(manifest?.createdAtMs)) ? Number(manifest.createdAtMs) : (Number.isFinite(parsed) ? parsed : (info ? info.mtimeMs : 0)),
703
+ engine: manifest?.engine || null,
704
+ files: files ? Object.keys(files).length : null,
705
+ bytes,
706
+ hasManifest: Boolean(manifest),
707
+ complete: status === 'complete',
708
+ status,
709
+ issues: issues.slice(0, 6),
710
+ uniqueId: typeof manifest?.uniqueId === 'string' ? manifest.uniqueId : null,
711
+ label: typeof manifest?.label === 'string' ? manifest.label : null,
712
+ vector: manifest?.vector || null,
713
+ }
714
+ }
715
+
716
+ /**
717
+ * Reads one directory in backups/ and classifies it. status is 'complete'
718
+ * (only these are counted or deleted), 'incomplete', 'corrupt' or 'unknown'.
719
+ * verify re-hashes every file: cheap listing stays cheap, but retention must
720
+ * never count a backup it has not actually checked.
721
+ */
722
+ function scanEntry(name, { verify = false } = {}) {
723
+ const dir = path.join(backupsDir(), name)
724
+ const info = statOrNull(dir)
725
+ if (!info) return null
726
+ if (info.isSymbolicLink()) return buildEntry(name, dir, null, 'unknown', ['backups entry is a symbolic link or junction'])
727
+ if (!info.isDirectory()) return buildEntry(name, dir, null, 'unknown', ['backups entry is not a directory'])
728
+ const manifest = readJsonQuiet(path.join(dir, 'manifest.json'))
729
+ const issues = []
730
+ let status = 'complete'
731
+ if (!manifest || typeof manifest !== 'object' || Array.isArray(manifest)) {
732
+ status = 'unknown'
733
+ issues.push('manifest.json is missing or unreadable')
734
+ } else if (!isManagedName(name)) {
735
+ status = 'unknown'
736
+ issues.push('not a managed backup name')
737
+ } else if (!manifest.files || typeof manifest.files !== 'object' || Array.isArray(manifest.files)) {
738
+ status = 'incomplete'
739
+ issues.push('manifest has no file inventory')
740
+ } else if (manifest.complete !== true) {
741
+ status = 'incomplete'
742
+ issues.push('backup was never completed')
743
+ }
744
+ if (status === 'complete') {
745
+ for (const issue of inventoryIssues(dir, manifest.files)) issues.push(issue)
746
+ if (issues.length) status = 'incomplete'
747
+ }
748
+ if (status === 'complete' && verify) {
749
+ for (const rel of Object.keys(manifest.files)) {
750
+ const expected = manifest.files[rel] || {}
751
+ if (typeof expected.sha256 !== 'string' || !expected.sha256) continue
752
+ const full = path.join(dir, rel.split('/').join(path.sep))
753
+ let actual = null
754
+ try { actual = sha256File(full) } catch { actual = null }
755
+ if (actual === expected.sha256) continue
756
+ status = 'corrupt'
757
+ issues.push('checksum mismatch: ' + rel)
758
+ break
759
+ }
760
+ }
761
+ if (status !== 'unknown') {
762
+ const real = realpathOrNull(dir)
763
+ if (!real || !samePath(real, dir)) { status = 'unknown'; issues.push('directory resolves somewhere else (link or junction)') }
764
+ else if (!samePath(path.dirname(path.resolve(dir)), path.resolve(backupsDir()))) { status = 'unknown'; issues.push('directory is not a direct child of backups/') }
765
+ }
766
+ return buildEntry(name, dir, manifest, status, issues)
767
+ }
768
+
769
+ function compareEntries(a, b) {
770
+ // createdAt first because mtimes lie, then the name: names are unique inside
771
+ // backups/ and a colliding millisecond appends a monotonic suffix, so the
772
+ // order stays deterministic without trusting the filesystem's clock.
773
+ if (b.createdAtMs !== a.createdAtMs) return b.createdAtMs - a.createdAtMs
774
+ return String(b.name).localeCompare(String(a.name))
775
+ }
776
+
777
+ /** Every entry in backups/, newest first, including incomplete and unknown. */
778
+ export function scanBackups({ verify = false } = {}) {
779
+ const out = []
780
+ for (const item of readdirSafe(backupsDir(), { withFileTypes: true })) {
781
+ if (item.name.startsWith('.')) continue
782
+ const scanned = scanEntry(item.name, { verify })
783
+ if (scanned) out.push(scanned)
784
+ }
785
+ return out.sort(compareEntries)
786
+ }
787
+
788
+ /** Public listing used by the UI: newest first, with completeness reported. */
789
+ export function listBackupEntries() { return scanBackups() }
790
+
791
+ export function findBackup(name) {
792
+ if (!isSafeId(name) || name.startsWith('.')) throw new Error('invalid backup name')
793
+ const dir = path.join(backupsDir(), name)
794
+ if (!statOrNull(dir)) throw new Error('backup not found: ' + name)
795
+ const found = scanEntry(name)
796
+ if (!found) throw new Error('backup not found: ' + name)
797
+ return found
798
+ }
799
+
800
+ // ----------------------------------------------------------------------- prune
801
+
802
+ function assertNoLinks(dir, prefix) {
803
+ for (const item of readdirSafe(dir, { withFileTypes: true })) {
804
+ const rel = prefix ? prefix + '/' + item.name : item.name
805
+ if (item.isSymbolicLink()) throw new Error('refusing to delete: ' + rel + ' is a link')
806
+ if (item.isDirectory()) assertNoLinks(path.join(dir, item.name), rel)
807
+ }
808
+ }
809
+
810
+ /**
811
+ * Every guard a recursive delete needs, re-checked on the resolved path:
812
+ * managed single-segment name, direct child of backups/, realpath unchanged,
813
+ * and no link or junction anywhere inside.
814
+ */
815
+ function assertRemovable(found) {
816
+ if (!found || typeof found.name !== 'string' || !isManagedName(found.name)) throw new Error('refusing to delete a backup with an unmanaged name')
817
+ const dir = path.join(backupsDir(), found.name)
818
+ const info = statOrNull(dir)
819
+ if (!info) throw new Error('backup not found: ' + found.name)
820
+ if (info.isSymbolicLink()) throw new Error('refusing to delete a link: ' + found.name)
821
+ if (!info.isDirectory()) throw new Error('refusing to delete a non-directory: ' + found.name)
822
+ if (!samePath(path.dirname(path.resolve(dir)), path.resolve(backupsDir()))) throw new Error('refusing to delete outside backups/: ' + found.name)
823
+ const real = realpathOrNull(dir)
824
+ if (!real || !samePath(real, dir)) throw new Error('refusing to delete a redirected path: ' + found.name)
825
+ assertNoLinks(dir, '')
826
+ }
827
+
828
+ function removeBackupDir(found) {
829
+ assertRemovable(found)
830
+ fs.rmSync(path.join(backupsDir(), found.name), { recursive: true, force: true, maxRetries: 3 })
831
+ }
832
+
833
+ /**
834
+ * Keeps the newest keep complete managed backups and deletes the rest.
835
+ * Incomplete and unknown entries are reported, never counted, never deleted.
836
+ * A leased directory (a restore or migration is reading it) is skipped, as is
837
+ * the safety backup named in protect - retention may briefly exceed the limit,
838
+ * and the next prune reclaims it.
839
+ */
840
+ export function pruneBackups({ keep = BACKUP_KEEP, protect = [] } = {}) {
841
+ return withBackupLock(() => {
842
+ ensureDir(backupsDir())
843
+ cleanupStaging()
844
+ const protectSet = new Set()
845
+ for (const name of protect) if (name) protectSet.add(path.basename(String(name)))
846
+ for (const name of Object.keys(activeLeases())) protectSet.add(name)
847
+ // Retention decides what to delete, so it pays for the full hash pass: a
848
+ // backup whose bytes no longer match its manifest must not occupy one of the
849
+ // three slots, and must never be the reason an intact one is deleted.
850
+ const all = scanBackups({ verify: true })
851
+ const complete = all.filter(found => found.status === 'complete')
852
+ const victims = complete.slice(keep).filter(found => !protectSet.has(found.name))
853
+ const deleted = []
854
+ const failed = []
855
+ for (const victim of victims) {
856
+ try {
857
+ removeBackupDir(victim)
858
+ deleted.push(victim.name)
859
+ } catch (error) {
860
+ failed.push({ name: victim.name, error: String(error?.message || error) })
861
+ warn('backups: could not remove ' + victim.name + ' (' + String(error?.message || error) + ')')
862
+ }
863
+ }
864
+ const kept = deleted.length
865
+ ? scanBackups().filter(found => found.status === 'complete')
866
+ : complete.filter(found => !deleted.includes(found.name))
867
+ const notice = 'at most ' + keep + ' complete backups are kept; older ones are cleaned up automatically'
868
+ if (deleted.length) {
869
+ warn('backups: ' + notice + ' (deleted ' + deleted.join(', ') + ')')
870
+ try {
871
+ const marker = path.join(backupsDir(), '.retention-notice.json')
872
+ if (!readJsonQuiet(marker)) writeJson(marker, { at: isoStamp(), policy: 'keep ' + keep, notice })
873
+ } catch {}
874
+ }
875
+ return {
876
+ keep,
877
+ notice,
878
+ deleted,
879
+ failed,
880
+ protected: Array.from(protectSet),
881
+ pendingCleanup: Math.max(0, kept.length - keep),
882
+ kept: kept.map(found => found.name),
883
+ skipped: all.filter(found => found.status !== 'complete').map(found => ({ name: found.name, status: found.status, issues: found.issues })),
884
+ }
885
+ })
886
+ }
887
+
888
+ /** Explicit removal requested by the user. */
889
+ export function deleteBackupEntry(name) {
890
+ return withBackupLock(() => {
891
+ const found = findBackup(name)
892
+ if (activeLeases()[found.name]) throw new Error('backup is in use by a running restore: ' + found.name)
893
+ removeBackupDir(found)
894
+ return { deleted: found.name, status: found.status }
895
+ })
896
+ }
897
+
898
+ // --------------------------------------------------------------------- restore
899
+
900
+ /** Where one relative path inside a backup lands in the live data root. */
901
+ function liveTargetFor(rel) {
902
+ const parts = rel.split('/')
903
+ const head = parts[0]
904
+ if (parts.length === 1) {
905
+ if (head === 'state.json') return stateFile()
906
+ if (head === 'global.sqlite3') return globalDbFile()
907
+ if (head === 'global.sqlite3-wal') return globalDbFile() + '-wal'
908
+ if (head === 'global.sqlite3-shm') return globalDbFile() + '-shm'
909
+ }
910
+ if ((head === 'index' || head === 'libraries') && parts.length === 2) {
911
+ return path.join(head === 'index' ? indexDir() : librariesDir(), parts[1])
912
+ }
913
+ if ((head === 'prompts' || head === 'attachments') && parts.length >= 2) {
914
+ return path.join(dataDir(), parts.join(path.sep))
915
+ }
916
+ throw new Error('backup contains an entry this version cannot restore: ' + rel)
917
+ }
918
+
919
+ function writeAtomicCopy(from, to) {
920
+ ensureDir(path.dirname(to))
921
+ const tmp = to + '.restore-' + process.pid + '-' + crypto.randomBytes(4).toString('hex')
922
+ try {
923
+ fs.copyFileSync(from, tmp)
924
+ const fd = fs.openSync(tmp, 'r+')
925
+ try { fs.fsyncSync(fd) } finally { fs.closeSync(fd) }
926
+ for (let attempt = 0; ; attempt += 1) {
927
+ try { fs.renameSync(tmp, to); return } catch (error) {
928
+ if (!['EPERM', 'EBUSY', 'EACCES'].includes(error?.code) || attempt >= 5) throw error
929
+ pause(RETRY_MS * (attempt + 1))
930
+ }
931
+ }
932
+ } catch (error) {
933
+ try { fs.rmSync(tmp, { force: true }) } catch {}
934
+ throw error
935
+ }
936
+ }
937
+
938
+ /** A restored database must not inherit the WAL of the one it replaces. */
939
+ function dropCompanions(target) {
940
+ for (const suffix of ['-wal', '-shm']) {
941
+ try { fs.rmSync(target + suffix, { force: true }) } catch (error) {
942
+ if (error?.code !== 'ENOENT') throw error
943
+ }
944
+ }
945
+ }
946
+
947
+ function isSidecar(rel) {
948
+ return rel.endsWith('-wal') || rel.endsWith('-shm')
949
+ }
950
+
951
+ /** A database file always lands before its sidecars. */
952
+ function restoreOrder(rels) {
953
+ return rels.slice().sort((left, right) => {
954
+ const delta = (isSidecar(left) ? 1 : 0) - (isSidecar(right) ? 1 : 0)
955
+ if (delta !== 0) return delta
956
+ return left.localeCompare(right)
957
+ })
958
+ }
959
+
960
+ /**
961
+ * Validates the WHOLE inventory before anything is written, so a restore either
962
+ * has every byte it needs or leaves the live root untouched.
963
+ */
964
+ function verifyInventory(found) {
965
+ const manifest = readJsonQuiet(path.join(found.dir, 'manifest.json'))
966
+ if (!manifest || typeof manifest !== 'object' || !manifest.files || typeof manifest.files !== 'object') {
967
+ throw new Error('backup is incomplete: ' + found.name + ' has no file inventory')
968
+ }
969
+ const rels = []
970
+ for (const rel of Object.keys(manifest.files)) {
971
+ if (!isSafeRelative(rel)) throw new Error('invalid backup manifest path')
972
+ const full = path.join(found.dir, rel.split('/').join(path.sep))
973
+ const info = statOrNull(full)
974
+ if (!info || info.isSymbolicLink() || !info.isFile()) throw new Error('backup is incomplete: ' + rel + ' is missing')
975
+ const expected = manifest.files[rel] || {}
976
+ if (Number.isFinite(Number(expected.bytes)) && Number(expected.bytes) !== info.size) {
977
+ throw new Error('backup is corrupt: ' + rel + ' has the wrong size')
978
+ }
979
+ if (expected.sha256 && sha256File(full) !== expected.sha256) {
980
+ throw new Error('backup is corrupt: ' + rel + ' failed its checksum')
981
+ }
982
+ liveTargetFor(rel)
983
+ rels.push(rel)
984
+ }
985
+ if (!rels.length) throw new Error('backup is incomplete: ' + found.name + ' lists no files')
986
+ return { manifest, rels }
987
+ }
988
+
989
+ function normalizeDbRel(value) {
990
+ if (typeof value !== 'string' || !value) return null
991
+ const rel = value.split(BACKSLASH).join('/').replace(/^\.\//, '')
992
+ if (rel.startsWith('libraries/')) return rel
993
+ if (rel.indexOf('/') === -1) return 'libraries/' + rel
994
+ return rel
995
+ }
996
+
997
+ /**
998
+ * A backup made before the vector journal existed carries no vector-active
999
+ * manifest. Restoring it over a live root that still points at the newer
1000
+ * generation would leave recall reading tables the restored text index never
1001
+ * saw, so that pointer must be retired, and the same holds for a journal that
1002
+ * points at databases the backup does not contain.
1003
+ */
1004
+ function planVectorActive(found, rels) {
1005
+ const rel = 'index/vector-active.json'
1006
+ if (!rels.includes(rel)) return { action: 'absent', restore: false }
1007
+ const raw = readJsonQuiet(path.join(found.dir, 'index', 'vector-active.json'))
1008
+ if (!raw || typeof raw !== 'object') return { action: 'unreadable', restore: false }
1009
+ const active = raw.active && typeof raw.active === 'object' ? raw.active : null
1010
+ if (!active) return { action: 'restore', restore: true }
1011
+ const needed = []
1012
+ const global = active.databases && active.databases.global
1013
+ if (global && typeof global.path === 'string') needed.push(global.path)
1014
+ const libraries = active.databases && active.databases.libraries
1015
+ if (libraries && typeof libraries === 'object') {
1016
+ for (const key of Object.keys(libraries)) {
1017
+ const item = libraries[key]
1018
+ if (item && typeof item.path === 'string') needed.push(item.path)
1019
+ }
1020
+ }
1021
+ const missing = []
1022
+ for (const value of needed) {
1023
+ const normalized = normalizeDbRel(value)
1024
+ if (!normalized || !rels.includes(normalized)) missing.push(String(value))
1025
+ }
1026
+ if (missing.length) return { action: 'missing-databases', restore: false, missing: missing.slice(0, 5) }
1027
+ return { action: 'restore', restore: true }
1028
+ }
1029
+
1030
+ /** Moves the live vector-active pointer aside so recall falls back to text. */
1031
+ function retireVectorActive(reason) {
1032
+ const live = vectorActiveFile()
1033
+ const info = statOrNull(live)
1034
+ if (!info) return 'absent'
1035
+ if (info.isSymbolicLink()) {
1036
+ warn('backups: the live vector-active.json is a link; leaving it alone')
1037
+ return 'unreadable'
1038
+ }
1039
+ const aside = live + '.stale-' + isoStamp().replace(/[:.]/g, '-')
1040
+ try { fs.renameSync(live, aside) } catch (error) {
1041
+ throw new Error('the restored data has no usable vector-active manifest and the stale pointer could not be retired: ' + String(error?.message || error))
1042
+ }
1043
+ warn('backups: ' + reason + '; the stale vector-active pointer was moved to ' + path.basename(aside) + ' so recall falls back to lexical text')
1044
+ return 'cleared'
1045
+ }
1046
+
1047
+ /**
1048
+ * Puts a backup back. The target is leased and fully verified first, the live
1049
+ * root is saved as a safety backup second, and only then is anything written.
1050
+ * A failure after that point is recoverable: the safety backup holds the state
1051
+ * that was replaced, and it is protected from this operation's prune.
1052
+ */
1053
+ export function restoreFromBackup(name, { beforeRestore = null, flushState = null, clearCaches = null, keep = BACKUP_KEEP } = {}) {
1054
+ return withBackupLock(() => {
1055
+ const found = findBackup(name)
1056
+ const plan = verifyInventory(found)
1057
+ const vector = planVectorActive(found, plan.rels)
1058
+ writeLease(found.name, { pid: process.pid, at: Date.now(), kind: 'restore' })
1059
+ let safety = null
1060
+ try {
1061
+ const created = typeof beforeRestore === 'function'
1062
+ ? beforeRestore()
1063
+ : createBackup({ label: 'before-restore', flushState })
1064
+ safety = typeof created === 'string' ? created : (created && created.dir ? created.dir : null)
1065
+ const rels = restoreOrder(plan.rels.filter(rel => rel !== 'index/vector-active.json' || vector.restore))
1066
+ for (const rel of rels) {
1067
+ const target = liveTargetFor(rel)
1068
+ if (rel.endsWith('.sqlite3')) dropCompanions(target)
1069
+ writeAtomicCopy(path.join(found.dir, rel.split('/').join(path.sep)), target)
1070
+ }
1071
+ const vectorActive = vector.restore
1072
+ ? 'restored'
1073
+ : retireVectorActive(vector.action === 'absent'
1074
+ ? 'this backup predates the vector generation journal'
1075
+ : 'the vector journal in this backup does not match the databases it contains')
1076
+ if (typeof clearCaches === 'function') clearCaches()
1077
+ const prune = pruneBackups({ keep, protect: safety ? [path.basename(safety)] : [] })
1078
+ return {
1079
+ restored: found.name,
1080
+ safety,
1081
+ files: rels.length,
1082
+ vectorActive,
1083
+ pruned: prune.deleted,
1084
+ pendingCleanup: prune.pendingCleanup,
1085
+ }
1086
+ } catch (error) {
1087
+ const reason = String(error?.message || error)
1088
+ warn('backups: restore of ' + found.name + ' failed: ' + reason)
1089
+ const wrapped = new Error(safety
1090
+ ? 'restore failed (' + reason + '); the live state it replaced is saved as ' + path.basename(safety)
1091
+ : 'restore failed (' + reason + ') and no safety backup could be made')
1092
+ wrapped.code = 'DSH_MEMU_BACKUP_RESTORE_FAILED'
1093
+ wrapped.cause = error
1094
+ wrapped.safety = safety ? path.basename(safety) : null
1095
+ // Whatever landed on disk is the truth now: cached readers must not keep
1096
+ // serving the half-replaced state.
1097
+ try { if (typeof clearCaches === 'function') clearCaches() } catch {}
1098
+ throw wrapped
1099
+ } finally {
1100
+ try { writeLease(found.name, null) } catch {}
1101
+ }
1102
+ })
1103
+ }