| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107 |
- import { existsSync, lstatSync, readFileSync, readdirSync, renameSync, rmdirSync, writeFileSync } from 'node:fs'
- import { homedir } from 'node:os'
- import { join, resolve } from 'node:path'
- import { DatabaseSync } from 'node:sqlite'
- import { defaultDataHome, legacyDataHome, legacyUpdateActive } from './data-home.mjs'
- const markerName = '.data-home-migration.json'
- const read = file => JSON.parse(readFileSync(file, 'utf8'))
- function write(file, value) {
- const temporary = file + '.tmp'
- writeFileSync(temporary, JSON.stringify(value, null, 2) + '\n', { mode: 0o600 })
- renameSync(temporary, file)
- }
- export function rebaseDataPath(value, old, home) {
- return typeof value === 'string' && (value === old || value.startsWith(old + '/')) ? home + value.slice(old.length) : value
- }
- /** Only storage paths are rebased; conversation text and external paths stay intact. */
- function rebaseDatabasePaths(home, old) {
- for (const [name, table, columns] of [
- ['uploads.sqlite3', 'uploads', ['path']],
- ['chat-cache.sqlite3', 'chat_attachments', ['local_path', 'source_path']],
- ]) {
- const file = join(home, name)
- if (!existsSync(file)) continue
- const db = new DatabaseSync(file)
- try {
- if (!db.prepare('SELECT 1 FROM sqlite_master WHERE type=\'table\' AND name=?').get(table)) continue
- const fields = new Set(db.prepare(`PRAGMA table_info("${table}")`).all().map(row => row.name))
- db.exec('BEGIN IMMEDIATE')
- for (const column of columns) if (fields.has(column)) {
- db.prepare(`UPDATE "${table}" SET "${column}"=? || substr("${column}",?) WHERE "${column}"=? OR substr("${column}",1,?)=?`)
- .run(home, old.length + 1, old, old.length + 1, old + '/')
- }
- db.exec('COMMIT; PRAGMA wal_checkpoint(TRUNCATE)')
- } finally { db.close() }
- }
- const rebase = value => typeof value === 'string' ? rebaseDataPath(value, old, home)
- : Array.isArray(value) ? value.map(rebase)
- : value && typeof value === 'object' ? Object.fromEntries(Object.entries(value).map(([key, item]) => [key, rebase(item)])) : value
- // Durable update/notification configuration stores absolute paths as well.
- for (const name of ['updates', 'push']) {
- const directory = join(home, name)
- if (!existsSync(directory)) continue
- for (const entry of readdirSync(directory, { withFileTypes: true })) {
- if (!entry.isFile() || !entry.name.endsWith('.json')) continue
- const file = join(directory, entry.name), value = read(file), next = rebase(value)
- if (JSON.stringify(value) !== JSON.stringify(next)) write(file, next)
- }
- }
- }
- export function migrateDataHome(home, { userHome = homedir() } = {}) {
- home = resolve(home)
- const old = legacyDataHome(userHome)
- if (home !== defaultDataHome(userHome)) return false
- const marker = join(home, markerName)
- if (!existsSync(old)) {
- if (existsSync(marker) && read(marker).phase === 'moving') {
- const record = read(marker)
- if (record.source !== old || record.target !== home) throw new Error('数据目录迁移记录不匹配')
- rebaseDatabasePaths(home, old)
- write(marker, { ...record, phase: 'complete' })
- }
- if (process.platform === 'linux' && home === '/home/node/.yaoyao' && existsSync(home)) {
- const lock = new DatabaseSync(join(home, 'service-instance.sqlite3'))
- try { lock.exec('PRAGMA busy_timeout=0; BEGIN EXCLUSIVE'); rebaseDatabasePaths(home, '/var/lib/hermes-yaoyao') }
- finally { lock.close() }
- }
- return false
- }
- if (lstatSync(old).isSymbolicLink() || !lstatSync(old).isDirectory()) throw new Error('旧数据目录不是普通目录,请先核对实际数据位置')
- if (existsSync(home) && (lstatSync(home).isSymbolicLink() || readdirSync(home).length))
- throw new Error('~/.yaoyao 与 ~/.hermes-yaoyao 同时存在数据,已保留两份数据,请先合并确认后重试')
- if (legacyUpdateActive(userHome)) throw new Error('旧版升级事务尚未完成,请完成更新后重试数据迁移')
- const locks = []
- try {
- // Lock every SQLite database, including independent Runner owners, before moving.
- const lockTree = directory => {
- for (const entry of readdirSync(directory, { withFileTypes: true })) {
- if (entry.isSymbolicLink() || ['backups', 'uploads', 'workspace-files', 'chat-cache-assets'].includes(entry.name)) continue
- const path = join(directory, entry.name)
- if (entry.isDirectory()) { if (['runner-state', 'updates'].includes(entry.name) || directory.startsWith(join(old, 'runner-state'))) lockTree(path) }
- else if (/\.(sqlite3?|db)$/.test(entry.name)) {
- const db = new DatabaseSync(path); locks.push(db)
- db.exec('PRAGMA busy_timeout=0; BEGIN EXCLUSIVE')
- }
- }
- }
- lockTree(old)
- const recordFile = join(old, 'service-instance.json')
- if (existsSync(recordFile)) {
- const { pid } = read(recordFile)
- if (Number.isSafeInteger(pid) && pid > 0) {
- try { process.kill(pid, 0); throw new Error('旧版服务仍在运行,请停止后台服务后重试数据迁移') }
- catch (error) { if (error.code !== 'ESRCH') throw error }
- }
- }
- write(join(old, markerName), { source: old, target: home, phase: 'moving', startedAt: new Date().toISOString() })
- if (existsSync(home)) rmdirSync(home)
- renameSync(old, home)
- } catch (error) {
- if (/locked|busy/i.test(error.message)) throw new Error('旧数据仍被服务或执行节点使用,停止后才能迁移')
- throw error
- } finally { for (const db of locks.reverse()) db.close() }
- rebaseDatabasePaths(home, old)
- write(marker, { ...read(marker), phase: 'complete' })
- return true
- }
|