1use 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 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 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 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 let backup_path = self.backup_database_file()?;
147
148 let local_snapshot = policy.map(|_| self.snapshot_to_memory()).transpose()?;
149
150 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 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 {
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 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 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 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 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 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 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 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 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 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 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}