data-migration.mjs 5.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107
  1. import { existsSync, lstatSync, readFileSync, readdirSync, renameSync, rmdirSync, writeFileSync } from 'node:fs'
  2. import { homedir } from 'node:os'
  3. import { join, resolve } from 'node:path'
  4. import { DatabaseSync } from 'node:sqlite'
  5. import { defaultDataHome, legacyDataHome, legacyUpdateActive } from './data-home.mjs'
  6. const markerName = '.data-home-migration.json'
  7. const read = file => JSON.parse(readFileSync(file, 'utf8'))
  8. function write(file, value) {
  9. const temporary = file + '.tmp'
  10. writeFileSync(temporary, JSON.stringify(value, null, 2) + '\n', { mode: 0o600 })
  11. renameSync(temporary, file)
  12. }
  13. export function rebaseDataPath(value, old, home) {
  14. return typeof value === 'string' && (value === old || value.startsWith(old + '/')) ? home + value.slice(old.length) : value
  15. }
  16. /** Only storage paths are rebased; conversation text and external paths stay intact. */
  17. function rebaseDatabasePaths(home, old) {
  18. for (const [name, table, columns] of [
  19. ['uploads.sqlite3', 'uploads', ['path']],
  20. ['chat-cache.sqlite3', 'chat_attachments', ['local_path', 'source_path']],
  21. ]) {
  22. const file = join(home, name)
  23. if (!existsSync(file)) continue
  24. const db = new DatabaseSync(file)
  25. try {
  26. if (!db.prepare('SELECT 1 FROM sqlite_master WHERE type=\'table\' AND name=?').get(table)) continue
  27. const fields = new Set(db.prepare(`PRAGMA table_info("${table}")`).all().map(row => row.name))
  28. db.exec('BEGIN IMMEDIATE')
  29. for (const column of columns) if (fields.has(column)) {
  30. db.prepare(`UPDATE "${table}" SET "${column}"=? || substr("${column}",?) WHERE "${column}"=? OR substr("${column}",1,?)=?`)
  31. .run(home, old.length + 1, old, old.length + 1, old + '/')
  32. }
  33. db.exec('COMMIT; PRAGMA wal_checkpoint(TRUNCATE)')
  34. } finally { db.close() }
  35. }
  36. const rebase = value => typeof value === 'string' ? rebaseDataPath(value, old, home)
  37. : Array.isArray(value) ? value.map(rebase)
  38. : value && typeof value === 'object' ? Object.fromEntries(Object.entries(value).map(([key, item]) => [key, rebase(item)])) : value
  39. // Durable update/notification configuration stores absolute paths as well.
  40. for (const name of ['updates', 'push']) {
  41. const directory = join(home, name)
  42. if (!existsSync(directory)) continue
  43. for (const entry of readdirSync(directory, { withFileTypes: true })) {
  44. if (!entry.isFile() || !entry.name.endsWith('.json')) continue
  45. const file = join(directory, entry.name), value = read(file), next = rebase(value)
  46. if (JSON.stringify(value) !== JSON.stringify(next)) write(file, next)
  47. }
  48. }
  49. }
  50. export function migrateDataHome(home, { userHome = homedir() } = {}) {
  51. home = resolve(home)
  52. const old = legacyDataHome(userHome)
  53. if (home !== defaultDataHome(userHome)) return false
  54. const marker = join(home, markerName)
  55. if (!existsSync(old)) {
  56. if (existsSync(marker) && read(marker).phase === 'moving') {
  57. const record = read(marker)
  58. if (record.source !== old || record.target !== home) throw new Error('数据目录迁移记录不匹配')
  59. rebaseDatabasePaths(home, old)
  60. write(marker, { ...record, phase: 'complete' })
  61. }
  62. if (process.platform === 'linux' && home === '/home/node/.yaoyao' && existsSync(home)) {
  63. const lock = new DatabaseSync(join(home, 'service-instance.sqlite3'))
  64. try { lock.exec('PRAGMA busy_timeout=0; BEGIN EXCLUSIVE'); rebaseDatabasePaths(home, '/var/lib/hermes-yaoyao') }
  65. finally { lock.close() }
  66. }
  67. return false
  68. }
  69. if (lstatSync(old).isSymbolicLink() || !lstatSync(old).isDirectory()) throw new Error('旧数据目录不是普通目录,请先核对实际数据位置')
  70. if (existsSync(home) && (lstatSync(home).isSymbolicLink() || readdirSync(home).length))
  71. throw new Error('~/.yaoyao 与 ~/.hermes-yaoyao 同时存在数据,已保留两份数据,请先合并确认后重试')
  72. if (legacyUpdateActive(userHome)) throw new Error('旧版升级事务尚未完成,请完成更新后重试数据迁移')
  73. const locks = []
  74. try {
  75. // Lock every SQLite database, including independent Runner owners, before moving.
  76. const lockTree = directory => {
  77. for (const entry of readdirSync(directory, { withFileTypes: true })) {
  78. if (entry.isSymbolicLink() || ['backups', 'uploads', 'workspace-files', 'chat-cache-assets'].includes(entry.name)) continue
  79. const path = join(directory, entry.name)
  80. if (entry.isDirectory()) { if (['runner-state', 'updates'].includes(entry.name) || directory.startsWith(join(old, 'runner-state'))) lockTree(path) }
  81. else if (/\.(sqlite3?|db)$/.test(entry.name)) {
  82. const db = new DatabaseSync(path); locks.push(db)
  83. db.exec('PRAGMA busy_timeout=0; BEGIN EXCLUSIVE')
  84. }
  85. }
  86. }
  87. lockTree(old)
  88. const recordFile = join(old, 'service-instance.json')
  89. if (existsSync(recordFile)) {
  90. const { pid } = read(recordFile)
  91. if (Number.isSafeInteger(pid) && pid > 0) {
  92. try { process.kill(pid, 0); throw new Error('旧版服务仍在运行,请停止后台服务后重试数据迁移') }
  93. catch (error) { if (error.code !== 'ESRCH') throw error }
  94. }
  95. }
  96. write(join(old, markerName), { source: old, target: home, phase: 'moving', startedAt: new Date().toISOString() })
  97. if (existsSync(home)) rmdirSync(home)
  98. renameSync(old, home)
  99. } catch (error) {
  100. if (/locked|busy/i.test(error.message)) throw new Error('旧数据仍被服务或执行节点使用,停止后才能迁移')
  101. throw error
  102. } finally { for (const db of locks.reverse()) db.close() }
  103. rebaseDatabasePaths(home, old)
  104. write(marker, { ...read(marker), phase: 'complete' })
  105. return true
  106. }