Skip to main content

cc_switch_lib/database/
backup.rs

1//! 数据库备份和恢复
2//!
3//! 提供 SQL 导出/导入和二进制快照备份功能。
4
5use super::{lock_conn, Database, DB_BACKUP_RETAIN};
6use crate::config::get_app_config_dir;
7use crate::error::AppError;
8use chrono::Utc;
9use rusqlite::backup::Backup;
10use rusqlite::types::Value;
11use rusqlite::{Connection, OptionalExtension};
12use std::fs;
13use std::path::{Path, PathBuf};
14use tempfile::NamedTempFile;
15
16const CC_SWITCH_SQL_EXPORT_HEADER: &str = "-- CC Switch SQLite 导出";
17
18const SYNC_IMPORT_RESTORE_TABLES: &[&str] = &[
19    "proxy_request_logs",
20    "stream_check_logs",
21    "proxy_live_backup",
22    "usage_daily_rollups",
23];
24
25const SYNC_EXPORT_RESETTABLE_TABLES: &[&str] = &["provider_health"];
26
27const SYNC_LOCAL_SETTINGS_KEYS: &[&str] = &["proxy_runtime_session"];
28const PROXY_CONFIG_LOCAL_COLUMNS: &[&str] =
29    &["proxy_enabled", "listen_address", "listen_port", "enabled"];
30
31#[derive(Clone, Copy)]
32enum SyncNeutralValue {
33    Integer(i64),
34    Text(&'static str),
35}
36
37impl SyncNeutralValue {
38    fn into_sql_value(self) -> Value {
39        match self {
40            Self::Integer(value) => Value::Integer(value),
41            Self::Text(value) => Value::Text(value.to_string()),
42        }
43    }
44}
45
46#[derive(Clone, Copy)]
47struct SyncNeutralizedColumn {
48    column: &'static str,
49    value: SyncNeutralValue,
50}
51
52#[derive(Clone, Copy)]
53struct SyncRowKeyedColumnGroup {
54    table: &'static str,
55    key_column: &'static str,
56    preserved_columns: &'static [&'static str],
57    export_defaults: &'static [SyncNeutralizedColumn],
58}
59
60#[derive(Clone, Copy)]
61struct SyncPreservationPolicy {
62    import_restore_tables: &'static [&'static str],
63    export_resettable_tables: &'static [&'static str],
64    local_settings_keys: &'static [&'static str],
65    row_keyed_column_groups: &'static [SyncRowKeyedColumnGroup],
66}
67
68const PROXY_CONFIG_EXPORT_DEFAULTS: &[SyncNeutralizedColumn] = &[
69    SyncNeutralizedColumn {
70        column: "proxy_enabled",
71        value: SyncNeutralValue::Integer(0),
72    },
73    SyncNeutralizedColumn {
74        column: "listen_address",
75        value: SyncNeutralValue::Text("127.0.0.1"),
76    },
77    SyncNeutralizedColumn {
78        column: "listen_port",
79        value: SyncNeutralValue::Integer(15721),
80    },
81    SyncNeutralizedColumn {
82        column: "enabled",
83        value: SyncNeutralValue::Integer(0),
84    },
85    SyncNeutralizedColumn {
86        column: "live_takeover_active",
87        value: SyncNeutralValue::Integer(0),
88    },
89];
90
91const SYNC_ROW_KEYED_COLUMN_GROUPS: &[SyncRowKeyedColumnGroup] = &[SyncRowKeyedColumnGroup {
92    table: "proxy_config",
93    key_column: "app_type",
94    preserved_columns: PROXY_CONFIG_LOCAL_COLUMNS,
95    export_defaults: PROXY_CONFIG_EXPORT_DEFAULTS,
96}];
97
98const SYNC_PRESERVATION_POLICY: SyncPreservationPolicy = SyncPreservationPolicy {
99    import_restore_tables: SYNC_IMPORT_RESTORE_TABLES,
100    export_resettable_tables: SYNC_EXPORT_RESETTABLE_TABLES,
101    local_settings_keys: SYNC_LOCAL_SETTINGS_KEYS,
102    row_keyed_column_groups: SYNC_ROW_KEYED_COLUMN_GROUPS,
103};
104
105impl Database {
106    /// 导出为 SQL 字符串(内存操作,不写文件)
107    pub fn export_sql_string(&self) -> Result<String, AppError> {
108        let snapshot = self.snapshot_to_memory()?;
109        Self::dump_sql(&snapshot, None)
110    }
111
112    pub fn export_sql_string_for_sync(&self) -> Result<String, AppError> {
113        let snapshot = self.snapshot_to_memory()?;
114        Self::dump_sql(&snapshot, Some(&SYNC_PRESERVATION_POLICY))
115    }
116
117    /// 导出为 SQLite 兼容的 SQL 文本文件
118    pub fn export_sql(&self, target_path: &Path) -> Result<(), AppError> {
119        let dump = self.export_sql_string()?;
120
121        if let Some(parent) = target_path.parent() {
122            fs::create_dir_all(parent).map_err(|e| AppError::io(parent, e))?;
123        }
124
125        crate::config::atomic_write(target_path, dump.as_bytes())
126    }
127
128    /// 从 SQL 字符串导入,返回生成的备份 ID(若无备份则为空字符串)
129    pub fn import_sql_string(&self, sql_raw: &str) -> Result<String, AppError> {
130        self.import_sql_string_inner(sql_raw, None)
131    }
132
133    pub(crate) fn import_sql_string_for_sync(&self, sql_raw: &str) -> Result<String, AppError> {
134        self.import_sql_string_inner(sql_raw, Some(&SYNC_PRESERVATION_POLICY))
135    }
136
137    fn import_sql_string_inner(
138        &self,
139        sql_raw: &str,
140        policy: Option<&SyncPreservationPolicy>,
141    ) -> Result<String, AppError> {
142        let sql_content = sql_raw.trim_start_matches('\u{feff}');
143        Self::validate_cc_switch_sql_export(sql_content)?;
144
145        // 导入前备份现有数据库
146        let backup_path = self.backup_database_file()?;
147
148        let local_snapshot = policy.map(|_| self.snapshot_to_memory()).transpose()?;
149
150        // 在临时数据库执行导入,确保失败不会污染主库
151        let temp_file = NamedTempFile::new().map_err(|e| AppError::IoContext {
152            context: "创建临时数据库文件失败".to_string(),
153            source: e,
154        })?;
155        let temp_path = temp_file.path().to_path_buf();
156        let temp_conn =
157            Connection::open(&temp_path).map_err(|e| AppError::Database(e.to_string()))?;
158
159        temp_conn
160            .execute_batch(sql_content)
161            .map_err(|e| AppError::Database(format!("执行 SQL 导入失败: {e}")))?;
162
163        // 补齐缺失表/索引并进行基础校验
164        Self::create_tables_on_conn(&temp_conn)?;
165        Self::apply_schema_migrations_on_conn(&temp_conn)?;
166        Self::validate_basic_state(&temp_conn)?;
167        if let (Some(local_snapshot), Some(policy)) = (local_snapshot.as_ref(), policy) {
168            Self::restore_sync_local_overlay(local_snapshot, &temp_conn, policy)?;
169        }
170
171        // 使用 Backup 将临时库原子写回主库
172        {
173            let mut main_conn = lock_conn!(self.conn);
174            let backup = Backup::new(&temp_conn, &mut main_conn)
175                .map_err(|e| AppError::Database(e.to_string()))?;
176            backup
177                .step(-1)
178                .map_err(|e| AppError::Database(e.to_string()))?;
179        }
180
181        let backup_id = backup_path
182            .and_then(|p| p.file_stem().map(|s| s.to_string_lossy().to_string()))
183            .unwrap_or_default();
184
185        Ok(backup_id)
186    }
187
188    /// 从 SQL 文件导入,返回生成的备份 ID(若无备份则为空字符串)
189    pub fn import_sql(&self, source_path: &Path) -> Result<String, AppError> {
190        if !source_path.exists() {
191            return Err(AppError::InvalidInput(format!(
192                "SQL 文件不存在: {}",
193                source_path.display()
194            )));
195        }
196
197        let sql_raw = fs::read_to_string(source_path).map_err(|e| AppError::io(source_path, e))?;
198        self.import_sql_string(&sql_raw)
199    }
200
201    /// 创建内存快照以避免长时间持有数据库锁
202    pub(crate) fn snapshot_to_memory(&self) -> Result<Connection, AppError> {
203        let conn = lock_conn!(self.conn);
204        let mut snapshot =
205            Connection::open_in_memory().map_err(|e| AppError::Database(e.to_string()))?;
206
207        {
208            let backup =
209                Backup::new(&conn, &mut snapshot).map_err(|e| AppError::Database(e.to_string()))?;
210            backup
211                .step(-1)
212                .map_err(|e| AppError::Database(e.to_string()))?;
213        }
214
215        Ok(snapshot)
216    }
217
218    fn validate_cc_switch_sql_export(sql: &str) -> Result<(), AppError> {
219        let trimmed = sql.trim_start();
220        if trimmed.starts_with(CC_SWITCH_SQL_EXPORT_HEADER) {
221            return Ok(());
222        }
223
224        Err(AppError::localized(
225            "backup.sql.invalid_format",
226            "仅支持导入由 CC Switch 导出的 SQL 备份文件。",
227            "Only SQL backups exported by CC Switch are supported.",
228        ))
229    }
230
231    fn restore_tables(
232        source_conn: &Connection,
233        target_conn: &Connection,
234        tables: &[&str],
235    ) -> Result<(), AppError> {
236        for table in tables {
237            if !Self::table_exists(source_conn, table)? || !Self::table_exists(target_conn, table)?
238            {
239                continue;
240            }
241
242            let columns = Self::get_table_columns(source_conn, table)?;
243            if columns.is_empty() {
244                continue;
245            }
246
247            target_conn
248                .execute(&format!("DELETE FROM \"{table}\""), [])
249                .map_err(|e| AppError::Database(format!("清空表 {table} 失败: {e}")))?;
250
251            let placeholders = (1..=columns.len())
252                .map(|idx| format!("?{idx}"))
253                .collect::<Vec<_>>()
254                .join(", ");
255            let cols = columns
256                .iter()
257                .map(|column| format!("\"{column}\""))
258                .collect::<Vec<_>>()
259                .join(", ");
260            let insert_sql = format!("INSERT INTO \"{table}\" ({cols}) VALUES ({placeholders})");
261
262            let mut stmt = source_conn
263                .prepare(&format!("SELECT * FROM \"{table}\""))
264                .map_err(|e| AppError::Database(format!("读取表 {table} 失败: {e}")))?;
265            let mut rows = stmt
266                .query([])
267                .map_err(|e| AppError::Database(format!("查询表 {table} 数据失败: {e}")))?;
268
269            while let Some(row) = rows.next().map_err(|e| AppError::Database(e.to_string()))? {
270                let mut values = Vec::with_capacity(columns.len());
271                for idx in 0..columns.len() {
272                    values.push(
273                        row.get::<_, rusqlite::types::Value>(idx)
274                            .map_err(|e| AppError::Database(e.to_string()))?,
275                    );
276                }
277
278                target_conn
279                    .execute(&insert_sql, rusqlite::params_from_iter(values.iter()))
280                    .map_err(|e| AppError::Database(format!("恢复表 {table} 数据失败: {e}")))?;
281            }
282        }
283
284        Ok(())
285    }
286
287    fn restore_sync_local_overlay(
288        source_conn: &Connection,
289        target_conn: &Connection,
290        policy: &SyncPreservationPolicy,
291    ) -> Result<(), AppError> {
292        Self::restore_tables(source_conn, target_conn, policy.import_restore_tables)?;
293        Self::clear_tables(target_conn, policy.export_resettable_tables)?;
294        Self::restore_settings_keys(source_conn, target_conn, policy.local_settings_keys)?;
295        for group in policy.row_keyed_column_groups {
296            Self::restore_row_keyed_column_group(source_conn, target_conn, group)?;
297        }
298        Ok(())
299    }
300
301    fn clear_tables(target_conn: &Connection, tables: &[&str]) -> Result<(), AppError> {
302        for table in tables {
303            if !Self::table_exists(target_conn, table)? {
304                continue;
305            }
306
307            target_conn
308                .execute(&format!("DELETE FROM {}", Self::quote_ident(table)), [])
309                .map_err(|e| AppError::Database(format!("清空表 {table} 失败: {e}")))?;
310        }
311
312        Ok(())
313    }
314
315    fn restore_settings_keys(
316        source_conn: &Connection,
317        target_conn: &Connection,
318        keys: &[&str],
319    ) -> Result<(), AppError> {
320        if keys.is_empty()
321            || !Self::table_exists(source_conn, "settings")?
322            || !Self::table_exists(target_conn, "settings")?
323        {
324            return Ok(());
325        }
326
327        for key in keys {
328            let local_value: Option<String> = source_conn
329                .query_row("SELECT value FROM settings WHERE key = ?1", [*key], |row| {
330                    row.get(0)
331                })
332                .optional()
333                .map_err(|e| AppError::Database(format!("读取本地 settings 键 {key} 失败: {e}")))?;
334
335            target_conn
336                .execute("DELETE FROM settings WHERE key = ?1", [*key])
337                .map_err(|e| AppError::Database(format!("清理远端 settings 键 {key} 失败: {e}")))?;
338
339            if let Some(value) = local_value {
340                target_conn
341                    .execute(
342                        "INSERT INTO settings (key, value) VALUES (?1, ?2)",
343                        rusqlite::params![*key, value],
344                    )
345                    .map_err(|e| {
346                        AppError::Database(format!("恢复本地 settings 键 {key} 失败: {e}"))
347                    })?;
348            }
349        }
350
351        Ok(())
352    }
353
354    fn restore_row_keyed_column_group(
355        source_conn: &Connection,
356        target_conn: &Connection,
357        group: &SyncRowKeyedColumnGroup,
358    ) -> Result<(), AppError> {
359        if !Self::table_exists(source_conn, group.table)?
360            || !Self::table_exists(target_conn, group.table)?
361        {
362            return Ok(());
363        }
364
365        let source_columns = Self::get_table_columns(source_conn, group.table)?;
366        let target_columns = Self::get_table_columns(target_conn, group.table)?;
367        if !source_columns
368            .iter()
369            .any(|column| column == group.key_column)
370            || !target_columns
371                .iter()
372                .any(|column| column == group.key_column)
373        {
374            return Ok(());
375        }
376
377        let preserved_columns = group
378            .preserved_columns
379            .iter()
380            .copied()
381            .filter(|column| {
382                source_columns.iter().any(|existing| existing == column)
383                    && target_columns.iter().any(|existing| existing == column)
384            })
385            .collect::<Vec<_>>();
386        if preserved_columns.is_empty() {
387            return Ok(());
388        }
389
390        let select_columns = std::iter::once(group.key_column)
391            .chain(preserved_columns.iter().copied())
392            .map(Self::quote_ident)
393            .collect::<Vec<_>>()
394            .join(", ");
395        let select_sql = format!(
396            "SELECT {select_columns} FROM {}",
397            Self::quote_ident(group.table)
398        );
399        let assignments = preserved_columns
400            .iter()
401            .enumerate()
402            .map(|(idx, column)| format!("{} = ?{}", Self::quote_ident(column), idx + 1))
403            .collect::<Vec<_>>()
404            .join(", ");
405        let update_sql = format!(
406            "UPDATE {} SET {assignments} WHERE {} = ?{}",
407            Self::quote_ident(group.table),
408            Self::quote_ident(group.key_column),
409            preserved_columns.len() + 1
410        );
411
412        let mut stmt = source_conn.prepare(&select_sql).map_err(|e| {
413            AppError::Database(format!("读取本地表 {} 的列组失败: {e}", group.table))
414        })?;
415        let mut rows = stmt.query([]).map_err(|e| {
416            AppError::Database(format!("查询本地表 {} 的列组数据失败: {e}", group.table))
417        })?;
418
419        while let Some(row) = rows.next().map_err(|e| AppError::Database(e.to_string()))? {
420            let mut values = Vec::with_capacity(preserved_columns.len() + 1);
421            for idx in 1..=preserved_columns.len() {
422                values.push(
423                    row.get::<_, Value>(idx)
424                        .map_err(|e| AppError::Database(e.to_string()))?,
425                );
426            }
427            values.push(
428                row.get::<_, Value>(0)
429                    .map_err(|e| AppError::Database(e.to_string()))?,
430            );
431
432            target_conn
433                .execute(&update_sql, rusqlite::params_from_iter(values.iter()))
434                .map_err(|e| {
435                    AppError::Database(format!("恢复本地表 {} 的列组失败: {e}", group.table))
436                })?;
437        }
438
439        Ok(())
440    }
441
442    /// 生成一致性快照备份,返回备份文件路径(不存在主库时返回 None)
443    pub(crate) fn backup_database_file(&self) -> Result<Option<PathBuf>, AppError> {
444        let db_path = get_app_config_dir().join("cc-switch.db");
445        if !db_path.exists() {
446            return Ok(None);
447        }
448
449        let backup_dir = db_path
450            .parent()
451            .ok_or_else(|| AppError::Config("无效的数据库路径".to_string()))?
452            .join("backups");
453
454        fs::create_dir_all(&backup_dir).map_err(|e| AppError::io(&backup_dir, e))?;
455
456        let base_id = format!("db_backup_{}", Utc::now().format("%Y%m%d_%H%M%S"));
457        let mut backup_id = base_id.clone();
458        let mut backup_path = backup_dir.join(format!("{backup_id}.db"));
459        let mut counter = 1;
460        while backup_path.exists() {
461            backup_id = format!("{base_id}_{counter}");
462            backup_path = backup_dir.join(format!("{backup_id}.db"));
463            counter += 1;
464        }
465
466        {
467            let conn = lock_conn!(self.conn);
468            let mut dest_conn =
469                Connection::open(&backup_path).map_err(|e| AppError::Database(e.to_string()))?;
470            let backup = Backup::new(&conn, &mut dest_conn)
471                .map_err(|e| AppError::Database(e.to_string()))?;
472            backup
473                .step(-1)
474                .map_err(|e| AppError::Database(e.to_string()))?;
475        }
476
477        Self::cleanup_db_backups(&backup_dir)?;
478        Ok(Some(backup_path))
479    }
480
481    /// 清理旧的数据库备份,保留最新的 N 个
482    fn cleanup_db_backups(dir: &Path) -> Result<(), AppError> {
483        let entries = match fs::read_dir(dir) {
484            Ok(iter) => iter
485                .filter_map(|entry| entry.ok())
486                .filter(|entry| {
487                    entry
488                        .path()
489                        .extension()
490                        .map(|ext| ext == "db")
491                        .unwrap_or(false)
492                })
493                .collect::<Vec<_>>(),
494            Err(_) => return Ok(()),
495        };
496
497        if entries.len() <= DB_BACKUP_RETAIN {
498            return Ok(());
499        }
500
501        let remove_count = entries.len().saturating_sub(DB_BACKUP_RETAIN);
502        let mut sorted = entries;
503        sorted.sort_by_key(|entry| entry.metadata().and_then(|m| m.modified()).ok());
504
505        for entry in sorted.into_iter().take(remove_count) {
506            if let Err(err) = fs::remove_file(entry.path()) {
507                log::warn!("删除旧数据库备份失败 {}: {}", entry.path().display(), err);
508            }
509        }
510        Ok(())
511    }
512
513    /// 基础状态校验
514    fn validate_basic_state(conn: &Connection) -> Result<(), AppError> {
515        let provider_count: i64 = conn
516            .query_row("SELECT COUNT(*) FROM providers", [], |row| row.get(0))
517            .map_err(|e| AppError::Database(e.to_string()))?;
518        let mcp_count: i64 = conn
519            .query_row("SELECT COUNT(*) FROM mcp_servers", [], |row| row.get(0))
520            .map_err(|e| AppError::Database(e.to_string()))?;
521
522        if provider_count == 0 && mcp_count == 0 {
523            return Err(AppError::Config(
524                "导入的 SQL 未包含有效的供应商或 MCP 数据".to_string(),
525            ));
526        }
527        Ok(())
528    }
529
530    /// 导出数据库为 SQL 文本
531    fn dump_sql(
532        conn: &Connection,
533        policy: Option<&SyncPreservationPolicy>,
534    ) -> Result<String, AppError> {
535        let mut output = String::new();
536        let timestamp = Utc::now().format("%Y-%m-%d %H:%M:%S").to_string();
537        let user_version: i64 = conn
538            .query_row("PRAGMA user_version;", [], |row| row.get(0))
539            .unwrap_or(0);
540
541        output.push_str(&format!(
542            "-- CC Switch SQLite 导出\n-- 生成时间: {timestamp}\n-- user_version: {user_version}\n"
543        ));
544        output.push_str("PRAGMA foreign_keys=OFF;\n");
545        output.push_str(&format!("PRAGMA user_version={user_version};\n"));
546        output.push_str("BEGIN TRANSACTION;\n");
547
548        // 导出 schema
549        let mut stmt = conn
550            .prepare(
551                "SELECT type, name, tbl_name, sql
552                 FROM sqlite_master
553                 WHERE sql NOT NULL AND type IN ('table','index','trigger','view')
554                 ORDER BY type='table' DESC, name",
555            )
556            .map_err(|e| AppError::Database(e.to_string()))?;
557
558        let mut tables = Vec::new();
559        let mut rows = stmt
560            .query([])
561            .map_err(|e| AppError::Database(e.to_string()))?;
562        while let Some(row) = rows.next().map_err(|e| AppError::Database(e.to_string()))? {
563            let obj_type: String = row.get(0).map_err(|e| AppError::Database(e.to_string()))?;
564            let name: String = row.get(1).map_err(|e| AppError::Database(e.to_string()))?;
565            let sql: String = row.get(3).map_err(|e| AppError::Database(e.to_string()))?;
566
567            // 跳过 SQLite 内部对象(如 sqlite_sequence)
568            if name.starts_with("sqlite_") {
569                continue;
570            }
571
572            output.push_str(&sql);
573            output.push_str(";\n");
574
575            if obj_type == "table" && !name.starts_with("sqlite_") {
576                tables.push(name);
577            }
578        }
579
580        // 导出数据
581        for table in tables {
582            if policy.is_some_and(|policy| {
583                policy
584                    .import_restore_tables
585                    .iter()
586                    .any(|skip| *skip == table)
587                    || policy
588                        .export_resettable_tables
589                        .iter()
590                        .any(|skip| *skip == table)
591            }) {
592                continue;
593            }
594
595            let columns = Self::get_table_columns(conn, &table)?;
596            if columns.is_empty() {
597                continue;
598            }
599
600            let mut stmt = conn
601                .prepare(&format!("SELECT * FROM \"{table}\""))
602                .map_err(|e| AppError::Database(e.to_string()))?;
603            let mut rows = stmt
604                .query([])
605                .map_err(|e| AppError::Database(e.to_string()))?;
606
607            while let Some(row) = rows.next().map_err(|e| AppError::Database(e.to_string()))? {
608                let mut values = Vec::with_capacity(columns.len());
609                for idx in 0..columns.len() {
610                    values.push(
611                        row.get::<_, Value>(idx)
612                            .map_err(|e| AppError::Database(e.to_string()))?,
613                    );
614                }
615
616                if let Some(policy) = policy {
617                    if !Self::should_export_row(&table, &columns, &values, policy)? {
618                        continue;
619                    }
620                    Self::neutralize_export_row(&table, &columns, &mut values, policy);
621                }
622
623                let cols = columns
624                    .iter()
625                    .map(|c| format!("\"{c}\""))
626                    .collect::<Vec<_>>()
627                    .join(", ");
628                output.push_str(&format!(
629                    "INSERT INTO \"{table}\" ({cols}) VALUES ({});\n",
630                    values
631                        .iter()
632                        .map(Self::format_owned_sql_value)
633                        .collect::<Result<Vec<_>, _>>()?
634                        .join(", ")
635                ));
636            }
637        }
638
639        output.push_str("COMMIT;\nPRAGMA foreign_keys=ON;\n");
640        Ok(output)
641    }
642
643    /// 获取表的列名列表
644    fn get_table_columns(conn: &Connection, table: &str) -> Result<Vec<String>, AppError> {
645        let mut stmt = conn
646            .prepare(&format!("PRAGMA table_info(\"{table}\")"))
647            .map_err(|e| AppError::Database(e.to_string()))?;
648        let iter = stmt
649            .query_map([], |row| row.get::<_, String>(1))
650            .map_err(|e| AppError::Database(e.to_string()))?;
651
652        let mut columns = Vec::new();
653        for col in iter {
654            columns.push(col.map_err(|e| AppError::Database(e.to_string()))?);
655        }
656        Ok(columns)
657    }
658
659    fn format_owned_sql_value(value: &Value) -> Result<String, AppError> {
660        match value {
661            Value::Null => Ok("NULL".to_string()),
662            Value::Integer(i) => Ok(i.to_string()),
663            Value::Real(f) => Ok(f.to_string()),
664            Value::Text(text) => Ok(format!("'{}'", text.replace('\'', "''"))),
665            Value::Blob(bytes) => {
666                let mut s = String::from("X'");
667                for b in bytes {
668                    use std::fmt::Write;
669                    let _ = write!(&mut s, "{b:02X}");
670                }
671                s.push('\'');
672                Ok(s)
673            }
674        }
675    }
676
677    fn should_export_row(
678        table: &str,
679        columns: &[String],
680        values: &[Value],
681        policy: &SyncPreservationPolicy,
682    ) -> Result<bool, AppError> {
683        if table != "settings" {
684            return Ok(true);
685        }
686
687        let Some(key_idx) = columns.iter().position(|column| column == "key") else {
688            return Ok(true);
689        };
690        let Some(key) = Self::value_as_str(&values[key_idx]) else {
691            return Ok(true);
692        };
693
694        Ok(!policy
695            .local_settings_keys
696            .iter()
697            .any(|local_key| *local_key == key))
698    }
699
700    fn neutralize_export_row(
701        table: &str,
702        columns: &[String],
703        values: &mut [Value],
704        policy: &SyncPreservationPolicy,
705    ) {
706        let Some(group) = policy
707            .row_keyed_column_groups
708            .iter()
709            .find(|group| group.table == table)
710        else {
711            return;
712        };
713
714        for default in group.export_defaults {
715            if let Some(idx) = columns.iter().position(|column| column == default.column) {
716                values[idx] = default.value.into_sql_value();
717            }
718        }
719    }
720
721    fn value_as_str(value: &Value) -> Option<&str> {
722        match value {
723            Value::Text(text) => Some(text.as_str()),
724            _ => None,
725        }
726    }
727
728    fn quote_ident(value: &str) -> String {
729        format!("\"{}\"", value.replace('"', "\"\""))
730    }
731}
732
733#[cfg(test)]
734mod tests {
735    use super::Database;
736    use crate::error::AppError;
737    use rusqlite::Connection;
738
739    fn seed_provider(conn: &Connection, id: &str) -> Result<(), AppError> {
740        conn.execute(
741            "INSERT INTO providers (id, app_type, name, settings_config, meta)
742             VALUES (?1, 'claude', ?2, '{}', '{}')",
743            rusqlite::params![id, format!("Provider {id}")],
744        )
745        .map_err(|e| AppError::Database(e.to_string()))?;
746        Ok(())
747    }
748
749    #[allow(clippy::too_many_arguments)]
750    fn set_proxy_row(
751        conn: &Connection,
752        app_type: &str,
753        proxy_enabled: bool,
754        listen_address: &str,
755        listen_port: i64,
756        enabled: bool,
757        auto_failover_enabled: bool,
758        max_retries: i64,
759    ) -> Result<(), AppError> {
760        conn.execute(
761            "UPDATE proxy_config
762             SET proxy_enabled = ?2,
763                 listen_address = ?3,
764                 listen_port = ?4,
765                 enabled = ?5,
766                 auto_failover_enabled = ?6,
767                 max_retries = ?7
768             WHERE app_type = ?1",
769            rusqlite::params![
770                app_type,
771                if proxy_enabled { 1 } else { 0 },
772                listen_address,
773                listen_port,
774                if enabled { 1 } else { 0 },
775                if auto_failover_enabled { 1 } else { 0 },
776                max_retries,
777            ],
778        )
779        .map_err(|e| AppError::Database(e.to_string()))?;
780        Ok(())
781    }
782
783    fn read_proxy_row(
784        conn: &Connection,
785        app_type: &str,
786    ) -> Result<(bool, String, i64, bool, bool, i64), AppError> {
787        conn.query_row(
788            "SELECT proxy_enabled, listen_address, listen_port, enabled, auto_failover_enabled, max_retries
789             FROM proxy_config WHERE app_type = ?1",
790            [app_type],
791            |row| {
792                Ok((
793                    row.get::<_, i64>(0)? != 0,
794                    row.get(1)?,
795                    row.get(2)?,
796                    row.get::<_, i64>(3)? != 0,
797                    row.get::<_, i64>(4)? != 0,
798                    row.get(5)?,
799                ))
800            },
801        )
802        .map_err(|e| AppError::Database(e.to_string()))
803    }
804
805    #[test]
806    fn sync_import_preserves_local_only_tables() -> Result<(), AppError> {
807        let remote_db = Database::memory()?;
808        {
809            let conn = crate::database::lock_conn!(remote_db.conn);
810            conn.execute(
811                "INSERT INTO providers (id, app_type, name, settings_config, meta)
812                 VALUES ('remote-provider', 'claude', 'Remote Provider', '{}', '{}')",
813                [],
814            )?;
815        }
816        let remote_sql = remote_db.export_sql_string_for_sync()?;
817
818        let local_db = Database::memory()?;
819        {
820            let conn = crate::database::lock_conn!(local_db.conn);
821            conn.execute(
822                "INSERT INTO providers (id, app_type, name, settings_config, meta)
823                 VALUES ('local-provider', 'claude', 'Local Provider', '{}', '{}')",
824                [],
825            )?;
826            conn.execute(
827                "INSERT INTO proxy_request_logs (
828                    request_id, provider_id, app_type, model,
829                    input_tokens, output_tokens, total_cost_usd,
830                    latency_ms, status_code, created_at
831                ) VALUES ('req-1', 'local-provider', 'claude', 'claude-3', 100, 50, '0.01', 120, 200, 1000)",
832                [],
833            )?;
834            conn.execute(
835                "INSERT INTO usage_daily_rollups (
836                    date, app_type, provider_id, model, request_count, success_count,
837                    input_tokens, output_tokens, cache_read_tokens, cache_creation_tokens,
838                    total_cost_usd, avg_latency_ms
839                ) VALUES ('2026-03-01', 'claude', 'local-provider', 'claude-3', 7, 7, 700, 350, 0, 0, '0.07', 120)",
840                [],
841            )?;
842            conn.execute(
843                "INSERT INTO stream_check_logs (
844                    provider_id, provider_name, app_type, status, success, message,
845                    response_time_ms, http_status, model_used, retry_count, tested_at
846                ) VALUES ('local-provider', 'Local Provider', 'claude', 'operational', 1, 'ok', 42, 200, 'claude-3', 0, 1000)",
847                [],
848            )?;
849        }
850
851        local_db.import_sql_string_for_sync(&remote_sql)?;
852
853        let remote_provider_exists: i64 = {
854            let conn = crate::database::lock_conn!(local_db.conn);
855            conn.query_row(
856                "SELECT COUNT(*) FROM providers WHERE id = 'remote-provider' AND app_type = 'claude'",
857                [],
858                |row| row.get(0),
859            )?
860        };
861        assert_eq!(
862            remote_provider_exists, 1,
863            "remote config should be imported"
864        );
865
866        let (request_logs, rollups, stream_logs): (i64, i64, i64) = {
867            let conn = crate::database::lock_conn!(local_db.conn);
868            let request_logs =
869                conn.query_row("SELECT COUNT(*) FROM proxy_request_logs", [], |row| {
870                    row.get(0)
871                })?;
872            let rollups =
873                conn.query_row("SELECT COUNT(*) FROM usage_daily_rollups", [], |row| {
874                    row.get(0)
875                })?;
876            let stream_logs =
877                conn.query_row("SELECT COUNT(*) FROM stream_check_logs", [], |row| {
878                    row.get(0)
879                })?;
880            (request_logs, rollups, stream_logs)
881        };
882        assert_eq!(request_logs, 1, "local request logs should be preserved");
883        assert_eq!(rollups, 1, "local rollups should be preserved");
884        assert_eq!(
885            stream_logs, 1,
886            "local stream check logs should be preserved"
887        );
888
889        Ok(())
890    }
891
892    #[test]
893    fn sync_import_preserves_local_settings_keys() -> Result<(), AppError> {
894        let remote_db = Database::memory()?;
895        {
896            let conn = crate::database::lock_conn!(remote_db.conn);
897            seed_provider(&conn, "remote-provider")?;
898            conn.execute(
899                "INSERT INTO settings (key, value) VALUES ('proxy_runtime_session', '{\"pid\":999}')",
900                [],
901            )
902            .map_err(|e| AppError::Database(e.to_string()))?;
903        }
904        let remote_sql = remote_db.export_sql_string()?;
905
906        let local_db = Database::memory()?;
907        local_db
908            .set_setting("proxy_runtime_session", "{\"pid\":123}")
909            .expect("persist local runtime session");
910        {
911            let conn = crate::database::lock_conn!(local_db.conn);
912            seed_provider(&conn, "local-provider")?;
913        }
914
915        local_db.import_sql_string_for_sync(&remote_sql)?;
916
917        assert_eq!(
918            local_db
919                .get_setting("proxy_runtime_session")
920                .expect("read local runtime session after import")
921                .as_deref(),
922            Some("{\"pid\":123}")
923        );
924
925        Ok(())
926    }
927
928    #[test]
929    fn sync_import_preserves_local_proxy_state_and_keeps_remote_strategy() -> Result<(), AppError> {
930        let remote_db = Database::memory()?;
931        {
932            let conn = crate::database::lock_conn!(remote_db.conn);
933            seed_provider(&conn, "remote-provider")?;
934            set_proxy_row(
935                &conn,
936                "claude",
937                false,
938                "192.168.10.10",
939                31001,
940                false,
941                true,
942                9,
943            )?;
944            set_proxy_row(&conn, "codex", true, "192.168.10.11", 31002, true, false, 8)?;
945            set_proxy_row(
946                &conn,
947                "gemini",
948                false,
949                "192.168.10.12",
950                31003,
951                true,
952                true,
953                7,
954            )?;
955            conn.execute(
956                "INSERT INTO settings (key, value) VALUES ('proxy_runtime_session', '{\"pid\":999}')",
957                [],
958            )
959            .map_err(|e| AppError::Database(e.to_string()))?;
960        }
961        let remote_sql = remote_db.export_sql_string()?;
962
963        let local_db = Database::memory()?;
964        local_db
965            .set_setting("proxy_runtime_session", "{\"pid\":123}")
966            .expect("persist local runtime session");
967        {
968            let conn = crate::database::lock_conn!(local_db.conn);
969            seed_provider(&conn, "local-provider")?;
970            set_proxy_row(&conn, "claude", true, "10.0.0.1", 21001, true, false, 1)?;
971            set_proxy_row(&conn, "codex", false, "10.0.0.2", 21002, false, true, 2)?;
972            set_proxy_row(&conn, "gemini", true, "10.0.0.3", 21003, false, false, 3)?;
973        }
974
975        local_db.import_sql_string_for_sync(&remote_sql)?;
976
977        let conn = crate::database::lock_conn!(local_db.conn);
978        assert_eq!(
979            read_proxy_row(&conn, "claude")?,
980            (true, "10.0.0.1".to_string(), 21001, true, true, 9)
981        );
982        assert_eq!(
983            read_proxy_row(&conn, "codex")?,
984            (false, "10.0.0.2".to_string(), 21002, false, false, 8)
985        );
986        assert_eq!(
987            read_proxy_row(&conn, "gemini")?,
988            (true, "10.0.0.3".to_string(), 21003, false, true, 7)
989        );
990
991        drop(conn);
992        assert_eq!(
993            local_db
994                .get_setting("proxy_runtime_session")
995                .expect("read local runtime session after overlay")
996                .as_deref(),
997            Some("{\"pid\":123}")
998        );
999
1000        Ok(())
1001    }
1002
1003    #[test]
1004    fn sync_export_scrubbed_snapshot_old_client_behavior_is_neutral_not_poisoned(
1005    ) -> Result<(), AppError> {
1006        let db = Database::memory()?;
1007        db.set_setting("proxy_runtime_session", "{\"pid\":456}")
1008            .expect("persist runtime session");
1009        {
1010            let conn = crate::database::lock_conn!(db.conn);
1011            seed_provider(&conn, "portable-provider")?;
1012            set_proxy_row(&conn, "claude", true, "10.1.0.1", 41001, true, true, 6)?;
1013            set_proxy_row(&conn, "codex", true, "10.1.0.2", 41002, true, false, 5)?;
1014            set_proxy_row(&conn, "gemini", true, "10.1.0.3", 41003, true, true, 4)?;
1015        }
1016
1017        let sync_sql = db.export_sql_string_for_sync()?;
1018        assert!(
1019            !sync_sql.contains("proxy_runtime_session"),
1020            "sync export should omit runtime session key:\n{sync_sql}"
1021        );
1022
1023        let old_client_db = Database::memory()?;
1024        old_client_db.import_sql_string(&sync_sql)?;
1025        let conn = crate::database::lock_conn!(old_client_db.conn);
1026        assert_eq!(
1027            read_proxy_row(&conn, "claude")?,
1028            (false, "127.0.0.1".to_string(), 15721, false, true, 6)
1029        );
1030        assert_eq!(
1031            read_proxy_row(&conn, "codex")?,
1032            (false, "127.0.0.1".to_string(), 15721, false, false, 5)
1033        );
1034        assert_eq!(
1035            read_proxy_row(&conn, "gemini")?,
1036            (false, "127.0.0.1".to_string(), 15721, false, true, 4)
1037        );
1038        drop(conn);
1039        assert!(
1040            old_client_db
1041                .get_setting("proxy_runtime_session")
1042                .expect("read runtime session from old client import")
1043                .is_none(),
1044            "old client import should not receive runtime session marker"
1045        );
1046
1047        Ok(())
1048    }
1049}