From c036c6359e24d6925779079a503a45839fed9c0e Mon Sep 17 00:00:00 2001 From: SaladDay Date: Sat, 1 Aug 2026 19:45:36 +0000 Subject: [PATCH] refactor(database): construct untrusted migrations from source specs --- src-tauri/src/database/backup.rs | 240 ++- .../backup_restore_certification_ext.rs | 1458 ++++++++++++-- src-tauri/src/database/migration_source.rs | 1728 +++++++++++++++++ src-tauri/src/database/mod.rs | 1 + src-tauri/src/database/schema.rs | 1163 ++++++----- .../pi/canonical-schema-manifest-v1.json | 6 +- 6 files changed, 3855 insertions(+), 741 deletions(-) create mode 100644 src-tauri/src/database/migration_source.rs diff --git a/src-tauri/src/database/backup.rs b/src-tauri/src/database/backup.rs index 4576f6836..d4f2fdad6 100644 --- a/src-tauri/src/database/backup.rs +++ b/src-tauri/src/database/backup.rs @@ -1359,7 +1359,13 @@ impl UntrustedScratch { self.connection .set_db_config(DbConfig::SQLITE_DBCONFIG_ENABLE_TRIGGER, true) .map_err(|error| AppError::Database(error.to_string()))?; - Database::create_tables_on_conn(&self.connection, MigrationRunContext::UntrustedRestore)?; + self.connection + .execute_batch("PRAGMA foreign_keys = OFF;") + .map_err(|error| AppError::Database(error.to_string()))?; + // Source recognition is deliberately first. In particular, never + // create current tables in this connection: doing so would turn a + // missing source table into an apparently valid empty table. + Database::validate_untrusted_migration_source(&self.connection)?; Database::apply_schema_migrations_on_conn( &self.connection, MigrationRunContext::UntrustedRestore, @@ -1612,7 +1618,12 @@ fn validate_restore_row(spec: &RestoreTableSpec, values: &[Value]) -> Result<(), let meta = text_value(spec.name, "meta", &values[11])?; crate::database::dao::providers::validate_provider_storage_json( app_type, id, settings, meta, - )?; + ) + .map_err(|error| { + AppError::InvalidInput(format!( + "restore provider row '{app_type}/{id}' is not decodable: {error}" + )) + })?; crate::database::validate_cost_multiplier(text_value( spec.name, "cost_multiplier", @@ -2511,6 +2522,11 @@ impl Database { Ok(output) } + #[cfg(test)] + pub(crate) fn dump_sql_for_migration_test(conn: &Connection) -> Result { + Self::dump_sql(conn, &[]) + } + /// 获取表的列名列表 fn get_table_columns(conn: &Connection, table: &str) -> Result, AppError> { let mut stmt = conn @@ -2904,6 +2920,94 @@ mod tests { source.snapshot_to_memory() } + fn exact_version_source_with_pi_provider(version: i32) -> Result { + let source = crate::database::migration_source::exact_migration_source_for_test(version)?; + let provider_id = format!("migration-v{version}"); + let settings_config = + crate::database::migration_source::pinned_pi_provider_settings_for_test(version); + if version == 1 { + source.execute( + "INSERT INTO providers ( + id, app_type, name, settings_config, website_url, category, + created_at, sort_index, notes, icon, icon_color, meta, is_current + ) VALUES ( + ?1, 'pi', ?2, ?3, ?4, 'custom', 1700000001, 1, + 'Pi migration sentinel', 'pi', '#13579b', '{}', 1 + )", + rusqlite::params![ + provider_id, + format!("Migration v{version}"), + settings_config, + format!("https://pi-v{version}.example") + ], + )?; + source.execute_batch( + "INSERT INTO proxy_config ( + id, proxy_enabled, listen_address, listen_port, enable_logging, + max_retries, streaming_first_byte_timeout, + streaming_idle_timeout, non_streaming_timeout + ) VALUES (1, 1, '10.20.30.40', 23456, 0, 9, 61, 122, 603); + INSERT INTO circuit_breaker_config ( + id, failure_threshold, success_threshold, timeout_seconds, + error_rate_threshold, min_requests + ) VALUES (1, 7, 4, 73, 0.375, 21); + INSERT INTO settings (key, value) VALUES + ('proxy_takeover_claude', 'true'), + ('auto_failover_enabled_claude', 'false'), + ('proxy_takeover_codex', 'false'), + ('auto_failover_enabled_codex', 'true'), + ('proxy_takeover_gemini', 'true'), + ('auto_failover_enabled_gemini', 'true');", + )?; + } else { + source.execute( + "INSERT INTO providers ( + id, app_type, name, settings_config, website_url, category, + created_at, sort_index, notes, icon, icon_color, meta, + is_current, in_failover_queue, cost_multiplier, + limit_daily_usd, limit_monthly_usd, provider_type + ) VALUES ( + ?1, 'pi', ?2, ?3, ?4, 'custom', 1700000001, 1, + 'Pi migration sentinel', 'pi', '#13579b', '{}', + 1, 0, '1.25', '42.50', '420.50', 'pi-native' + )", + rusqlite::params![ + provider_id, + format!("Migration v{version}"), + settings_config, + format!("https://pi-v{version}.example") + ], + )?; + } + if version == SCHEMA_VERSION { + source.execute( + "INSERT INTO provider_endpoints ( + id, provider_id, app_type, url, added_at, last_used + ) VALUES (?1, ?2, 'pi', ?3, ?4, ?5)", + rusqlite::params![ + 10_000 + version, + provider_id, + format!("https://pi-endpoint-v{version}.example/v1"), + 1_700_000_000 + version, + 1_700_100_000 + version + ], + )?; + } else { + source.execute( + "INSERT INTO provider_endpoints ( + id, provider_id, app_type, url, added_at + ) VALUES (?1, ?2, 'pi', ?3, ?4)", + rusqlite::params![ + 10_000 + version, + provider_id, + format!("https://pi-endpoint-v{version}.example/v1"), + 1_700_000_000 + version + ], + )?; + } + Ok(source) + } + fn actual_v16_duplicate_endpoint_source() -> Result { let source = canonical_restore_source()?; source.execute_batch( @@ -3904,9 +4008,9 @@ mod tests { })?; let _home_guard = TestHomeGuard::set(test_home.path()); - // Exercise the oldest supported layout, rather than merely relabeling - // a current schema. Missing columns must be supplied by the real - // v0..current migration chain before fixed-column restore begins. + // The source-spec authority supports exactly v1..v17. A legacy v0 + // label must fail before migration DDL instead of being interpreted + // through the permissive local-upgrade path. for (entry_index, entry_point) in [RestoreEntryPoint::Sql, RestoreEntryPoint::Binary] .into_iter() .enumerate() @@ -3929,22 +4033,20 @@ mod tests { PRAGMA user_version = 0;", )?; let target = Database::memory()?; - run_restore_entry( + let error = run_restore_entry( &target, &oldest, entry_point, &format!("actual-v0-{entry_index}.db"), - )?; - let conn = crate::database::lock_conn!(target.conn); - let restored: i64 = conn.query_row( - "SELECT COUNT(*) FROM providers - WHERE id = 'actual-v0-provider' - AND app_type = 'pi' - AND cost_multiplier = '1.0'", - [], - |row| row.get(0), - )?; - assert_eq!(restored, 1, "actual oldest-layout migration sentinel"); + ) + .expect_err("untrusted v0 is outside the declared source-spec range"); + assert!( + matches!(error, AppError::InvalidInput(_)) + && error + .to_string() + .contains("unsupported restore user_version 0"), + "v0 must fail at source recognition via entry {entry_index}: {error:?}" + ); } // Exercise the real immediately-previous v16 shape. In particular, @@ -3993,69 +4095,55 @@ mod tests { assert_eq!(sentinel, (None, 0, 0, 0)); } - // Every version still gets a public dispatch sentinel. The separate - // historical-layout cases above prevent this matrix from passing only - // because current tables were stamped with an older user_version. - for version in 0..=SCHEMA_VERSION { - let source_db = Database::memory()?; + // Every supported version is materialized from its exact source spec. + // Each public entry must preserve a real Pi provider and its endpoint; + // this cannot pass by stamping a current database with an old version. + for version in 1..=SCHEMA_VERSION { + for (entry_index, entry_point) in [RestoreEntryPoint::Sql, RestoreEntryPoint::Binary] + .into_iter() + .enumerate() { - let conn = crate::database::lock_conn!(source_db.conn); - conn.execute( - "INSERT INTO providers (id, app_type, name, settings_config, meta) - VALUES (?1, 'pi', ?2, '{}', '{}')", - rusqlite::params![ - format!("migration-v{version}"), - format!("Migration v{version}") - ], + let source = exact_version_source_with_pi_provider(version)?; + let target = Database::memory()?; + run_restore_entry( + &target, + &source, + entry_point, + &format!("exact-migration-v{version}-{entry_index}.db"), )?; - Database::set_user_version(&conn, version)?; - } - let source = source_db.snapshot_to_memory()?; - let target = Database::memory()?; - run_restore_entry( - &target, - &source, - RestoreEntryPoint::Sql, - &format!("unused-migration-v{version}.db"), - )?; - let conn = crate::database::lock_conn!(target.conn); - let restored: i64 = conn.query_row( - "SELECT COUNT(*) FROM providers WHERE id = ?1 AND app_type = 'pi'", - [format!("migration-v{version}")], - |row| row.get(0), - )?; - assert_eq!(restored, 1, "SQL migration sentinel v{version}"); - } - - for version in [0, SCHEMA_VERSION - 1, SCHEMA_VERSION] { - let source_db = Database::memory()?; - { - let conn = crate::database::lock_conn!(source_db.conn); - conn.execute( - "INSERT INTO providers (id, app_type, name, settings_config, meta) - VALUES (?1, 'pi', ?2, '{}', '{}')", - rusqlite::params![ - format!("binary-migration-v{version}"), - format!("Binary migration v{version}") - ], + let conn = crate::database::lock_conn!(target.conn); + let restored: (String, String, String, Option) = conn.query_row( + "SELECT p.settings_config, p.cost_multiplier, e.url, e.last_used + FROM providers AS p + JOIN provider_endpoints AS e + ON e.provider_id = p.id AND e.app_type = p.app_type + WHERE p.id = ?1 AND p.app_type = 'pi'", + [format!("migration-v{version}")], + |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?)), )?; - Database::set_user_version(&conn, version)?; + assert!( + restored.0.contains("\"id\":\"fractional\"") + && restored + .0 + .contains(&format!("\"migrationVersion\":{version}")), + "Pi settings payload was not preserved for v{version}" + ); + assert_eq!( + restored.1, + if version == 1 { "1.0" } else { "1.25" }, + "provider migration sentinel v{version}" + ); + assert_eq!( + restored.2, + format!("https://pi-endpoint-v{version}.example/v1"), + "endpoint migration sentinel v{version}" + ); + assert_eq!( + restored.3, + (version == SCHEMA_VERSION).then_some(1_700_100_000 + i64::from(version)), + "endpoint last_used migration sentinel v{version}" + ); } - let source = source_db.snapshot_to_memory()?; - let target = Database::memory()?; - run_restore_entry( - &target, - &source, - RestoreEntryPoint::Binary, - &format!("migration-v{version}.db"), - )?; - let conn = crate::database::lock_conn!(target.conn); - let restored: i64 = conn.query_row( - "SELECT COUNT(*) FROM providers WHERE id = ?1 AND app_type = 'pi'", - [format!("binary-migration-v{version}")], - |row| row.get(0), - )?; - assert_eq!(restored, 1, "binary migration sentinel v{version}"); } Ok(()) } diff --git a/src-tauri/src/database/backup_restore_certification_ext.rs b/src-tauri/src/database/backup_restore_certification_ext.rs index 8e9716195..0593d1ac9 100644 --- a/src-tauri/src/database/backup_restore_certification_ext.rs +++ b/src-tauri/src/database/backup_restore_certification_ext.rs @@ -2,16 +2,22 @@ // 裁决方授权:扩展认证沿用核心套件的纯风格类 lint 豁免。安全/正确性 // lint 不在豁免范围内。 #![allow(clippy::type_complexity)] -//! 前置工程 B:历史迁移 fail-closed 扩展认证。 +//! 前置工程 B:版本化 source spec 与历史迁移全函数映射认证。 //! //! 本文件只扩展、不得替代 `backup_restore_certification.rs`。行为测试必须 -//! 穿过两个公开入口,证明 v1 旧式 proxy 配置中的损坏不会被默认值修复后 -//! 静默发布。 +//! 穿过 SQL 与 binary 两个公开入口,证明 v1..v17 的精确源形状先于任何 +//! migration DDL 被认证、每个 portable 源字段被映射或结构化拒绝,并且旧式 +//! proxy/skill 等 typed transform 不会以默认值修复损坏输入。 +use super::migration_source::{ + exact_migration_source_for_test, migration_source_spec, pinned_pi_provider_settings_for_test, + validate_migration_mapping_completeness, MigrationStorageClass, +}; use super::Database; use crate::error::AppError; -use rusqlite::{backup::Backup, Connection}; +use rusqlite::{backup::Backup, params_from_iter, types::Value, Connection}; use serial_test::serial; +use std::collections::BTreeMap; use std::path::Path; use std::time::Duration; @@ -25,10 +31,35 @@ enum RestoreEntry { enum V1Damage { None, MissingPrimaryRow, + DuplicatePrimaryRow, WrongStorageClass, FieldDecodeFailure, MissingCircuitRow, + DuplicateCircuitRow, MissingSettingRow, + DuplicateSettingRow, +} + +#[derive(Debug, PartialEq)] +struct RestoredV1ProxyRow { + app_type: String, + proxy_enabled: i64, + listen_address: String, + listen_port: i64, + enable_logging: i64, + enabled: i64, + auto_failover_enabled: i64, + max_retries: i64, + streaming_first_byte_timeout: i64, + streaming_idle_timeout: i64, + non_streaming_timeout: i64, + circuit_failure_threshold: i64, + circuit_success_threshold: i64, + circuit_timeout_seconds: i64, + circuit_error_rate_threshold: f64, + circuit_min_requests: i64, + created_at: String, + updated_at: String, } struct TestHomeGuard(Option); @@ -50,68 +81,578 @@ impl Drop for TestHomeGuard { } } -fn v1_proxy_fixture(damage: V1Damage) -> String { - let proxy_row = match damage { - V1Damage::MissingPrimaryRow => "(2, 0, '127.0.0.1', 15721, 1, 3, 60, 120, 600)", - V1Damage::WrongStorageClass => "(1, 0, '127.0.0.1', X'00', 1, 3, 60, 120, 600)", - V1Damage::FieldDecodeFailure => "(1, 0, '127.0.0.1', 15721, 1, 3, 2147483648, 120, 600)", - V1Damage::None | V1Damage::MissingCircuitRow | V1Damage::MissingSettingRow => { - "(1, 0, '127.0.0.1', 15721, 1, 3, 60, 120, 600)" +fn exact_source_connection(version: i32) -> Result { + exact_migration_source_for_test(version) +} + +fn dump_exact_source(connection: &Connection) -> Result { + Database::dump_sql_for_migration_test(connection) +} + +#[derive(Debug)] +struct PortableSentinelProjection { + table: &'static str, + columns: Vec<&'static str>, + rows: Vec>, +} + +fn quoted_identifier(identifier: &str) -> String { + format!("\"{}\"", identifier.replace('"', "\"\"")) +} + +fn nonportable_restore_policy(table: &str) -> Option<&'static str> { + match table { + "provider_health" => Some("rebuild_runtime"), + "session_log_sync" | "pi_provider_projections" | "skill_deployments" => { + Some("preserve_live") } - }; - let circuit_row = if matches!(damage, V1Damage::MissingCircuitRow) { - "" - } else { - "INSERT INTO circuit_breaker_config VALUES (1, 5, 2, 60, 0.5, 10);" - }; - let settings_rows = if matches!(damage, V1Damage::MissingSettingRow) { - "" - } else { - "INSERT INTO settings (key, value) VALUES + _ => None, + } +} + +fn boolean_sentinel(column: &str) -> Option { + match column { + "installed" + | "is_current" + | "enabled" + | "enabled_claude" + | "enabled_gemini" + | "enabled_opencode" + | "enabled_pi" + | "proxy_enabled" + | "is_healthy" + | "success" + | "is_streaming" + | "live_takeover_active" => Some(1), + "in_failover_queue" + | "enabled_codex" + | "enabled_grokbuild" + | "enabled_hermes" + | "enable_logging" + | "auto_failover_enabled" => Some(0), + _ => None, + } +} + +fn integer_sentinel(version: i32, table: &str, column: &str, ordinal: usize) -> i64 { + if let Some(value) = boolean_sentinel(column) { + return value; + } + match (table, column) { + ("proxy_config" | "circuit_breaker_config", "id") => 1, + ("provider_endpoints", "id") => 10_000 + i64::from(version), + ("stream_check_logs", "id") => 20_000 + i64::from(version), + (_, "sort_index" | "sort_order") => 3, + (_, "listen_port") => 23_456, + (_, "max_retries") => 9, + (_, "streaming_first_byte_timeout") => 61, + (_, "streaming_idle_timeout") => 122, + (_, "non_streaming_timeout") => 603, + (_, "circuit_failure_threshold" | "failure_threshold") => 7, + (_, "circuit_success_threshold" | "success_threshold") => 4, + (_, "circuit_timeout_seconds" | "timeout_seconds") => 73, + (_, "circuit_min_requests" | "min_requests") => 21, + (_, "input_token_semantics") => 2, + ("proxy_request_logs", "input_tokens") => 101, + ("proxy_request_logs", "output_tokens") => 202, + ("proxy_request_logs", "cache_read_tokens") => 303, + ("proxy_request_logs", "cache_creation_tokens") => 404, + ("usage_daily_rollups", "request_count") => 17, + ("usage_daily_rollups", "success_count") => 13, + ("usage_daily_rollups", "input_tokens") => 111, + ("usage_daily_rollups", "output_tokens") => 222, + ("usage_daily_rollups", "cache_read_tokens") => 333, + ("usage_daily_rollups", "cache_creation_tokens") => 444, + (_, "latency_ms") => 505, + (_, "avg_latency_ms") => 506, + (_, "first_token_ms") => 51, + (_, "duration_ms") => 507, + (_, "status_code") => 201, + (_, "http_status") => 202, + (_, "response_time_ms") => 508, + (_, "retry_count") => 2, + (_, "consecutive_failures") => 6, + (_, "last_line_offset") => 4096, + (_, "installed_at") => 1_700_100_000 + i64::from(version), + _ => 1_700_000_000 + i64::from(version) * 100 + ordinal as i64, + } +} + +fn text_sentinel(version: i32, table: &str, column: &str) -> String { + match (table, column) { + ("providers", "id") | (_, "provider_id") => format!("pi-provider-v{version}"), + ("providers", "app_type") + | ("provider_endpoints", "app_type") + | ("prompts", "app_type") + | ("provider_health", "app_type") + | ("proxy_request_logs", "app_type") + | ("stream_check_logs", "app_type") + | ("usage_daily_rollups", "app_type") + | ("skill_deployments", "app_type") => "pi".to_string(), + ("skills", "app_type") | ("proxy_config", "app_type") => "claude".to_string(), + ("skills", "key") => format!("claude:pi-skill-v{version}"), + ("skills", "id") | ("skill_deployments", "skill_id") => { + format!("pi-skill-v{version}") + } + ("skills", "directory") => format!("pi-skill-v{version}"), + ("mcp_servers", "id") => format!("pi-mcp-v{version}"), + ("prompts", "id") => format!("pi-prompt-v{version}"), + ("profiles", "id") => format!("pi-profile-v{version}"), + ("providers", "name") => format!("Pi migration provider v{version}"), + ("mcp_servers", "name") => format!("Pi migration MCP v{version}"), + ("prompts", "name") => format!("Pi migration prompt v{version}"), + ("skills", "name") => format!("Pi migration skill v{version}"), + ("profiles", "name") => format!("Pi migration profile v{version}"), + ("providers", "settings_config") => pinned_pi_provider_settings_for_test(version), + ("providers", "meta") => "{}".to_string(), + ("providers", "website_url") => format!("https://pi-v{version}.example"), + ("providers", "category") => "custom".to_string(), + ("providers", "notes") => format!("Pi migration notes v{version}"), + ("providers", "icon") => "pi".to_string(), + ("providers", "icon_color") => "#13579b".to_string(), + ("providers", "cost_multiplier") | ("proxy_config", "default_cost_multiplier") => { + "1.25".to_string() + } + ("providers", "limit_daily_usd") => "42.50".to_string(), + ("providers", "limit_monthly_usd") => "420.50".to_string(), + ("providers", "provider_type") | ("proxy_request_logs", "provider_type") => { + "pi-native".to_string() + } + ("provider_endpoints", "url") => { + format!("https://pi-endpoint-v{version}.example/v1") + } + ("mcp_servers", "server_config") => { + r#"{"command":"printf","args":["pi-migration"]}"#.to_string() + } + ("mcp_servers", "tags") => r#"["pi","migration"]"#.to_string(), + ("mcp_servers", "homepage") => format!("https://pi-mcp-v{version}.example"), + ("mcp_servers", "docs") => format!("https://pi-mcp-v{version}.example/docs"), + ("prompts", "content") => format!("Pi migration prompt body v{version}"), + ("skills", "repo_owner") | ("skill_repos", "owner") => "pi-maintainers".to_string(), + ("skills", "repo_name") | ("skill_repos", "name") => { + format!("pi-skill-repo-v{version}") + } + ("skills", "repo_branch") | ("skill_repos", "branch") => "main".to_string(), + ("skills", "readme_url") => { + format!("https://pi-skills-v{version}.example/README.md") + } + ("skills", "content_hash") => format!("sha256:pi-skill-v{version}"), + ("settings", "key") => format!("migration.pi.v{version}"), + ("settings", "value") => format!("pi-setting-value-v{version}"), + ("proxy_config", "listen_address") => "10.20.30.40".to_string(), + ("proxy_config", "pricing_model_source") => "response".to_string(), + ("proxy_config", "created_at") => format!("2026-08-{version:02}T01:02:03Z"), + ("proxy_config", "updated_at") => format!("2026-08-{version:02}T04:05:06Z"), + ("proxy_request_logs", "request_id") => format!("pi-request-v{version}"), + ("proxy_request_logs", "model") => format!("pi-model-v{version}"), + ("proxy_request_logs", "request_model") => format!("pi-request-model-v{version}"), + ("proxy_request_logs", "pricing_model") => format!("pi-pricing-model-v{version}"), + ("proxy_request_logs", "input_cost_usd") => "1.01".to_string(), + ("proxy_request_logs", "output_cost_usd") => "2.02".to_string(), + ("proxy_request_logs", "cache_read_cost_usd") => "3.03".to_string(), + ("proxy_request_logs", "cache_creation_cost_usd") => "4.04".to_string(), + ("proxy_request_logs", "total_cost_usd") | ("usage_daily_rollups", "total_cost_usd") => { + "10.10".to_string() + } + ("proxy_request_logs", "error_message") => "pi-sentinel-error".to_string(), + ("proxy_request_logs", "session_id") => format!("pi-session-v{version}"), + ("proxy_request_logs", "cost_multiplier") => "1.75".to_string(), + ("proxy_request_logs", "data_source") => "proxy".to_string(), + ("model_pricing", "model_id") => format!("pi-priced-model-v{version}"), + ("model_pricing", "display_name") => format!("Pi priced model v{version}"), + ("model_pricing", "input_cost_per_million") => "0.11".to_string(), + ("model_pricing", "output_cost_per_million") => "0.22".to_string(), + ("model_pricing", "cache_read_cost_per_million") => "0.033".to_string(), + ("model_pricing", "cache_creation_cost_per_million") => "0.044".to_string(), + ("stream_check_logs", "provider_name") => format!("Pi provider v{version}"), + ("stream_check_logs", "status") => "healthy".to_string(), + ("stream_check_logs", "message") => "Pi migration stream check".to_string(), + ("stream_check_logs", "model_used") => format!("pi-model-v{version}"), + ("proxy_live_backup", "original_config") => { + r#"{"provider":"pi","source":"migration"}"#.to_string() + } + ("proxy_live_backup", "backed_up_at") => { + format!("2026-08-{version:02}T07:08:09Z") + } + ("usage_daily_rollups", "date") => "2026-08-01".to_string(), + ("usage_daily_rollups", "model") => format!("pi-model-v{version}"), + ("usage_daily_rollups", "request_model") => { + format!("pi-request-model-v{version}") + } + ("usage_daily_rollups", "pricing_model") => { + format!("pi-pricing-model-v{version}") + } + ("session_log_sync", "file_path") => format!("/pi/session/v{version}.jsonl"), + ("profiles", "payload") => "{}".to_string(), + ("pi_provider_projections", "provider_key") => { + format!("pi-native-provider-key-v{version}") + } + ("skill_deployments", "destination") => { + format!("/pi/skills/v{version}") + } + ("skill_deployments", "destination_key") => { + format!("pi-destination-v{version}") + } + ("skill_deployments", "method") => "symlink".to_string(), + ("skill_deployments", "source_identity") => { + format!("pi-source-identity-v{version}") + } + ("skill_deployments", "deployed_digest") => { + format!("sha256:pi-deployment-v{version}") + } + (_, "description") => format!("Pi migration description v{version}"), + (_, "created_at") => format!("2026-08-{version:02}T10:11:12Z"), + (_, "updated_at") => format!("2026-08-{version:02}T13:14:15Z"), + _ => format!("pi-{table}-{column}-v{version}"), + } +} + +fn full_field_sentinel_value( + version: i32, + table: &str, + column: &str, + storage: MigrationStorageClass, + ordinal: usize, +) -> Value { + match storage { + MigrationStorageClass::Integer => { + Value::Integer(integer_sentinel(version, table, column, ordinal)) + } + MigrationStorageClass::Real => Value::Real(0.375), + MigrationStorageClass::Text => Value::Text(text_sentinel(version, table, column)), + } +} + +fn seed_full_source_sentinels(source: &Connection, version: i32) -> Result<(), AppError> { + let spec = migration_source_spec(version)?; + for table in &spec.tables { + let columns = table + .columns + .iter() + .map(|column| quoted_identifier(column.name)) + .collect::>() + .join(", "); + let placeholders = (1..=table.columns.len()) + .map(|index| format!("?{index}")) + .collect::>() + .join(", "); + let values = table + .columns + .iter() + .enumerate() + .map(|(ordinal, column)| { + full_field_sentinel_value(version, table.name, column.name, column.storage, ordinal) + }) + .collect::>(); + source.execute( + &format!( + "INSERT INTO {} ({columns}) VALUES ({placeholders})", + quoted_identifier(table.name) + ), + params_from_iter(values), + )?; + } + if version == 1 { + source.execute_batch( + "INSERT INTO settings (key, value) VALUES + ('proxy_takeover_claude', 'true'), + ('auto_failover_enabled_claude', 'false'), + ('proxy_takeover_codex', 'false'), + ('auto_failover_enabled_codex', 'true'), + ('proxy_takeover_gemini', 'true'), + ('auto_failover_enabled_gemini', 'true');", + )?; + } + Ok(()) +} + +fn capture_portable_sentinel_projections( + source: &Connection, + version: i32, +) -> Result, AppError> { + let source_spec = migration_source_spec(version)?; + let current_spec = migration_source_spec(super::SCHEMA_VERSION)?; + let current_tables = current_spec + .tables + .iter() + .map(|table| (table.name, *table)) + .collect::>(); + let mut projections = Vec::new(); + + for source_table in &source_spec.tables { + if nonportable_restore_policy(source_table.name).is_some() { + continue; + } + let Some(current_table) = current_tables.get(source_table.name) else { + continue; + }; + let columns = source_table + .columns + .iter() + .filter(|source_column| { + current_table.columns.iter().any(|current_column| { + current_column.name == source_column.name + && current_column.storage == source_column.storage + }) + }) + .map(|column| column.name) + .collect::>(); + if columns.is_empty() { + continue; + } + let selected = columns + .iter() + .map(|column| quoted_identifier(column)) + .collect::>() + .join(", "); + let mut statement = source.prepare(&format!( + "SELECT {selected} FROM {}", + quoted_identifier(source_table.name) + ))?; + let mut query = statement.query([])?; + let mut rows = Vec::new(); + while let Some(row) = query.next()? { + rows.push( + (0..columns.len()) + .map(|index| row.get::<_, Value>(index)) + .collect::, _>>()?, + ); + } + assert!( + !rows.is_empty(), + "v{version} full-field fixture did not seed portable table {}", + source_table.name + ); + projections.push(PortableSentinelProjection { + table: source_table.name, + columns, + rows, + }); + } + Ok(projections) +} + +fn assert_portable_sentinel_projections( + target: &Database, + version: i32, + entry: RestoreEntry, + projections: &[PortableSentinelProjection], +) -> Result<(), AppError> { + let conn = super::lock_conn!(target.conn); + for projection in projections { + let selected = projection + .columns + .iter() + .map(|column| quoted_identifier(column)) + .collect::>() + .join(", "); + let mut statement = conn.prepare(&format!( + "SELECT {selected} FROM {}", + quoted_identifier(projection.table) + ))?; + let mut query = statement.query([])?; + let mut restored_rows = Vec::new(); + while let Some(row) = query.next()? { + restored_rows.push( + (0..projection.columns.len()) + .map(|index| row.get::<_, Value>(index)) + .collect::, _>>()?, + ); + } + for source_row in &projection.rows { + assert!( + restored_rows.contains(source_row), + "v{version} via {entry:?} did not preserve all common fields for {}: \ + columns={:?}, source={source_row:?}, restored={restored_rows:?}", + projection.table, + projection.columns + ); + } + } + Ok(()) +} + +fn assert_nonportable_source_rows_not_published( + target: &Database, + version: i32, + entry: RestoreEntry, +) -> Result<(), AppError> { + let source_spec = migration_source_spec(version)?; + let conn = super::lock_conn!(target.conn); + for table in source_spec.tables { + let Some(policy) = nonportable_restore_policy(table.name) else { + continue; + }; + let count: i64 = conn.query_row( + &format!("SELECT COUNT(*) FROM {}", quoted_identifier(table.name)), + [], + |row| row.get(0), + )?; + assert_eq!( + count, 0, + "v{version} via {entry:?} published a full-field sentinel from \ + {} despite {policy} policy", + table.name + ); + } + Ok(()) +} + +fn assert_legacy_skill_mapping( + target: &Database, + version: i32, + entry: RestoreEntry, +) -> Result<(), AppError> { + if !matches!(version, 1 | 2) { + return Ok(()); + } + let directory = format!("pi-skill-v{version}"); + let id = format!("claude:{directory}"); + let conn = super::lock_conn!(target.conn); + let restored: (String, String, String, i64, i64, i64, i64) = conn.query_row( + "SELECT id, name, directory, enabled_claude, enabled_codex, + enabled_gemini, installed_at + FROM skills WHERE id = ?1", + [&id], + |row| { + Ok(( + row.get(0)?, + row.get(1)?, + row.get(2)?, + row.get(3)?, + row.get(4)?, + row.get(5)?, + row.get(6)?, + )) + }, + )?; + assert_eq!( + restored, + ( + id, + directory.clone(), + directory, + 1, + 0, + 0, + 1_700_100_000 + i64::from(version), + ), + "v{version} via {entry:?} did not preserve the typed legacy skill row" + ); + Ok(()) +} + +fn seed_required_v1_rows(source: &Connection) -> Result<(), AppError> { + source.execute_batch( + "INSERT INTO proxy_config ( + id, proxy_enabled, listen_address, listen_port, enable_logging, + max_retries, streaming_first_byte_timeout, streaming_idle_timeout, + non_streaming_timeout + ) VALUES (1, 0, '127.0.0.1', 15721, 1, 3, 60, 120, 600); + INSERT INTO circuit_breaker_config ( + id, failure_threshold, success_threshold, timeout_seconds, + error_rate_threshold, min_requests + ) VALUES (1, 5, 2, 60, 0.5, 10); + INSERT INTO settings (key, value) VALUES ('proxy_takeover_claude', 'false'), ('auto_failover_enabled_claude', 'false'), ('proxy_takeover_codex', 'false'), ('auto_failover_enabled_codex', 'false'), ('proxy_takeover_gemini', 'false'), - ('auto_failover_enabled_gemini', 'false');" + ('auto_failover_enabled_gemini', 'false');", + )?; + Ok(()) +} + +fn v1_proxy_fixture(damage: V1Damage) -> Result { + let source = exact_source_connection(1)?; + let proxy_row = match damage { + V1Damage::MissingPrimaryRow => "(2, 0, '127.0.0.1', 15721, 1, 3, 60, 120, 600)", + V1Damage::WrongStorageClass => "(1, 0, '127.0.0.1', X'00', 1, 3, 60, 120, 600)", + V1Damage::FieldDecodeFailure => "(1, 0, '127.0.0.1', 15721, 1, 3, 2147483648, 120, 600)", + V1Damage::None => "(1, 1, '10.20.30.40', 23456, 0, 9, 61, 122, 603)", + V1Damage::DuplicatePrimaryRow + | V1Damage::DuplicateCircuitRow + | V1Damage::DuplicateSettingRow => "(1, 0, '127.0.0.1', 15721, 1, 3, 60, 120, 600)", + V1Damage::MissingCircuitRow | V1Damage::MissingSettingRow => { + "(1, 0, '127.0.0.1', 15721, 1, 3, 60, 120, 600)" + } + }; + let circuit_row = if matches!(damage, V1Damage::MissingCircuitRow) { + None + } else if matches!(damage, V1Damage::None) { + Some((1_i64, 7_i64, 4_i64, 73_i64, 0.375_f64, 21_i64)) + } else { + Some((1_i64, 5_i64, 2_i64, 60_i64, 0.5_f64, 10_i64)) }; - format!( - "-- CC Switch SQLite 导出 -BEGIN TRANSACTION; -CREATE TABLE proxy_config ( - id INTEGER PRIMARY KEY, - proxy_enabled INTEGER NOT NULL DEFAULT 0, - listen_address TEXT NOT NULL, - listen_port INTEGER NOT NULL, - enable_logging INTEGER NOT NULL DEFAULT 1, - max_retries INTEGER NOT NULL DEFAULT 3, - streaming_first_byte_timeout INTEGER NOT NULL DEFAULT 60, - streaming_idle_timeout INTEGER NOT NULL DEFAULT 120, - non_streaming_timeout INTEGER NOT NULL DEFAULT 600 -); -INSERT INTO proxy_config ( - id, proxy_enabled, listen_address, listen_port, enable_logging, - max_retries, streaming_first_byte_timeout, streaming_idle_timeout, - non_streaming_timeout -) VALUES {proxy_row}; -CREATE TABLE circuit_breaker_config ( - id INTEGER PRIMARY KEY, - failure_threshold INTEGER NOT NULL, - success_threshold INTEGER NOT NULL, - timeout_seconds INTEGER NOT NULL, - error_rate_threshold REAL NOT NULL, - min_requests INTEGER NOT NULL -); -{circuit_row} -CREATE TABLE settings ( - key TEXT PRIMARY KEY, - value TEXT -); -{settings_rows} -PRAGMA user_version = 1; -COMMIT;" - ) + source.execute( + &format!( + "INSERT INTO proxy_config ( + id, proxy_enabled, listen_address, listen_port, enable_logging, + max_retries, streaming_first_byte_timeout, streaming_idle_timeout, + non_streaming_timeout + ) VALUES {proxy_row}" + ), + [], + )?; + if matches!(damage, V1Damage::DuplicatePrimaryRow) { + source.execute( + "INSERT INTO proxy_config ( + id, proxy_enabled, listen_address, listen_port, enable_logging, + max_retries, streaming_first_byte_timeout, streaming_idle_timeout, + non_streaming_timeout + ) VALUES (2, 0, '127.0.0.2', 15722, 1, 4, 62, 123, 604)", + [], + )?; + } + if let Some((id, failure, success, timeout, rate, requests)) = circuit_row { + source.execute( + "INSERT INTO circuit_breaker_config ( + id, failure_threshold, success_threshold, timeout_seconds, + error_rate_threshold, min_requests + ) VALUES (?1, ?2, ?3, ?4, ?5, ?6)", + rusqlite::params![id, failure, success, timeout, rate, requests], + )?; + } + if matches!(damage, V1Damage::DuplicateCircuitRow) { + source.execute( + "INSERT INTO circuit_breaker_config ( + id, failure_threshold, success_threshold, timeout_seconds, + error_rate_threshold, min_requests + ) VALUES (2, 8, 5, 74, 0.25, 22)", + [], + )?; + } + if !matches!(damage, V1Damage::MissingSettingRow) { + let settings = if matches!(damage, V1Damage::None) { + "INSERT INTO settings (key, value) VALUES + ('proxy_takeover_claude', 'true'), + ('auto_failover_enabled_claude', 'false'), + ('proxy_takeover_codex', 'false'), + ('auto_failover_enabled_codex', 'true'), + ('proxy_takeover_gemini', 'true'), + ('auto_failover_enabled_gemini', 'true'), + ('proxy_takeover_future', 'preserve-me');" + } else { + "INSERT INTO settings (key, value) VALUES + ('proxy_takeover_claude', 'false'), + ('auto_failover_enabled_claude', 'false'), + ('proxy_takeover_codex', 'false'), + ('auto_failover_enabled_codex', 'false'), + ('proxy_takeover_gemini', 'false'), + ('auto_failover_enabled_gemini', 'false');" + }; + source.execute_batch(settings)?; + if matches!(damage, V1Damage::DuplicateSettingRow) { + source.execute( + "INSERT INTO settings (key, value) + VALUES ('proxy_takeover_claude', 'true')", + [], + )?; + } + } + dump_exact_source(&source) } fn write_binary_fixture(sql: &str, path: &Path) -> Result<(), AppError> { @@ -177,13 +718,12 @@ fn current_schema_fixture( version: i32, mutate: impl FnOnce(&Connection) -> Result<(), AppError>, ) -> Result { - let source = Database::memory()?; - { - let conn = super::lock_conn!(source.conn); - mutate(&conn)?; - Database::set_user_version(&conn, version)?; + let source = exact_source_connection(version)?; + if version == 1 { + seed_required_v1_rows(&source)?; } - source.export_sql_string() + mutate(&source)?; + dump_exact_source(&source) } fn assert_fixture_rejected( @@ -214,6 +754,24 @@ fn assert_fixture_rejected( Ok(()) } +fn assert_fixture_accepted_with( + label: &str, + fixture: &str, + verify: impl Fn(&Database) -> Result<(), AppError>, +) -> Result<(), AppError> { + for entry in [RestoreEntry::Sql, RestoreEntry::Binary] { + let target = Database::memory()?; + let filename = format!("{label}-{entry:?}.db").to_ascii_lowercase(); + run_restore_fixture(&target, fixture, entry, &filename).map_err(|error| { + AppError::InvalidInput(format!( + "{label} via {entry:?} must be accepted losslessly: {error}" + )) + })?; + verify(&target)?; + } + Ok(()) +} + #[test] #[serial] fn v1_damaged_proxy_rows_fail_closed_through_sql_and_binary_entries() -> Result<(), AppError> { @@ -225,15 +783,18 @@ fn v1_damaged_proxy_rows_fail_closed_through_sql_and_binary_entries() -> Result< for damage in [ V1Damage::MissingPrimaryRow, + V1Damage::DuplicatePrimaryRow, V1Damage::WrongStorageClass, V1Damage::FieldDecodeFailure, V1Damage::MissingCircuitRow, + V1Damage::DuplicateCircuitRow, V1Damage::MissingSettingRow, + V1Damage::DuplicateSettingRow, ] { for entry in [RestoreEntry::Sql, RestoreEntry::Binary] { let target = Database::memory()?; seed_live_sentinel(&target)?; - let fixture = v1_proxy_fixture(damage); + let fixture = v1_proxy_fixture(damage)?; let filename = format!("v1-{damage:?}-{entry:?}.db").to_ascii_lowercase(); let error = run_restore_fixture(&target, &fixture, entry, &filename) @@ -257,31 +818,277 @@ fn valid_v1_proxy_rows_migrate_losslessly_through_sql_and_binary_entries() -> Re source: error, })?; let _home_guard = TestHomeGuard::set(home.path()); - let fixture = v1_proxy_fixture(V1Damage::None); + let fixture = v1_proxy_fixture(V1Damage::None)?; for entry in [RestoreEntry::Sql, RestoreEntry::Binary] { let target = Database::memory()?; let filename = format!("valid-v1-{entry:?}.db").to_ascii_lowercase(); run_restore_fixture(&target, &fixture, entry, &filename)?; let conn = super::lock_conn!(target.conn); - let restored: (i64, String, i64) = conn.query_row( + let mut statement = conn.prepare( "SELECT - COUNT(*), - MIN(listen_address), - MIN(listen_port) - FROM proxy_config", + app_type, proxy_enabled, listen_address, listen_port, + enable_logging, enabled, auto_failover_enabled, max_retries, + streaming_first_byte_timeout, streaming_idle_timeout, + non_streaming_timeout, circuit_failure_threshold, + circuit_success_threshold, circuit_timeout_seconds, + circuit_error_rate_threshold, circuit_min_requests, + created_at, updated_at + FROM proxy_config ORDER BY app_type", + )?; + let restored = statement + .query_map([], |row| { + Ok(RestoredV1ProxyRow { + app_type: row.get(0)?, + proxy_enabled: row.get(1)?, + listen_address: row.get(2)?, + listen_port: row.get(3)?, + enable_logging: row.get(4)?, + enabled: row.get(5)?, + auto_failover_enabled: row.get(6)?, + max_retries: row.get(7)?, + streaming_first_byte_timeout: row.get(8)?, + streaming_idle_timeout: row.get(9)?, + non_streaming_timeout: row.get(10)?, + circuit_failure_threshold: row.get(11)?, + circuit_success_threshold: row.get(12)?, + circuit_timeout_seconds: row.get(13)?, + circuit_error_rate_threshold: row.get(14)?, + circuit_min_requests: row.get(15)?, + created_at: row.get(16)?, + updated_at: row.get(17)?, + }) + })? + .collect::, _>>()?; + let expected_flags = [ + ("claude", 1, 0), + ("codex", 0, 1), + ("gemini", 1, 1), + ("grokbuild", 0, 0), + ]; + assert_eq!(restored.len(), expected_flags.len()); + for (row, (app_type, enabled, auto_failover)) in restored.iter().zip(expected_flags) { + assert_eq!(row.app_type, app_type); + if app_type == "grokbuild" { + assert_eq!(row.proxy_enabled, 0); + assert_eq!(row.listen_address, "127.0.0.1"); + assert_eq!(row.listen_port, 15721); + assert_eq!(row.enable_logging, 1); + assert_eq!(row.enabled, 0); + assert_eq!(row.auto_failover_enabled, 0); + assert_eq!(row.max_retries, 3); + assert_eq!(row.streaming_first_byte_timeout, 60); + assert_eq!(row.streaming_idle_timeout, 120); + assert_eq!(row.non_streaming_timeout, 600); + assert_eq!(row.circuit_failure_threshold, 4); + assert_eq!(row.circuit_success_threshold, 2); + assert_eq!(row.circuit_timeout_seconds, 60); + assert_eq!(row.circuit_error_rate_threshold, 0.6); + assert_eq!(row.circuit_min_requests, 10); + } else { + assert_eq!(row.proxy_enabled, 1); + assert_eq!(row.listen_address, "10.20.30.40"); + assert_eq!(row.listen_port, 23456); + assert_eq!(row.enable_logging, 0); + assert_eq!(row.enabled, enabled); + assert_eq!(row.auto_failover_enabled, auto_failover); + assert_eq!(row.max_retries, 9); + assert_eq!(row.streaming_first_byte_timeout, 61); + assert_eq!(row.streaming_idle_timeout, 122); + assert_eq!(row.non_streaming_timeout, 603); + assert_eq!(row.circuit_failure_threshold, 7); + assert_eq!(row.circuit_success_threshold, 4); + assert_eq!(row.circuit_timeout_seconds, 73); + assert_eq!(row.circuit_error_rate_threshold, 0.375); + assert_eq!(row.circuit_min_requests, 21); + } + assert_eq!(row.created_at, super::schema::UNTRUSTED_MIGRATION_TIMESTAMP); + assert_eq!(row.updated_at, super::schema::UNTRUSTED_MIGRATION_TIMESTAMP); + } + let future_setting: String = conn.query_row( + "SELECT value FROM settings WHERE key = 'proxy_takeover_future'", [], - |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)), + |row| row.get(0), )?; assert_eq!( - restored, - (4, "127.0.0.1".to_string(), 15721), - "valid authoritative v1 fields must survive {entry:?}" + future_setting, "preserve-me", + "row-selective ownership mapping must not discard future settings" ); } Ok(()) } +#[test] +#[serial] +fn v1_restore_does_not_synthesize_current_model_pricing() -> Result<(), AppError> { + let home = tempfile::tempdir().map_err(|error| AppError::IoContext { + context: "create v1 structural-table migration home".to_string(), + source: error, + })?; + let _home_guard = TestHomeGuard::set(home.path()); + let fixture = v1_proxy_fixture(V1Damage::None)?; + + for entry in [RestoreEntry::Sql, RestoreEntry::Binary] { + let target = Database::memory()?; + let filename = format!("v1-empty-pricing-{entry:?}.db").to_ascii_lowercase(); + run_restore_fixture(&target, &fixture, entry, &filename)?; + let conn = super::lock_conn!(target.conn); + let count: i64 = + conn.query_row("SELECT COUNT(*) FROM model_pricing", [], |row| row.get(0))?; + assert_eq!( + count, 0, + "v1 via {entry:?} must construct the v2-owned table empty instead of \ + importing the current binary's pricing seed" + ); + } + Ok(()) +} + +#[test] +#[serial] +fn every_declared_source_version_reaches_public_canonical_restore() -> Result<(), AppError> { + let home = tempfile::tempdir().map_err(|error| AppError::IoContext { + context: "create source-spec migration matrix home".to_string(), + source: error, + })?; + let _home_guard = TestHomeGuard::set(home.path()); + + for version in 1..=super::SCHEMA_VERSION { + if version < super::SCHEMA_VERSION { + validate_migration_mapping_completeness(version)?; + } + let source = exact_source_connection(version)?; + if version == 1 { + seed_required_v1_rows(&source)?; + } + let fixture = dump_exact_source(&source)?; + for entry in [RestoreEntry::Sql, RestoreEntry::Binary] { + let target = Database::memory()?; + let filename = format!("source-v{version}-{entry:?}.db").to_ascii_lowercase(); + run_restore_fixture(&target, &fixture, entry, &filename).map_err(|error| { + AppError::InvalidInput(format!( + "declared source v{version} failed via {entry:?}: {error}" + )) + })?; + } + } + Ok(()) +} + +#[test] +#[serial] +fn every_source_version_preserves_full_field_sentinels_through_both_public_entries( +) -> Result<(), AppError> { + let home = tempfile::tempdir().map_err(|error| AppError::IoContext { + context: "create full-field migration matrix home".to_string(), + source: error, + })?; + let _home_guard = TestHomeGuard::set(home.path()); + + for version in 1..=super::SCHEMA_VERSION { + let source = exact_source_connection(version)?; + seed_full_source_sentinels(&source, version)?; + let projections = capture_portable_sentinel_projections(&source, version)?; + let fixture = dump_exact_source(&source)?; + + for entry in [RestoreEntry::Sql, RestoreEntry::Binary] { + let target = Database::memory()?; + let filename = format!("full-field-v{version}-{entry:?}.db").to_ascii_lowercase(); + run_restore_fixture(&target, &fixture, entry, &filename).map_err(|error| { + AppError::InvalidInput(format!( + "full-field source v{version} failed via {entry:?}: {error}" + )) + })?; + assert_portable_sentinel_projections(&target, version, entry, &projections)?; + assert_nonportable_source_rows_not_published(&target, version, entry)?; + assert_legacy_skill_mapping(&target, version, entry)?; + } + } + Ok(()) +} + +#[test] +#[serial] +fn source_spec_rejects_missing_v17_settings_and_v1_proxy_table_at_both_entries( +) -> Result<(), AppError> { + let home = tempfile::tempdir().map_err(|error| AppError::IoContext { + context: "create source-spec missing-table home".to_string(), + source: error, + })?; + let _home_guard = TestHomeGuard::set(home.path()); + + let v17 = exact_source_connection(super::SCHEMA_VERSION)?; + v17.execute("DROP TABLE settings", [])?; + assert_fixture_rejected( + "v17-missing-settings", + "source table set mismatch", + &dump_exact_source(&v17)?, + )?; + + let v1 = exact_source_connection(1)?; + seed_required_v1_rows(&v1)?; + v1.execute("DROP TABLE proxy_config", [])?; + assert_fixture_rejected( + "v1-missing-proxy-config", + "source table set mismatch", + &dump_exact_source(&v1)?, + ) +} + +#[test] +#[serial] +fn source_spec_rejects_extra_tables_and_columns_at_both_entries() -> Result<(), AppError> { + let home = tempfile::tempdir().map_err(|error| AppError::IoContext { + context: "create source-spec extra-shape home".to_string(), + source: error, + })?; + let _home_guard = TestHomeGuard::set(home.path()); + + let extra_table = exact_source_connection(super::SCHEMA_VERSION)?; + extra_table.execute("CREATE TABLE unowned_restore_data (value TEXT)", [])?; + assert_fixture_rejected( + "v17-extra-table", + "source table set mismatch", + &dump_exact_source(&extra_table)?, + )?; + + let extra_column = exact_source_connection(super::SCHEMA_VERSION)?; + extra_column.execute( + "ALTER TABLE settings ADD COLUMN unowned_restore_value TEXT", + [], + )?; + assert_fixture_rejected( + "v17-extra-column", + "source column set mismatch", + &dump_exact_source(&extra_column)?, + ) +} + +#[test] +fn untrusted_migration_cannot_invoke_the_current_schema_factory() -> Result<(), AppError> { + let connection = Connection::open_in_memory()?; + let error = Database::create_tables_on_conn( + &connection, + super::schema::MigrationRunContext::UntrustedRestore, + ) + .expect_err("untrusted context must not enter the canonical factory"); + assert!( + matches!(error, AppError::Config(_)), + "factory barrier must be structural, got {error:?}" + ); + let created: i64 = connection.query_row( + "SELECT COUNT(*) FROM sqlite_schema + WHERE type = 'table' AND name NOT LIKE 'sqlite_%'", + [], + |row| row.get(0), + )?; + assert_eq!( + created, 0, + "factory rejection must happen before any current table is synthesized" + ); + Ok(()) +} + #[test] #[serial] fn every_repair_class_has_a_public_entry_fail_closed_sentinel() -> Result<(), AppError> { @@ -296,148 +1103,117 @@ fn every_repair_class_has_a_public_entry_fail_closed_sentinel() -> Result<(), Ap "v1-unprefixed-skill", "v1->v2 skills", current_schema_fixture(1, |conn| { - conn.execute_batch( - "DROP TABLE skills; - CREATE TABLE skills ( - key TEXT PRIMARY KEY, - installed BOOLEAN NOT NULL DEFAULT 0, - installed_at INTEGER NOT NULL DEFAULT 0 - ); - INSERT INTO skills (key, installed, installed_at) - VALUES ('missing-prefix', 1, 10);", - )?; - Ok(()) - })?, - ), - ( - "v2-nonempty-skills", - "v2->v3 skills SSOT rebuild", - current_schema_fixture(2, |conn| { - conn.execute_batch( - "DROP TABLE skills; - CREATE TABLE skills ( - directory TEXT NOT NULL, - app_type TEXT NOT NULL, - installed BOOLEAN NOT NULL DEFAULT 0, - installed_at INTEGER NOT NULL DEFAULT 0, - PRIMARY KEY (directory, app_type) - ); - INSERT INTO skills - (directory, app_type, installed, installed_at) - VALUES ('legacy-skill', 'claude', 1, 10);", - )?; - Ok(()) - })?, - ), - ( - "v5-invalid-provider-meta", - "v5->v6 provider meta", - current_schema_fixture(5, |conn| { conn.execute( - "INSERT INTO providers - (id, app_type, name, settings_config, meta) - VALUES ('bad-meta', 'claude', 'bad meta', '{}', '{')", + "INSERT INTO skills (key, installed, installed_at) + VALUES ('missing-prefix', 1, 10);", [], )?; Ok(()) })?, ), ( - "v5-provider-meta-normalization", - "v5->v6 provider meta", + "v1-unsupported-prefixed-skill", + "unsupported app", + current_schema_fixture(1, |conn| { + conn.execute( + "INSERT INTO skills (key, installed, installed_at) + VALUES ('unknown:legacy-skill', 1, 10);", + [], + )?; + Ok(()) + })?, + ), + ( + "v1-invalid-skill-boolean", + "field installed=2", + current_schema_fixture(1, |conn| { + conn.execute( + "INSERT INTO skills (key, installed, installed_at) + VALUES ('claude:legacy-skill', 2, 10);", + [], + )?; + Ok(()) + })?, + ), + ( + "v2-unsupported-skill-app", + "unsupported app_type", + current_schema_fixture(2, |conn| { + conn.execute( + "INSERT INTO skills + (directory, app_type, installed, installed_at) + VALUES ('legacy-skill', 'unknown', 1, 10)", + [], + )?; + Ok(()) + })?, + ), + ( + "v5-invalid-provider-meta", + "provider", current_schema_fixture(5, |conn| { conn.execute( "INSERT INTO providers - (id, app_type, name, settings_config, meta) + (id, app_type, name, settings_config, meta, is_current, + in_failover_queue, cost_multiplier) VALUES ( - 'copilot-meta', 'claude', 'copilot meta', '{}', - '{\"usage_script\":{\"template_type\":\"copilot\"}}' + 'bad-meta', 'claude', 'bad meta', '{}', '{', 0, 0, '1.0' )", [], )?; Ok(()) })?, ), - ( - "v7-pricing-correction", - "v7->v8 model pricing correction", - current_schema_fixture(7, |conn| { - conn.execute( - "UPDATE model_pricing - SET input_cost_per_million = '999' - WHERE model_id = 'deepseek-v3.2'", - [], - )?; - Ok(()) - })?, - ), - ( - "v8-pricing-reseed", - "v8->v9 model-pricing replacement", - current_schema_fixture(8, |conn| { - conn.execute( - "INSERT INTO model_pricing ( - model_id, display_name, input_cost_per_million, - output_cost_per_million, cache_read_cost_per_million, - cache_creation_cost_per_million - ) VALUES ('untrusted-extra', 'extra', '1', '1', '0', '0')", - [], - )?; - Ok(()) - })?, - ), - ( - "v10-rollup-synthesis", - "v10->v11 usage rollup rebuild", - current_schema_fixture(10, |conn| { - conn.execute( - "INSERT INTO usage_daily_rollups - (date, app_type, provider_id, model) - VALUES ('2026-08-01', 'claude', 'legacy', 'legacy-model')", - [], - )?; - Ok(()) - })?, - ), - ( - "v13-grokbuild-synthesis", - "v13->v14 proxy rebuild", - current_schema_fixture(13, |conn| { - conn.execute("DELETE FROM proxy_config WHERE app_type = 'grokbuild'", [])?; - Ok(()) - })?, - ), ( "v13-missing-column-fallback", - "v13->v14 proxy rebuild", + "source column set mismatch", current_schema_fixture(13, |conn| { - conn.execute_batch("ALTER TABLE proxy_config DROP COLUMN live_takeover_active;")?; + conn.execute_batch("ALTER TABLE proxy_config DROP COLUMN listen_address;")?; Ok(()) })?, ), ( "v13-wrong-storage", - "v13->v14 proxy rebuild", + "source storage mismatch", current_schema_fixture(13, |conn| { conn.execute( - "UPDATE proxy_config SET listen_port = X'00' - WHERE app_type = 'claude'", + "INSERT INTO proxy_config ( + app_type, proxy_enabled, listen_address, listen_port, + enable_logging, enabled, auto_failover_enabled, max_retries, + streaming_first_byte_timeout, streaming_idle_timeout, + non_streaming_timeout, circuit_failure_threshold, + circuit_success_threshold, circuit_timeout_seconds, + circuit_error_rate_threshold, circuit_min_requests, + default_cost_multiplier, pricing_model_source, + created_at, updated_at + ) VALUES ( + 'claude', 0, '127.0.0.1', X'00', 1, 0, 0, 3, + 60, 120, 600, 4, 2, 60, 0.6, 10, + '1', 'response', 'created', 'updated' + )", [], )?; Ok(()) })?, ), ( - "v15-codex-log-reset", - "v15->v16 Codex usage reset", - current_schema_fixture(15, |conn| { + "v13-premature-grok-row", + "unsupported app_type", + current_schema_fixture(13, |conn| { conn.execute( - "INSERT INTO proxy_request_logs ( - request_id, provider_id, app_type, model, - latency_ms, status_code, created_at, data_source + "INSERT INTO proxy_config ( + app_type, proxy_enabled, listen_address, listen_port, + enable_logging, enabled, auto_failover_enabled, max_retries, + streaming_first_byte_timeout, streaming_idle_timeout, + non_streaming_timeout, circuit_failure_threshold, + circuit_success_threshold, circuit_timeout_seconds, + circuit_error_rate_threshold, circuit_min_requests, + default_cost_multiplier, pricing_model_source, + created_at, updated_at ) VALUES ( - 'untrusted-codex-log', '_codex_session', 'codex', 'legacy', - 1, 200, 1, 'codex_session' + 'grokbuild', 0, '127.0.0.1', 15721, 1, 0, 0, 3, + 60, 120, 600, 4, 2, 60, 0.6, 10, + '1', 'response', 'created', 'updated' )", [], )?; @@ -452,10 +1228,319 @@ fn every_repair_class_has_a_public_entry_fail_closed_sentinel() -> Result<(), Ap Ok(()) } +#[test] +#[serial] +fn historical_rows_are_mapped_or_preserved_instead_of_repaired() -> Result<(), AppError> { + let home = tempfile::tempdir().map_err(|error| AppError::IoContext { + context: "create migration preservation home".to_string(), + source: error, + })?; + let _home_guard = TestHomeGuard::set(home.path()); + + let v2 = exact_source_connection(2)?; + v2.execute( + "INSERT INTO skills (directory, app_type, installed, installed_at) + VALUES ('legacy-skill', 'claude', 1, 1234)", + [], + )?; + assert_fixture_accepted_with("v2-typed-skill", &dump_exact_source(&v2)?, |target| { + let conn = super::lock_conn!(target.conn); + let restored: (String, i64, i64, i64) = conn.query_row( + "SELECT directory, enabled_claude, enabled_codex, installed_at + FROM skills WHERE id = 'claude:legacy-skill'", + [], + |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?)), + )?; + assert_eq!(restored, ("legacy-skill".to_string(), 1, 0, 1234)); + Ok(()) + })?; + + let v5 = exact_source_connection(5)?; + let copilot_meta = "{\"usage_script\":{\"enabled\":true,\"language\":\"javascript\",\ + \"code\":\"return 1\",\"template_type\":\"copilot\"}}"; + v5.execute( + "INSERT INTO providers ( + id, app_type, name, settings_config, meta, is_current, + in_failover_queue, cost_multiplier + ) VALUES ('copilot-meta', 'claude', 'copilot meta', '{}', ?1, 0, 0, '1.0')", + [copilot_meta], + )?; + assert_fixture_accepted_with("v5-meta-identity", &dump_exact_source(&v5)?, |target| { + let conn = super::lock_conn!(target.conn); + let restored: String = conn.query_row( + "SELECT meta FROM providers + WHERE id = 'copilot-meta' AND app_type = 'claude'", + [], + |row| row.get(0), + )?; + assert_eq!(restored, copilot_meta); + Ok(()) + })?; + + for (version, model_id) in [(7, "deepseek-v3.2"), (8, "untrusted-extra")] { + let source = exact_source_connection(version)?; + source.execute( + "INSERT INTO model_pricing ( + model_id, display_name, input_cost_per_million, + output_cost_per_million, cache_read_cost_per_million, + cache_creation_cost_per_million + ) VALUES (?1, 'portable price', '999', '37', '11', '5')", + [model_id], + )?; + assert_fixture_accepted_with( + &format!("v{version}-pricing-identity"), + &dump_exact_source(&source)?, + |target| { + let conn = super::lock_conn!(target.conn); + let restored: (String, String, String, String) = conn.query_row( + "SELECT input_cost_per_million, output_cost_per_million, + cache_read_cost_per_million, + cache_creation_cost_per_million + FROM model_pricing WHERE model_id = ?1", + [model_id], + |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?)), + )?; + assert_eq!( + restored, + ( + "999".to_string(), + "37".to_string(), + "11".to_string(), + "5".to_string() + ) + ); + Ok(()) + }, + )?; + } + + let v10 = exact_source_connection(10)?; + v10.execute( + "INSERT INTO usage_daily_rollups ( + date, app_type, provider_id, model, request_count, success_count, + input_tokens, output_tokens, cache_read_tokens, + cache_creation_tokens, total_cost_usd, avg_latency_ms + ) VALUES ( + '2026-08-01', 'claude', 'legacy', 'legacy-model', + 17, 13, 101, 202, 303, 404, '12.34', 505 + )", + [], + )?; + assert_fixture_accepted_with( + "v10-rollup-total-map", + &dump_exact_source(&v10)?, + |target| { + let conn = super::lock_conn!(target.conn); + let restored: (String, String, i64, i64, i64, i64, String, i64) = conn.query_row( + "SELECT request_model, pricing_model, request_count, + success_count, input_tokens, cache_creation_tokens, + total_cost_usd, avg_latency_ms + FROM usage_daily_rollups + WHERE date = '2026-08-01' AND model = 'legacy-model'", + [], + |row| { + Ok(( + row.get(0)?, + row.get(1)?, + row.get(2)?, + row.get(3)?, + row.get(4)?, + row.get(5)?, + row.get(6)?, + row.get(7)?, + )) + }, + )?; + assert_eq!( + restored, + ( + "".to_string(), + "".to_string(), + 17, + 13, + 101, + 404, + "12.34".to_string(), + 505 + ) + ); + Ok(()) + }, + )?; + + let v13 = exact_source_connection(13)?; + v13.execute( + "INSERT INTO proxy_config ( + app_type, proxy_enabled, listen_address, listen_port, + enable_logging, enabled, auto_failover_enabled, max_retries, + streaming_first_byte_timeout, streaming_idle_timeout, + non_streaming_timeout, circuit_failure_threshold, + circuit_success_threshold, circuit_timeout_seconds, + circuit_error_rate_threshold, circuit_min_requests, + default_cost_multiplier, pricing_model_source, created_at, updated_at + ) VALUES ( + 'claude', 1, '10.13.0.1', 23113, 0, 1, 1, 13, + 131, 132, 133, 14, 15, 136, 0.25, 17, + '1.25', 'request', 'v13-created', 'v13-updated' + )", + [], + )?; + assert_fixture_accepted_with("v13-proxy-total-map", &dump_exact_source(&v13)?, |target| { + let conn = super::lock_conn!(target.conn); + let restored: (i64, String, i64, i64, String, String) = conn.query_row( + "SELECT proxy_enabled, listen_address, max_retries, + live_takeover_active, default_cost_multiplier, + pricing_model_source + FROM proxy_config WHERE app_type = 'claude'", + [], + |row| { + Ok(( + row.get(0)?, + row.get(1)?, + row.get(2)?, + row.get(3)?, + row.get(4)?, + row.get(5)?, + )) + }, + )?; + assert_eq!( + restored, + ( + 1, + "10.13.0.1".to_string(), + 13, + 0, + "1.25".to_string(), + "request".to_string() + ) + ); + let grokbuild: i64 = conn.query_row( + "SELECT COUNT(*) FROM proxy_config WHERE app_type = 'grokbuild'", + [], + |row| row.get(0), + )?; + assert_eq!(grokbuild, 1); + Ok(()) + })?; + + let v15 = exact_source_connection(15)?; + v15.execute( + "INSERT INTO proxy_request_logs ( + request_id, provider_id, app_type, model, request_model, + pricing_model, input_tokens, output_tokens, cache_read_tokens, + cache_creation_tokens, input_token_semantics, input_cost_usd, + output_cost_usd, cache_read_cost_usd, cache_creation_cost_usd, + total_cost_usd, latency_ms, first_token_ms, duration_ms, + status_code, error_message, session_id, provider_type, + is_streaming, cost_multiplier, created_at, data_source + ) VALUES ( + 'untrusted-codex-log', '_codex_session', 'codex', 'legacy', + 'request-legacy', 'price-legacy', 11, 12, 13, 14, 1, + '1', '2', '3', '4', '10', 15, 16, 17, 200, + NULL, 'session-15', 'portable', 0, '1.5', 18, 'codex_session' + )", + [], + )?; + assert_fixture_accepted_with( + "v15-codex-log-identity", + &dump_exact_source(&v15)?, + |target| { + let conn = super::lock_conn!(target.conn); + let restored: (String, i64, i64, String) = conn.query_row( + "SELECT request_model, input_tokens, cache_creation_tokens, + data_source + FROM proxy_request_logs + WHERE request_id = 'untrusted-codex-log'", + [], + |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?)), + )?; + assert_eq!( + restored, + ( + "request-legacy".to_string(), + 11, + 14, + "codex_session".to_string() + ) + ); + Ok(()) + }, + ) +} + +#[test] +fn full_field_provider_sentinel_comes_from_the_executed_pinned_pi_vector() { + let oracle: serde_json::Value = serde_json::from_str(include_str!( + "../../../tests/fixtures/pi/native-oracle/composer-oracle-v1.json" + )) + .expect("parse pinned Pi composer oracle"); + assert_eq!( + oracle["execution"]["piCommit"], "ab366ebe94cacd419d986be454f12b1b9913aaca", + "migration certification must use the repository's executed Pi pin" + ); + let case = oracle["cases"] + .as_array() + .and_then(|cases| { + cases + .iter() + .find(|case| case["id"] == "fractional-cost-and-limits") + }) + .expect("fractional executed Pi vector"); + assert_eq!(case["execution"]["status"], "success"); + + let mut sentinel: serde_json::Value = + serde_json::from_str(&text_sentinel(17, "providers", "settings_config")) + .expect("parse migration Pi sentinel"); + sentinel + .as_object_mut() + .expect("Pi sentinel object") + .remove("migrationVersion"); + assert_eq!( + sentinel, case["input"], + "all Pi-managed fields in the migration fixture must come from an \ + upstream-executed vector rather than a hand-authored approximation" + ); + + let managed: crate::pi_config::model::PiManagedProviderConfig = + serde_json::from_value(case["input"].clone()).expect("decode managed Pi provider"); + crate::pi_config::model::validate_pi_managed_provider(&managed) + .expect("executed Pi input is locally manageable"); + let effective = crate::pi_config::model::effective_pi_model(&managed, "fractional") + .expect("resolve executed Pi model"); + let expected = &case["expected"]["models"][0]; + assert_eq!(effective.api.as_str(), expected["api"].as_str().unwrap()); + assert_eq!(effective.base_url, expected["baseUrl"].as_str().unwrap()); + assert_eq!( + f64::from(effective.context_window), + expected["contextWindow"].as_f64().unwrap() + ); + assert_eq!( + f64::from(effective.max_tokens), + expected["maxTokens"].as_f64().unwrap() + ); + assert_eq!( + effective.cost.rates.input, + expected["cost"]["input"].as_f64().unwrap() + ); + assert_eq!( + effective.cost.rates.output, + expected["cost"]["output"].as_f64().unwrap() + ); + assert_eq!( + effective.cost.rates.cache_read, + expected["cost"]["cacheRead"].as_f64().unwrap() + ); + assert_eq!( + effective.cost.rates.cache_write, + expected["cost"]["cacheWrite"].as_f64().unwrap() + ); +} + #[test] fn migration_classification_and_no_swallowing_guard_cover_v1_through_v17() { let source = include_str!("schema.rs"); - for from in 1..17 { + for from in 1..super::SCHEMA_VERSION { let to = from + 1; let classification_row = format!("| v{from}→v{to}"); assert!( @@ -483,10 +1568,15 @@ fn migration_classification_and_no_swallowing_guard_cover_v1_through_v17() { ".ok()", "if let Ok(", "let _ =", + "create_canonical_table_on_conn(", + "create_canonical_table_as_on_conn(", + "current_canonical_stage(", + "CANONICAL_TABLE_SPECS", ] { assert!( !migration_span.contains(forbidden), - "historical migration span contains forbidden error swallowing: {forbidden}" + "historical migration span contains forbidden swallowing or canonical-factory \ + coupling: {forbidden}" ); } assert!( diff --git a/src-tauri/src/database/migration_source.rs b/src-tauri/src/database/migration_source.rs new file mode 100644 index 000000000..edce3c5e3 --- /dev/null +++ b/src-tauri/src/database/migration_source.rs @@ -0,0 +1,1728 @@ +//! Declarative source schemas for untrusted database migration. +//! +//! The restore pipeline validates one of these versioned shapes before any +//! migration DDL runs. This module is intentionally independent from the +//! current-schema factory: source recognition must never become true because +//! current tables were created into the untrusted database. +//! +//! The supported shape for a version is the single adjudicated restore shape +//! declared below. Historical builds sometimes changed fresh-install DDL +//! without changing `user_version`; those ambiguous variants are intentionally +//! not inferred or repaired here. An input that does not match this authority +//! exactly fails closed and can be handled only by an explicitly versioned +//! future source spec. +//! +//! SQLite storage classes belong to values rather than columns. Accordingly, +//! the declaration spelling (`BOOLEAN` versus `INTEGER`, for example) is not +//! an authority boundary; every populated value is checked with `typeof`, while +//! the exact table and column-name sets are checked independently. + +use super::{Database, SCHEMA_VERSION}; +use crate::error::AppError; +use rusqlite::{Connection, OptionalExtension}; +use std::collections::{BTreeMap, BTreeSet}; + +const LATEST_DECLARED_SOURCE_VERSION: i32 = 17; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum MigrationStorageClass { + Integer, + Real, + Text, +} + +impl MigrationStorageClass { + const fn sqlite_name(self) -> &'static str { + match self { + Self::Integer => "integer", + Self::Real => "real", + Self::Text => "text", + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) struct MigrationSourceColumnSpec { + pub(crate) name: &'static str, + pub(crate) declared_type: &'static str, + pub(crate) storage: MigrationStorageClass, + pub(crate) nullable: bool, +} + +impl MigrationSourceColumnSpec { + const fn new( + name: &'static str, + declared_type: &'static str, + storage: MigrationStorageClass, + nullable: bool, + ) -> Self { + Self { + name, + declared_type, + storage, + nullable, + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) struct MigrationSourceTableSpec { + pub(crate) name: &'static str, + pub(crate) columns: &'static [MigrationSourceColumnSpec], +} + +#[derive(Debug, Clone)] +pub(crate) struct MigrationSourceSpec { + pub(crate) version: i32, + pub(crate) tables: Vec<&'static MigrationSourceTableSpec>, +} + +macro_rules! text { + ($name:literal) => { + MigrationSourceColumnSpec::new($name, "TEXT", MigrationStorageClass::Text, false) + }; +} + +macro_rules! nullable_text { + ($name:literal) => { + MigrationSourceColumnSpec::new($name, "TEXT", MigrationStorageClass::Text, true) + }; +} + +macro_rules! integer { + ($name:literal) => { + MigrationSourceColumnSpec::new($name, "INTEGER", MigrationStorageClass::Integer, false) + }; +} + +macro_rules! nullable_integer { + ($name:literal) => { + MigrationSourceColumnSpec::new($name, "INTEGER", MigrationStorageClass::Integer, true) + }; +} + +macro_rules! boolean { + ($name:literal) => { + MigrationSourceColumnSpec::new($name, "BOOLEAN", MigrationStorageClass::Integer, false) + }; +} + +macro_rules! real { + ($name:literal) => { + MigrationSourceColumnSpec::new($name, "REAL", MigrationStorageClass::Real, false) + }; +} + +macro_rules! table { + ($name:ident, $sql_name:literal, [$($column:expr),* $(,)?]) => { + const $name: MigrationSourceTableSpec = MigrationSourceTableSpec { + name: $sql_name, + columns: &[$($column),*], + }; + }; +} + +table!( + PROVIDERS_V1, + "providers", + [ + text!("id"), + text!("app_type"), + text!("name"), + text!("settings_config"), + nullable_text!("website_url"), + nullable_text!("category"), + nullable_integer!("created_at"), + nullable_integer!("sort_index"), + nullable_text!("notes"), + nullable_text!("icon"), + nullable_text!("icon_color"), + text!("meta"), + boolean!("is_current"), + ] +); + +table!( + PROVIDERS_V2, + "providers", + [ + text!("id"), + text!("app_type"), + text!("name"), + text!("settings_config"), + nullable_text!("website_url"), + nullable_text!("category"), + nullable_integer!("created_at"), + nullable_integer!("sort_index"), + nullable_text!("notes"), + nullable_text!("icon"), + nullable_text!("icon_color"), + text!("meta"), + boolean!("is_current"), + boolean!("in_failover_queue"), + text!("cost_multiplier"), + nullable_text!("limit_daily_usd"), + nullable_text!("limit_monthly_usd"), + nullable_text!("provider_type"), + ] +); + +table!( + PROVIDER_ENDPOINTS_V1, + "provider_endpoints", + [ + integer!("id"), + text!("provider_id"), + text!("app_type"), + text!("url"), + nullable_integer!("added_at"), + ] +); + +table!( + PROVIDER_ENDPOINTS_V17, + "provider_endpoints", + [ + integer!("id"), + text!("provider_id"), + text!("app_type"), + text!("url"), + nullable_integer!("added_at"), + nullable_integer!("last_used"), + ] +); + +table!( + MCP_V1, + "mcp_servers", + [ + text!("id"), + text!("name"), + text!("server_config"), + nullable_text!("description"), + nullable_text!("homepage"), + nullable_text!("docs"), + text!("tags"), + boolean!("enabled_claude"), + boolean!("enabled_codex"), + boolean!("enabled_gemini"), + ] +); + +table!( + MCP_V4, + "mcp_servers", + [ + text!("id"), + text!("name"), + text!("server_config"), + nullable_text!("description"), + nullable_text!("homepage"), + nullable_text!("docs"), + text!("tags"), + boolean!("enabled_claude"), + boolean!("enabled_codex"), + boolean!("enabled_gemini"), + boolean!("enabled_opencode"), + ] +); + +table!( + MCP_V10, + "mcp_servers", + [ + text!("id"), + text!("name"), + text!("server_config"), + nullable_text!("description"), + nullable_text!("homepage"), + nullable_text!("docs"), + text!("tags"), + boolean!("enabled_claude"), + boolean!("enabled_codex"), + boolean!("enabled_gemini"), + boolean!("enabled_opencode"), + boolean!("enabled_hermes"), + ] +); + +table!( + MCP_V15, + "mcp_servers", + [ + text!("id"), + text!("name"), + text!("server_config"), + nullable_text!("description"), + nullable_text!("homepage"), + nullable_text!("docs"), + text!("tags"), + boolean!("enabled_claude"), + boolean!("enabled_codex"), + boolean!("enabled_gemini"), + boolean!("enabled_grokbuild"), + boolean!("enabled_opencode"), + boolean!("enabled_hermes"), + ] +); + +table!( + PROMPTS, + "prompts", + [ + text!("id"), + text!("app_type"), + text!("name"), + text!("content"), + nullable_text!("description"), + boolean!("enabled"), + nullable_integer!("created_at"), + nullable_integer!("updated_at"), + ] +); + +table!( + SKILLS_V1, + "skills", + [ + text!("key"), + boolean!("installed"), + integer!("installed_at"), + ] +); + +table!( + SKILLS_V2, + "skills", + [ + text!("directory"), + text!("app_type"), + boolean!("installed"), + integer!("installed_at"), + ] +); + +table!( + SKILLS_V3, + "skills", + [ + text!("id"), + text!("name"), + nullable_text!("description"), + text!("directory"), + nullable_text!("repo_owner"), + nullable_text!("repo_name"), + nullable_text!("repo_branch"), + nullable_text!("readme_url"), + boolean!("enabled_claude"), + boolean!("enabled_codex"), + boolean!("enabled_gemini"), + integer!("installed_at"), + ] +); + +table!( + SKILLS_V4, + "skills", + [ + text!("id"), + text!("name"), + nullable_text!("description"), + text!("directory"), + nullable_text!("repo_owner"), + nullable_text!("repo_name"), + nullable_text!("repo_branch"), + nullable_text!("readme_url"), + boolean!("enabled_claude"), + boolean!("enabled_codex"), + boolean!("enabled_gemini"), + boolean!("enabled_opencode"), + integer!("installed_at"), + ] +); + +table!( + SKILLS_V7, + "skills", + [ + text!("id"), + text!("name"), + nullable_text!("description"), + text!("directory"), + nullable_text!("repo_owner"), + nullable_text!("repo_name"), + nullable_text!("repo_branch"), + nullable_text!("readme_url"), + boolean!("enabled_claude"), + boolean!("enabled_codex"), + boolean!("enabled_gemini"), + boolean!("enabled_opencode"), + integer!("installed_at"), + nullable_text!("content_hash"), + integer!("updated_at"), + ] +); + +table!( + SKILLS_V10, + "skills", + [ + text!("id"), + text!("name"), + nullable_text!("description"), + text!("directory"), + nullable_text!("repo_owner"), + nullable_text!("repo_name"), + nullable_text!("repo_branch"), + nullable_text!("readme_url"), + boolean!("enabled_claude"), + boolean!("enabled_codex"), + boolean!("enabled_gemini"), + boolean!("enabled_opencode"), + boolean!("enabled_hermes"), + integer!("installed_at"), + nullable_text!("content_hash"), + integer!("updated_at"), + ] +); + +table!( + SKILLS_V15, + "skills", + [ + text!("id"), + text!("name"), + nullable_text!("description"), + text!("directory"), + nullable_text!("repo_owner"), + nullable_text!("repo_name"), + nullable_text!("repo_branch"), + nullable_text!("readme_url"), + boolean!("enabled_claude"), + boolean!("enabled_codex"), + boolean!("enabled_gemini"), + boolean!("enabled_grokbuild"), + boolean!("enabled_opencode"), + boolean!("enabled_hermes"), + integer!("installed_at"), + nullable_text!("content_hash"), + integer!("updated_at"), + ] +); + +table!( + SKILLS_V17, + "skills", + [ + text!("id"), + text!("name"), + nullable_text!("description"), + text!("directory"), + nullable_text!("repo_owner"), + nullable_text!("repo_name"), + nullable_text!("repo_branch"), + nullable_text!("readme_url"), + boolean!("enabled_claude"), + boolean!("enabled_codex"), + boolean!("enabled_gemini"), + boolean!("enabled_grokbuild"), + boolean!("enabled_opencode"), + boolean!("enabled_hermes"), + boolean!("enabled_pi"), + integer!("installed_at"), + nullable_text!("content_hash"), + integer!("updated_at"), + ] +); + +table!( + SKILL_REPOS, + "skill_repos", + [ + text!("owner"), + text!("name"), + text!("branch"), + boolean!("enabled"), + ] +); + +table!( + SETTINGS, + "settings", + [text!("key"), nullable_text!("value")] +); + +// The adjudicated v1 input shape contains the last singleton proxy layout. +// Its fields are all consumed by the typed v1 fan-out mapper. +table!( + PROXY_CONFIG_V1, + "proxy_config", + [ + integer!("id"), + boolean!("proxy_enabled"), + text!("listen_address"), + integer!("listen_port"), + boolean!("enable_logging"), + integer!("max_retries"), + integer!("streaming_first_byte_timeout"), + integer!("streaming_idle_timeout"), + integer!("non_streaming_timeout"), + ] +); + +table!( + CIRCUIT_BREAKER_CONFIG_V1, + "circuit_breaker_config", + [ + integer!("id"), + integer!("failure_threshold"), + integer!("success_threshold"), + integer!("timeout_seconds"), + real!("error_rate_threshold"), + integer!("min_requests"), + ] +); + +table!( + PROXY_CONFIG_V2, + "proxy_config", + [ + text!("app_type"), + boolean!("proxy_enabled"), + text!("listen_address"), + integer!("listen_port"), + boolean!("enable_logging"), + boolean!("enabled"), + boolean!("auto_failover_enabled"), + integer!("max_retries"), + integer!("streaming_first_byte_timeout"), + integer!("streaming_idle_timeout"), + integer!("non_streaming_timeout"), + integer!("circuit_failure_threshold"), + integer!("circuit_success_threshold"), + integer!("circuit_timeout_seconds"), + real!("circuit_error_rate_threshold"), + integer!("circuit_min_requests"), + text!("created_at"), + text!("updated_at"), + ] +); + +table!( + PROXY_CONFIG_V5, + "proxy_config", + [ + text!("app_type"), + boolean!("proxy_enabled"), + text!("listen_address"), + integer!("listen_port"), + boolean!("enable_logging"), + boolean!("enabled"), + boolean!("auto_failover_enabled"), + integer!("max_retries"), + integer!("streaming_first_byte_timeout"), + integer!("streaming_idle_timeout"), + integer!("non_streaming_timeout"), + integer!("circuit_failure_threshold"), + integer!("circuit_success_threshold"), + integer!("circuit_timeout_seconds"), + real!("circuit_error_rate_threshold"), + integer!("circuit_min_requests"), + text!("created_at"), + text!("updated_at"), + text!("default_cost_multiplier"), + text!("pricing_model_source"), + ] +); + +table!( + PROXY_CONFIG_V14, + "proxy_config", + [ + text!("app_type"), + boolean!("proxy_enabled"), + text!("listen_address"), + integer!("listen_port"), + boolean!("enable_logging"), + boolean!("enabled"), + boolean!("auto_failover_enabled"), + integer!("max_retries"), + integer!("streaming_first_byte_timeout"), + integer!("streaming_idle_timeout"), + integer!("non_streaming_timeout"), + integer!("circuit_failure_threshold"), + integer!("circuit_success_threshold"), + integer!("circuit_timeout_seconds"), + real!("circuit_error_rate_threshold"), + integer!("circuit_min_requests"), + text!("created_at"), + text!("updated_at"), + text!("default_cost_multiplier"), + text!("pricing_model_source"), + boolean!("live_takeover_active"), + ] +); + +table!( + PROVIDER_HEALTH, + "provider_health", + [ + text!("provider_id"), + text!("app_type"), + boolean!("is_healthy"), + integer!("consecutive_failures"), + nullable_text!("last_success_at"), + nullable_text!("last_failure_at"), + nullable_text!("last_error"), + text!("updated_at"), + ] +); + +table!( + PROXY_LOG_V2, + "proxy_request_logs", + [ + text!("request_id"), + text!("provider_id"), + text!("app_type"), + text!("model"), + integer!("input_tokens"), + integer!("output_tokens"), + integer!("cache_read_tokens"), + integer!("cache_creation_tokens"), + text!("input_cost_usd"), + text!("output_cost_usd"), + text!("cache_read_cost_usd"), + text!("cache_creation_cost_usd"), + text!("total_cost_usd"), + integer!("latency_ms"), + nullable_integer!("first_token_ms"), + nullable_integer!("duration_ms"), + integer!("status_code"), + nullable_text!("error_message"), + nullable_text!("session_id"), + nullable_text!("provider_type"), + boolean!("is_streaming"), + text!("cost_multiplier"), + integer!("created_at"), + ] +); + +table!( + PROXY_LOG_V5, + "proxy_request_logs", + [ + text!("request_id"), + text!("provider_id"), + text!("app_type"), + text!("model"), + nullable_text!("request_model"), + integer!("input_tokens"), + integer!("output_tokens"), + integer!("cache_read_tokens"), + integer!("cache_creation_tokens"), + text!("input_cost_usd"), + text!("output_cost_usd"), + text!("cache_read_cost_usd"), + text!("cache_creation_cost_usd"), + text!("total_cost_usd"), + integer!("latency_ms"), + nullable_integer!("first_token_ms"), + nullable_integer!("duration_ms"), + integer!("status_code"), + nullable_text!("error_message"), + nullable_text!("session_id"), + nullable_text!("provider_type"), + boolean!("is_streaming"), + text!("cost_multiplier"), + integer!("created_at"), + ] +); + +table!( + PROXY_LOG_V8, + "proxy_request_logs", + [ + text!("request_id"), + text!("provider_id"), + text!("app_type"), + text!("model"), + nullable_text!("request_model"), + integer!("input_tokens"), + integer!("output_tokens"), + integer!("cache_read_tokens"), + integer!("cache_creation_tokens"), + text!("input_cost_usd"), + text!("output_cost_usd"), + text!("cache_read_cost_usd"), + text!("cache_creation_cost_usd"), + text!("total_cost_usd"), + integer!("latency_ms"), + nullable_integer!("first_token_ms"), + nullable_integer!("duration_ms"), + integer!("status_code"), + nullable_text!("error_message"), + nullable_text!("session_id"), + nullable_text!("provider_type"), + boolean!("is_streaming"), + text!("cost_multiplier"), + integer!("created_at"), + text!("data_source"), + ] +); + +table!( + PROXY_LOG_V11, + "proxy_request_logs", + [ + text!("request_id"), + text!("provider_id"), + text!("app_type"), + text!("model"), + nullable_text!("request_model"), + nullable_text!("pricing_model"), + integer!("input_tokens"), + integer!("output_tokens"), + integer!("cache_read_tokens"), + integer!("cache_creation_tokens"), + text!("input_cost_usd"), + text!("output_cost_usd"), + text!("cache_read_cost_usd"), + text!("cache_creation_cost_usd"), + text!("total_cost_usd"), + integer!("latency_ms"), + nullable_integer!("first_token_ms"), + nullable_integer!("duration_ms"), + integer!("status_code"), + nullable_text!("error_message"), + nullable_text!("session_id"), + nullable_text!("provider_type"), + boolean!("is_streaming"), + text!("cost_multiplier"), + integer!("created_at"), + text!("data_source"), + ] +); + +table!( + PROXY_LOG_V13, + "proxy_request_logs", + [ + text!("request_id"), + text!("provider_id"), + text!("app_type"), + text!("model"), + nullable_text!("request_model"), + nullable_text!("pricing_model"), + integer!("input_tokens"), + integer!("output_tokens"), + integer!("cache_read_tokens"), + integer!("cache_creation_tokens"), + integer!("input_token_semantics"), + text!("input_cost_usd"), + text!("output_cost_usd"), + text!("cache_read_cost_usd"), + text!("cache_creation_cost_usd"), + text!("total_cost_usd"), + integer!("latency_ms"), + nullable_integer!("first_token_ms"), + nullable_integer!("duration_ms"), + integer!("status_code"), + nullable_text!("error_message"), + nullable_text!("session_id"), + nullable_text!("provider_type"), + boolean!("is_streaming"), + text!("cost_multiplier"), + integer!("created_at"), + text!("data_source"), + ] +); + +table!( + MODEL_PRICING, + "model_pricing", + [ + text!("model_id"), + text!("display_name"), + text!("input_cost_per_million"), + text!("output_cost_per_million"), + text!("cache_read_cost_per_million"), + text!("cache_creation_cost_per_million"), + ] +); + +table!( + STREAM_CHECK_LOGS, + "stream_check_logs", + [ + integer!("id"), + text!("provider_id"), + text!("provider_name"), + text!("app_type"), + text!("status"), + boolean!("success"), + text!("message"), + nullable_integer!("response_time_ms"), + nullable_integer!("http_status"), + nullable_text!("model_used"), + nullable_integer!("retry_count"), + integer!("tested_at"), + ] +); + +table!( + PROXY_LIVE_BACKUP, + "proxy_live_backup", + [ + text!("app_type"), + text!("original_config"), + text!("backed_up_at"), + ] +); + +table!( + USAGE_ROLLUPS_V6, + "usage_daily_rollups", + [ + text!("date"), + text!("app_type"), + text!("provider_id"), + text!("model"), + integer!("request_count"), + integer!("success_count"), + integer!("input_tokens"), + integer!("output_tokens"), + integer!("cache_read_tokens"), + integer!("cache_creation_tokens"), + text!("total_cost_usd"), + integer!("avg_latency_ms"), + ] +); + +table!( + USAGE_ROLLUPS_V11, + "usage_daily_rollups", + [ + text!("date"), + text!("app_type"), + text!("provider_id"), + text!("model"), + text!("request_model"), + text!("pricing_model"), + integer!("request_count"), + integer!("success_count"), + integer!("input_tokens"), + integer!("output_tokens"), + integer!("cache_read_tokens"), + integer!("cache_creation_tokens"), + text!("total_cost_usd"), + integer!("avg_latency_ms"), + ] +); + +table!( + USAGE_ROLLUPS_V13, + "usage_daily_rollups", + [ + text!("date"), + text!("app_type"), + text!("provider_id"), + text!("model"), + text!("request_model"), + text!("pricing_model"), + integer!("request_count"), + integer!("success_count"), + integer!("input_tokens"), + integer!("output_tokens"), + integer!("cache_read_tokens"), + integer!("cache_creation_tokens"), + integer!("input_token_semantics"), + text!("total_cost_usd"), + integer!("avg_latency_ms"), + ] +); + +table!( + SESSION_LOG_SYNC, + "session_log_sync", + [ + text!("file_path"), + integer!("last_modified"), + integer!("last_line_offset"), + integer!("last_synced_at"), + ] +); + +table!( + PROFILES, + "profiles", + [ + text!("id"), + text!("name"), + text!("payload"), + nullable_integer!("sort_order"), + nullable_integer!("created_at"), + nullable_integer!("updated_at"), + ] +); + +table!( + PI_PROVIDER_PROJECTIONS, + "pi_provider_projections", + [ + text!("provider_id"), + text!("provider_key"), + integer!("created_at"), + integer!("updated_at"), + ] +); + +table!( + SKILL_DEPLOYMENTS, + "skill_deployments", + [ + text!("app_type"), + text!("skill_id"), + text!("destination"), + text!("destination_key"), + text!("method"), + text!("source_identity"), + nullable_text!("deployed_digest"), + integer!("created_at"), + integer!("updated_at"), + ] +); + +fn replace_table( + tables: &mut Vec<&'static MigrationSourceTableSpec>, + replacement: &'static MigrationSourceTableSpec, +) { + if let Some(existing) = tables + .iter_mut() + .find(|table| table.name == replacement.name) + { + *existing = replacement; + } else { + tables.push(replacement); + } +} + +fn remove_table(tables: &mut Vec<&'static MigrationSourceTableSpec>, name: &str) { + tables.retain(|table| table.name != name); +} + +pub(crate) fn migration_source_spec(version: i32) -> Result { + if SCHEMA_VERSION != LATEST_DECLARED_SOURCE_VERSION { + return Err(AppError::Config(format!( + "schema version {SCHEMA_VERSION} has no reviewed MigrationSourceSpec; \ + latest declared source version is {LATEST_DECLARED_SOURCE_VERSION}" + ))); + } + if !(1..=LATEST_DECLARED_SOURCE_VERSION).contains(&version) { + return Err(AppError::InvalidInput(format!( + "unsupported restore user_version {version}; supported versions are \ + 1..={LATEST_DECLARED_SOURCE_VERSION}" + ))); + } + + let mut tables = vec![ + &PROVIDERS_V1, + &PROVIDER_ENDPOINTS_V1, + &MCP_V1, + &PROMPTS, + &SKILLS_V1, + &SKILL_REPOS, + &SETTINGS, + &PROXY_CONFIG_V1, + &CIRCUIT_BREAKER_CONFIG_V1, + ]; + + if version >= 2 { + replace_table(&mut tables, &PROVIDERS_V2); + replace_table(&mut tables, &SKILLS_V2); + replace_table(&mut tables, &PROXY_CONFIG_V2); + remove_table(&mut tables, CIRCUIT_BREAKER_CONFIG_V1.name); + tables.extend([ + &PROVIDER_HEALTH, + &PROXY_LOG_V2, + &MODEL_PRICING, + &STREAM_CHECK_LOGS, + &PROXY_LIVE_BACKUP, + ]); + } + if version >= 3 { + replace_table(&mut tables, &SKILLS_V3); + } + if version >= 4 { + replace_table(&mut tables, &MCP_V4); + replace_table(&mut tables, &SKILLS_V4); + } + if version >= 5 { + replace_table(&mut tables, &PROXY_CONFIG_V5); + replace_table(&mut tables, &PROXY_LOG_V5); + } + if version >= 6 { + tables.push(&USAGE_ROLLUPS_V6); + } + if version >= 7 { + replace_table(&mut tables, &SKILLS_V7); + } + if version >= 8 { + replace_table(&mut tables, &PROXY_LOG_V8); + tables.push(&SESSION_LOG_SYNC); + } + if version >= 10 { + replace_table(&mut tables, &MCP_V10); + replace_table(&mut tables, &SKILLS_V10); + } + if version >= 11 { + replace_table(&mut tables, &PROXY_LOG_V11); + replace_table(&mut tables, &USAGE_ROLLUPS_V11); + } + if version >= 12 { + tables.push(&PROFILES); + } + if version >= 13 { + replace_table(&mut tables, &PROXY_LOG_V13); + replace_table(&mut tables, &USAGE_ROLLUPS_V13); + } + if version >= 14 { + replace_table(&mut tables, &PROXY_CONFIG_V14); + } + if version >= 15 { + replace_table(&mut tables, &MCP_V15); + replace_table(&mut tables, &SKILLS_V15); + } + if version >= 17 { + replace_table(&mut tables, &PROVIDER_ENDPOINTS_V17); + replace_table(&mut tables, &SKILLS_V17); + tables.extend([&PI_PROVIDER_PROJECTIONS, &SKILL_DEPLOYMENTS]); + } + + tables.sort_by_key(|table| table.name); + Ok(MigrationSourceSpec { version, tables }) +} + +/// Materialize an exact declared source shape for migration certification. +/// +/// This is deliberately a source-spec fixture factory, not an alias for the +/// canonical current-schema factory. Keeping it here lets all migration tests +/// exercise the same versioned declarations that guard production restores. +#[cfg(test)] +pub(crate) fn exact_migration_source_for_test(version: i32) -> Result { + let spec = migration_source_spec(version)?; + let connection = Connection::open_in_memory()?; + for table in &spec.tables { + let columns = table + .columns + .iter() + .map(|column| { + format!( + "{} {}", + quoted_identifier(column.name), + column.declared_type + ) + }) + .collect::>() + .join(", "); + connection.execute( + &format!("CREATE TABLE {} ({columns})", quoted_identifier(table.name)), + [], + )?; + } + Database::set_user_version(&connection, version)?; + Ok(connection) +} + +/// Pi-managed provider input executed by the pinned upstream composer oracle, +/// plus one unknown field that makes each migration-version sentinel distinct. +#[cfg(test)] +pub(crate) fn pinned_pi_provider_settings_for_test(version: i32) -> String { + format!( + concat!( + "{{\"api\":\"google-generative-ai\",", + "\"baseUrl\":\"https://fractional.example/v1\",", + "\"apiKey\":\"literal-secret\",", + "\"models\":[{{\"id\":\"fractional\",", + "\"contextWindow\":128000.5,\"maxTokens\":16384.25,", + "\"cost\":{{\"input\":0.125,\"output\":0.375,", + "\"cacheRead\":0.0625,\"cacheWrite\":0.1875}}}}],", + "\"migrationVersion\":{version}}}" + ), + version = version + ) +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) struct MigrationTargetColumn { + pub(crate) table: &'static str, + pub(crate) column: &'static str, + pub(crate) storage: MigrationStorageClass, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum MigrationColumnDisposition { + Derived(&'static [MigrationTargetColumn]), + FanOutDerived { + targets: &'static [MigrationTargetColumn], + rule: &'static str, + }, + /// One source value is copied to the same-named column on every row + /// produced by a declared fan-out. + FanOutIdentity { + target_table: &'static str, + rule: &'static str, + }, + /// Every source value remains in the same-named identity column, while + /// rows selected by `rule` additionally contribute to `derived`. + IdentityAndDerived { + identity_table: &'static str, + derived: &'static [MigrationTargetColumn], + rule: &'static str, + }, + Structural(&'static str), +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) struct MigrationColumnMapping { + pub(crate) source_table: &'static str, + pub(crate) source_column: &'static str, + pub(crate) disposition: MigrationColumnDisposition, +} + +const V1_KEY_TARGETS: &[MigrationTargetColumn] = &[ + MigrationTargetColumn { + table: "skills", + column: "directory", + storage: MigrationStorageClass::Text, + }, + MigrationTargetColumn { + table: "skills", + column: "app_type", + storage: MigrationStorageClass::Text, + }, +]; +const V1_OWNERSHIP_KEY_TARGETS: &[MigrationTargetColumn] = &[ + MigrationTargetColumn { + table: "proxy_config", + column: "app_type", + storage: MigrationStorageClass::Text, + }, + MigrationTargetColumn { + table: "proxy_config", + column: "enabled", + storage: MigrationStorageClass::Integer, + }, + MigrationTargetColumn { + table: "proxy_config", + column: "auto_failover_enabled", + storage: MigrationStorageClass::Integer, + }, +]; +const V1_OWNERSHIP_VALUE_TARGETS: &[MigrationTargetColumn] = &[ + MigrationTargetColumn { + table: "proxy_config", + column: "enabled", + storage: MigrationStorageClass::Integer, + }, + MigrationTargetColumn { + table: "proxy_config", + column: "auto_failover_enabled", + storage: MigrationStorageClass::Integer, + }, +]; +const V1_CIRCUIT_FAILURE_TARGET: &[MigrationTargetColumn] = &[MigrationTargetColumn { + table: "proxy_config", + column: "circuit_failure_threshold", + storage: MigrationStorageClass::Integer, +}]; +const V1_CIRCUIT_SUCCESS_TARGET: &[MigrationTargetColumn] = &[MigrationTargetColumn { + table: "proxy_config", + column: "circuit_success_threshold", + storage: MigrationStorageClass::Integer, +}]; +const V1_CIRCUIT_TIMEOUT_TARGET: &[MigrationTargetColumn] = &[MigrationTargetColumn { + table: "proxy_config", + column: "circuit_timeout_seconds", + storage: MigrationStorageClass::Integer, +}]; +const V1_CIRCUIT_RATE_TARGET: &[MigrationTargetColumn] = &[MigrationTargetColumn { + table: "proxy_config", + column: "circuit_error_rate_threshold", + storage: MigrationStorageClass::Real, +}]; +const V1_CIRCUIT_REQUESTS_TARGET: &[MigrationTargetColumn] = &[MigrationTargetColumn { + table: "proxy_config", + column: "circuit_min_requests", + storage: MigrationStorageClass::Integer, +}]; +const V2_APP_TARGETS: &[MigrationTargetColumn] = &[ + MigrationTargetColumn { + table: "skills", + column: "id", + storage: MigrationStorageClass::Text, + }, + MigrationTargetColumn { + table: "skills", + column: "enabled_claude", + storage: MigrationStorageClass::Integer, + }, + MigrationTargetColumn { + table: "skills", + column: "enabled_codex", + storage: MigrationStorageClass::Integer, + }, + MigrationTargetColumn { + table: "skills", + column: "enabled_gemini", + storage: MigrationStorageClass::Integer, + }, +]; +const V2_DIRECTORY_TARGETS: &[MigrationTargetColumn] = &[ + MigrationTargetColumn { + table: "skills", + column: "id", + storage: MigrationStorageClass::Text, + }, + MigrationTargetColumn { + table: "skills", + column: "name", + storage: MigrationStorageClass::Text, + }, + MigrationTargetColumn { + table: "skills", + column: "directory", + storage: MigrationStorageClass::Text, + }, +]; + +const V1_MAPPINGS: &[MigrationColumnMapping] = &[ + MigrationColumnMapping { + source_table: "proxy_config", + source_column: "id", + disposition: MigrationColumnDisposition::Structural( + "the singleton id=1 is the fan-out discriminator", + ), + }, + MigrationColumnMapping { + source_table: "circuit_breaker_config", + source_column: "id", + disposition: MigrationColumnDisposition::Structural( + "the singleton id=1 identifies the circuit source row", + ), + }, + MigrationColumnMapping { + source_table: "circuit_breaker_config", + source_column: "failure_threshold", + disposition: MigrationColumnDisposition::FanOutDerived { + targets: V1_CIRCUIT_FAILURE_TARGET, + rule: "copy the singleton value to the three app rows owned by v2", + }, + }, + MigrationColumnMapping { + source_table: "circuit_breaker_config", + source_column: "success_threshold", + disposition: MigrationColumnDisposition::FanOutDerived { + targets: V1_CIRCUIT_SUCCESS_TARGET, + rule: "copy the singleton value to the three app rows owned by v2", + }, + }, + MigrationColumnMapping { + source_table: "circuit_breaker_config", + source_column: "timeout_seconds", + disposition: MigrationColumnDisposition::FanOutDerived { + targets: V1_CIRCUIT_TIMEOUT_TARGET, + rule: "copy the singleton value to the three app rows owned by v2", + }, + }, + MigrationColumnMapping { + source_table: "circuit_breaker_config", + source_column: "error_rate_threshold", + disposition: MigrationColumnDisposition::FanOutDerived { + targets: V1_CIRCUIT_RATE_TARGET, + rule: "copy the singleton value to the three app rows owned by v2", + }, + }, + MigrationColumnMapping { + source_table: "circuit_breaker_config", + source_column: "min_requests", + disposition: MigrationColumnDisposition::FanOutDerived { + targets: V1_CIRCUIT_REQUESTS_TARGET, + rule: "copy the singleton value to the three app rows owned by v2", + }, + }, + MigrationColumnMapping { + source_table: "skills", + source_column: "key", + disposition: MigrationColumnDisposition::Derived(V1_KEY_TARGETS), + }, + MigrationColumnMapping { + source_table: "settings", + source_column: "key", + disposition: MigrationColumnDisposition::IdentityAndDerived { + identity_table: "settings", + derived: V1_OWNERSHIP_KEY_TARGETS, + rule: "all keys survive; six exact ownership keys also select app and destination flag", + }, + }, + MigrationColumnMapping { + source_table: "settings", + source_column: "value", + disposition: MigrationColumnDisposition::IdentityAndDerived { + identity_table: "settings", + derived: V1_OWNERSHIP_VALUE_TARGETS, + rule: "all values survive; six exact ownership values also decode as booleans", + }, + }, + MigrationColumnMapping { + source_table: "proxy_config", + source_column: "proxy_enabled", + disposition: MigrationColumnDisposition::FanOutIdentity { + target_table: "proxy_config", + rule: "copy the singleton value to the three app rows owned by v2", + }, + }, + MigrationColumnMapping { + source_table: "proxy_config", + source_column: "listen_address", + disposition: MigrationColumnDisposition::FanOutIdentity { + target_table: "proxy_config", + rule: "copy the singleton value to the three app rows owned by v2", + }, + }, + MigrationColumnMapping { + source_table: "proxy_config", + source_column: "listen_port", + disposition: MigrationColumnDisposition::FanOutIdentity { + target_table: "proxy_config", + rule: "copy the singleton value to the three app rows owned by v2", + }, + }, + MigrationColumnMapping { + source_table: "proxy_config", + source_column: "enable_logging", + disposition: MigrationColumnDisposition::FanOutIdentity { + target_table: "proxy_config", + rule: "copy the singleton value to the three app rows owned by v2", + }, + }, + MigrationColumnMapping { + source_table: "proxy_config", + source_column: "max_retries", + disposition: MigrationColumnDisposition::FanOutIdentity { + target_table: "proxy_config", + rule: "copy the singleton value to the three app rows owned by v2", + }, + }, + MigrationColumnMapping { + source_table: "proxy_config", + source_column: "streaming_first_byte_timeout", + disposition: MigrationColumnDisposition::FanOutIdentity { + target_table: "proxy_config", + rule: "copy the singleton value to the three app rows owned by v2", + }, + }, + MigrationColumnMapping { + source_table: "proxy_config", + source_column: "streaming_idle_timeout", + disposition: MigrationColumnDisposition::FanOutIdentity { + target_table: "proxy_config", + rule: "copy the singleton value to the three app rows owned by v2", + }, + }, + MigrationColumnMapping { + source_table: "proxy_config", + source_column: "non_streaming_timeout", + disposition: MigrationColumnDisposition::FanOutIdentity { + target_table: "proxy_config", + rule: "copy the singleton value to the three app rows owned by v2", + }, + }, +]; + +const V2_MAPPINGS: &[MigrationColumnMapping] = &[ + MigrationColumnMapping { + source_table: "skills", + source_column: "directory", + disposition: MigrationColumnDisposition::Derived(V2_DIRECTORY_TARGETS), + }, + MigrationColumnMapping { + source_table: "skills", + source_column: "app_type", + disposition: MigrationColumnDisposition::Derived(V2_APP_TARGETS), + }, + MigrationColumnMapping { + source_table: "skills", + source_column: "installed", + disposition: MigrationColumnDisposition::Derived(V2_APP_TARGETS), + }, +]; + +fn explicit_mappings(version: i32) -> &'static [MigrationColumnMapping] { + match version { + 1 => V1_MAPPINGS, + 2 => V2_MAPPINGS, + _ => &[], + } +} + +fn validate_mapping_target( + version: i32, + mapping: &MigrationColumnMapping, + target_tables: &BTreeMap<&'static str, &'static MigrationSourceTableSpec>, + mapped_target: &MigrationTargetColumn, +) -> Result<(), AppError> { + let target_table = target_tables.get(mapped_target.table).ok_or_else(|| { + AppError::Config(format!( + "v{version} mapping targets missing table {}", + mapped_target.table + )) + })?; + let target_column = target_table + .columns + .iter() + .find(|column| column.name == mapped_target.column) + .ok_or_else(|| { + AppError::Config(format!( + "v{version} mapping targets missing column {}.{}", + mapped_target.table, mapped_target.column + )) + })?; + if target_column.storage != mapped_target.storage { + return Err(AppError::Config(format!( + "v{version} mapping for {}.{} targets {}.{} with storage {:?}, expected {:?}", + mapping.source_table, + mapping.source_column, + mapped_target.table, + mapped_target.column, + target_column.storage, + mapped_target.storage + ))); + } + Ok(()) +} + +fn validate_identity_mapping_target( + version: i32, + mapping: &MigrationColumnMapping, + source_column: &MigrationSourceColumnSpec, + target_tables: &BTreeMap<&'static str, &'static MigrationSourceTableSpec>, + target_table_name: &str, +) -> Result<(), AppError> { + let target_table = target_tables.get(target_table_name).ok_or_else(|| { + AppError::Config(format!( + "v{version} mapping targets missing identity table {target_table_name}" + )) + })?; + let target_column = target_table + .columns + .iter() + .find(|column| column.name == mapping.source_column) + .ok_or_else(|| { + AppError::Config(format!( + "v{version} mapping targets missing identity column {}.{}", + target_table_name, mapping.source_column + )) + })?; + if target_column.storage != source_column.storage { + return Err(AppError::Config(format!( + "v{version} identity mapping target {}.{} has storage {:?}, expected {:?}", + target_table_name, mapping.source_column, target_column.storage, source_column.storage + ))); + } + Ok(()) +} + +pub(crate) fn validate_migration_mapping_completeness(version: i32) -> Result<(), AppError> { + if version >= SCHEMA_VERSION { + return Ok(()); + } + let source = migration_source_spec(version)?; + let target = migration_source_spec(version + 1)?; + let target_tables: BTreeMap<_, _> = target + .tables + .iter() + .map(|table| (table.name, *table)) + .collect(); + let mappings = explicit_mappings(version); + let mut mapped_sources = BTreeSet::new(); + + for mapping in mappings { + if !mapped_sources.insert((mapping.source_table, mapping.source_column)) { + return Err(AppError::Config(format!( + "v{version} declares duplicate mappings for {}.{}", + mapping.source_table, mapping.source_column + ))); + } + let source_table = source + .tables + .iter() + .find(|table| table.name == mapping.source_table) + .ok_or_else(|| { + AppError::Config(format!( + "v{version} mapping references missing source table {}", + mapping.source_table + )) + })?; + let source_column = source_table + .columns + .iter() + .find(|column| column.name == mapping.source_column) + .ok_or_else(|| { + AppError::Config(format!( + "v{version} mapping references missing source column {}.{}", + mapping.source_table, mapping.source_column + )) + })?; + let validate_targets = |targets: &[MigrationTargetColumn]| -> Result<(), AppError> { + if targets.is_empty() { + return Err(AppError::Config(format!( + "v{version} mapping for {}.{} has no derived target", + mapping.source_table, mapping.source_column + ))); + } + for target in targets { + validate_mapping_target(version, mapping, &target_tables, target)?; + } + Ok(()) + }; + match mapping.disposition { + MigrationColumnDisposition::Derived(targets) => validate_targets(targets)?, + MigrationColumnDisposition::FanOutDerived { targets, rule } => { + if rule.is_empty() { + return Err(AppError::Config(format!( + "v{version} fan-out mapping for {}.{} has no rule", + mapping.source_table, mapping.source_column + ))); + } + validate_targets(targets)?; + } + MigrationColumnDisposition::FanOutIdentity { target_table, rule } => { + if rule.is_empty() { + return Err(AppError::Config(format!( + "v{version} fan-out mapping for {}.{} has no rule", + mapping.source_table, mapping.source_column + ))); + } + validate_identity_mapping_target( + version, + mapping, + source_column, + &target_tables, + target_table, + )?; + } + MigrationColumnDisposition::IdentityAndDerived { + identity_table, + derived, + rule, + } => { + if rule.is_empty() { + return Err(AppError::Config(format!( + "v{version} identity-and-derived mapping for {}.{} has no rule", + mapping.source_table, mapping.source_column + ))); + } + validate_identity_mapping_target( + version, + mapping, + source_column, + &target_tables, + identity_table, + )?; + validate_targets(derived)?; + } + MigrationColumnDisposition::Structural(rule) => { + if rule.is_empty() { + return Err(AppError::Config(format!( + "v{version} structural mapping for {}.{} has no rule", + mapping.source_table, mapping.source_column + ))); + } + } + } + } + + for source_table in &source.tables { + for source_column in source_table.columns { + let explicit = mappings.iter().find(|mapping| { + mapping.source_table == source_table.name + && mapping.source_column == source_column.name + }); + if explicit.is_some() { + continue; + } + let Some(target_table) = target_tables.get(source_table.name) else { + return Err(AppError::Config(format!( + "v{version}->v{} does not consume table {} column {}", + version + 1, + source_table.name, + source_column.name + ))); + }; + let Some(target_column) = target_table + .columns + .iter() + .find(|column| column.name == source_column.name) + else { + return Err(AppError::Config(format!( + "v{version}->v{} does not consume column {}.{}", + version + 1, + source_table.name, + source_column.name + ))); + }; + if target_column.storage != source_column.storage { + return Err(AppError::Config(format!( + "v{version}->v{} changes storage for identity column {}.{}", + version + 1, + source_table.name, + source_column.name + ))); + } + } + } + Ok(()) +} + +fn quoted_identifier(identifier: &str) -> String { + format!("\"{}\"", identifier.replace('"', "\"\"")) +} + +impl Database { + pub(crate) fn validate_untrusted_migration_source(conn: &Connection) -> Result { + let version = Self::get_user_version(conn)?; + let spec = migration_source_spec(version)?; + for mapped_version in 1..SCHEMA_VERSION { + validate_migration_mapping_completeness(mapped_version)?; + } + Self::validate_migration_source_spec(conn, &spec)?; + Ok(version) + } + + pub(crate) fn validate_migration_source_version( + conn: &Connection, + version: i32, + ) -> Result<(), AppError> { + let spec = migration_source_spec(version)?; + Self::validate_migration_source_spec(conn, &spec) + } + + fn validate_migration_source_spec( + conn: &Connection, + spec: &MigrationSourceSpec, + ) -> Result<(), AppError> { + let observed_tables = conn + .prepare( + "SELECT name FROM sqlite_schema + WHERE type = 'table' AND name NOT LIKE 'sqlite_%' + ORDER BY name", + ) + .and_then(|mut statement| { + statement + .query_map([], |row| row.get::<_, String>(0))? + .collect::, _>>() + }) + .map_err(|error| { + AppError::InvalidInput(format!( + "inspect untrusted v{} table set: {error}", + spec.version + )) + })?; + let expected_tables: Vec<_> = spec + .tables + .iter() + .map(|table| table.name.to_string()) + .collect(); + if observed_tables != expected_tables { + let observed: BTreeSet<_> = observed_tables.iter().cloned().collect(); + let expected: BTreeSet<_> = expected_tables.iter().cloned().collect(); + let missing: Vec<_> = expected.difference(&observed).cloned().collect(); + let extra: Vec<_> = observed.difference(&expected).cloned().collect(); + return Err(AppError::InvalidInput(format!( + "untrusted v{} source table set mismatch; missing={missing:?}, extra={extra:?}", + spec.version + ))); + } + + for table in &spec.tables { + let pragma = format!("PRAGMA table_info({})", quoted_identifier(table.name)); + let observed_columns = conn + .prepare(&pragma) + .and_then(|mut statement| { + statement + .query_map([], |row| row.get::<_, String>(1))? + .collect::, _>>() + }) + .map_err(|error| { + AppError::InvalidInput(format!( + "inspect untrusted v{} table {} columns: {error}", + spec.version, table.name + )) + })?; + let observed_by_name: BTreeSet<_> = observed_columns.iter().cloned().collect(); + let expected_by_name: BTreeSet<_> = table + .columns + .iter() + .map(|column| column.name.to_string()) + .collect(); + if observed_by_name.len() != observed_columns.len() + || observed_by_name != expected_by_name + { + let missing: Vec<_> = expected_by_name + .difference(&observed_by_name) + .cloned() + .collect(); + let extra: Vec<_> = observed_by_name + .difference(&expected_by_name) + .cloned() + .collect(); + return Err(AppError::InvalidInput(format!( + "untrusted v{} source column set mismatch for {}; missing={missing:?}, extra={extra:?}", + spec.version, table.name + ))); + } + + for column in table.columns { + let table_name = quoted_identifier(table.name); + let column_name = quoted_identifier(column.name); + let mismatch_predicate = if column.nullable { + format!( + "typeof({column_name}) NOT IN ('null', '{}')", + column.storage.sqlite_name() + ) + } else { + format!( + "typeof({column_name}) <> '{}'", + column.storage.sqlite_name() + ) + }; + let mismatch_sql = format!( + "SELECT typeof({column_name}) FROM {table_name} + WHERE {mismatch_predicate} LIMIT 1" + ); + let mismatch = conn + .query_row(&mismatch_sql, [], |row| row.get::<_, String>(0)) + .optional() + .map_err(|error| { + AppError::InvalidInput(format!( + "validate untrusted v{} storage for {}.{}: {error}", + spec.version, table.name, column.name + )) + })?; + if let Some(observed_storage) = mismatch { + return Err(AppError::InvalidInput(format!( + "untrusted v{} source storage mismatch for {}.{}: expected {}{}, got {}", + spec.version, + table.name, + column.name, + column.storage.sqlite_name(), + if column.nullable { " or null" } else { "" }, + observed_storage + ))); + } + } + } + Ok(()) + } +} diff --git a/src-tauri/src/database/mod.rs b/src-tauri/src/database/mod.rs index b156c0ff7..36962a620 100644 --- a/src-tauri/src/database/mod.rs +++ b/src-tauri/src/database/mod.rs @@ -30,6 +30,7 @@ mod backup_restore_certification; mod backup_restore_certification_ext; mod dao; mod migration; +mod migration_source; mod schema; #[cfg(test)] diff --git a/src-tauri/src/database/schema.rs b/src-tauri/src/database/schema.rs index ea1c61c9f..e4f3c9bd8 100644 --- a/src-tauri/src/database/schema.rs +++ b/src-tauri/src/database/schema.rs @@ -26,50 +26,52 @@ impl CanonicalStage { } } -/// Selects whether schema migrations may repair locally trusted historical -/// data or must fail closed on an input that would require reconciliation. +/// Selects the trusted local-upgrade path or the construction-only untrusted +/// restore path. /// /// # Historical migration classification (blind-review authority) /// -/// “Pure shape” means the migration has a deterministic version-defined -/// mapping and never chooses a value because an input read failed. A declared -/// column default or an exact version sentinel belongs to that mapping. -/// “Repair/synthesis” means the old implementation guessed, discarded, -/// reconciled, or regenerated portable data. Under `UntrustedRestore`, that -/// branch may only prove that the postcondition already holds; if it would -/// change or invent portable data, it returns a structured `InvalidInput`. +/// Before this table is consulted, the untrusted database must exactly match +/// the `MigrationSourceSpec` for its declared `user_version`: table set, +/// column set, and every populated SQLite storage class. No current-schema +/// table is created during recognition. Each step is then checked mechanically +/// by `validate_migration_mapping_completeness`: every source column is either +/// an identity mapping with the same storage class or an explicit typed +/// disposition. The complete target source-spec is revalidated after every +/// step. Only after the chain reaches v17 may a separately constructed +/// canonical stage be created and populated. /// -/// | Step | Class | `UntrustedRestore` decision and basis | +/// “Total shape map” means all source values are preserved under that declared +/// mapping and all added values are version-owned structural sentinels. +/// “Typed transform” means rows are decoded, value-domain checked, and mapped +/// deterministically; malformed or ambiguous input returns structured +/// `InvalidInput`. Local repair behavior is never reachable from +/// `UntrustedRestore`. +/// +/// | Step | Class | `UntrustedRestore` construction and basis | /// | --- | --- | --- | -/// | v1→v2 column/table/index additions | Pure shape | Exact DDL; every SQLite error propagates. | -/// | v1→v2 proxy/settings/circuit conversion | Repair/synthesis | Requires the authoritative `id=1` rows and decodes every field; missing/malformed data aborts instead of selecting defaults. | -/// | v1→v2 skill-key conversion | Repair/synthesis | Prefixed keys map exactly; the legacy “missing prefix means claude” fallback aborts. | -/// | v1→v2 model-pricing replacement | Repair/synthesis | Seed-owned input must be empty or already equal the canonical seed; otherwise reseeding aborts. | -/// | v2→v3 skill SSOT rebuild | Repair/synthesis | Empty old storage is a shape rebuild; discarding non-empty rows for a later filesystem scan aborts. | -/// | v3→v4 | Pure shape | Adds OpenCode enablement columns with version-defined false sentinels. | -/// | v4→v5 | Pure shape | Adds billing/request-model columns with version-defined sentinels. | -/// | v5→v6 rollup DDL | Pure shape | Exact table creation. | -/// | v5→v6 provider-meta normalization | Repair/synthesis | Invalid JSON and `copilot` values requiring normalization abort; valid already-normalized JSON is preserved. | -/// | v6→v7 | Pure shape | Adds skill hash/timestamp columns with version-defined sentinels. | -/// | v7→v8 log/session DDL | Pure shape | Exact column/table/index creation. | -/// | v7→v8 pricing correction | Repair/synthesis | Empty/current-canonical pricing is a no-op; otherwise affected rows must already contain the declared corrected tuple or the overwrite aborts. | -/// | v8→v9 pricing reseed | Repair/synthesis | Seed-owned input must be empty or equal the canonical seed; otherwise replacement aborts. | -/// | v9→v10 | Pure shape | Adds Hermes enablement columns with version-defined false sentinels. | -/// | v10→v11 rollup rebuild | Repair/synthesis | Empty storage may be reshaped; non-empty rows whose request/pricing model would be synthesized as `''` abort. | -/// | v11→v12 | Pure shape | Creates the profiles table without rewriting rows. | -/// | v12→v13 | Pure shape | Adds explicit “unknown” token-semantics sentinel columns. | -/// | v13→v14 proxy rebuild | Repair/synthesis | A non-empty source requires every column, its declared storage class, and an existing Grok Build row; empty-table shape conversion is allowed, while fallback values for rows or row synthesis abort. | -/// | v14→v15 | Pure shape | Adds Grok Build enablement columns with version-defined false sentinels. | -/// | v15→v16 Codex reset | Repair/synthesis | Any runtime row that the reset could discard aborts; an empty runtime set is a no-op. | -/// | v16→v17 endpoint rebuild/ledger DDL | Pure shape + repair/synthesis | Canonical rebuild and empty device-ledger creation are exact; duplicate endpoint reconciliation aborts and is never executed. | +/// | v1→v2 | Typed transform + total shape map | The singleton proxy and circuit rows require `id=1`; every field fans out unchanged to the three app rows owned by v2, six ownership settings remain byte-preserved while also producing explicit app flags, and prefixed skill keys map to typed `(directory, app_type)` rows. Missing/malformed rows, unsupported keys, booleans, or numeric domains abort. All settings and identity columns survive; new timestamps use a fixed structural sentinel and v2-owned empty tables are created without canonical seed data. | +/// | v2→v3 | Typed transform | Every skill row decodes as `(directory, app_type, installed, installed_at)` and deterministically produces id/name/directory/app enablement; empty directories, unsupported apps, invalid booleans, or collisions abort. No row is deferred to a filesystem rescan. | +/// | v3→v4 | Total shape map | All source columns are identity-mapped; OpenCode flags are version-owned false sentinels. | +/// | v4→v5 | Total shape map | All source columns are identity-mapped; request-model and proxy pricing columns use declared version sentinels. | +/// | v5→v6 | Total shape map + typed validation | Provider meta bytes are preserved; malformed provider JSON is rejected at canonical hydration instead of normalized. The new rollup table starts empty. | +/// | v6→v7 | Total shape map | All source columns are identity-mapped; skill hash/timestamp columns use declared version sentinels. | +/// | v7→v8 | Total shape map | Pricing rows remain byte-for-byte source-owned; log `data_source` and the device cursor table are structural additions. No pricing correction runs. | +/// | v8→v9 | Total shape map | Every source value is identity-mapped; untrusted model pricing is never reseeded. | +/// | v9→v10 | Total shape map | All source columns are identity-mapped; Hermes flags are version-owned false sentinels. | +/// | v10→v11 | Typed transform | Every rollup source column is copied through an explicit projection; newly introduced request/pricing dimensions receive the version-defined unknown sentinel. Decode, insert, or collision failure aborts. | +/// | v11→v12 | Total shape map | All source values are identity-mapped and the new profiles table starts empty. | +/// | v12→v13 | Total shape map | All source values are identity-mapped; input-token semantics columns receive the declared unknown sentinel. | +/// | v13→v14 | Typed transform | The three supported proxy app rows are projected column-for-column, `live_takeover_active` receives the version-owned false sentinel, and the version-owned Grok row is inserted with fixed structural defaults. Missing columns, storage mismatches, unsupported app identities, invalid values, or collisions abort. | +/// | v14→v15 | Total shape map | All source values are identity-mapped; Grok Build enablement flags are version-owned false sentinels. | +/// | v15→v16 | Total shape map | Logs, rollups, and cursor rows remain unchanged. The historical filesystem-dependent Codex reset is restricted to `LocalUpgrade`; final restore policy separately excludes device-local cursors. | +/// | v16→v17 | Typed transform + total shape map | Endpoint rows preserve ids and timestamps while the new `last_used` field is null; duplicate logical endpoints abort instead of merging. Skills gain a false Pi bit, and device-local ownership ledgers start empty. | #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub(crate) enum MigrationRunContext { LocalUpgrade, UntrustedRestore, } -type ModelPricingSnapshotRow = (String, String, String, String, String, String); - #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] #[serde(rename_all = "snake_case")] pub(crate) enum CanonicalRestoreClass { @@ -240,6 +242,34 @@ const PROVIDERS_COLUMNS: &[CanonicalColumnSpec] = &[ default: Some("0"), pk_position: 0, }, + CanonicalColumnSpec { + name: "cost_multiplier", + data_type: "TEXT", + not_null: true, + default: Some("'1.0'"), + pk_position: 0, + }, + CanonicalColumnSpec { + name: "limit_daily_usd", + data_type: "TEXT", + not_null: false, + default: None, + pk_position: 0, + }, + CanonicalColumnSpec { + name: "limit_monthly_usd", + data_type: "TEXT", + not_null: false, + default: None, + pk_position: 0, + }, + CanonicalColumnSpec { + name: "provider_type", + data_type: "TEXT", + not_null: false, + default: None, + pk_position: 0, + }, ]; const PROVIDER_ENDPOINT_COLUMNS: &[CanonicalColumnSpec] = &[ @@ -473,6 +503,10 @@ pub(crate) const CANONICAL_TABLE_SPECS: &[CanonicalTableSpec] = &[ meta TEXT NOT NULL DEFAULT '{}', is_current BOOLEAN NOT NULL DEFAULT 0, in_failover_queue BOOLEAN NOT NULL DEFAULT 0, + cost_multiplier TEXT NOT NULL DEFAULT '1.0', + limit_daily_usd TEXT, + limit_monthly_usd TEXT, + provider_type TEXT, PRIMARY KEY (id, app_type) )", restore_class: CanonicalRestoreClass::MigrateAndValidate, @@ -549,9 +583,35 @@ struct LegacySkillMigrationRow { app_type: String, } +#[derive(Debug, Clone)] +struct LegacyV1ProxyConfig { + id: i64, + proxy_enabled: i64, + listen_address: String, + listen_port: i64, + enable_logging: i64, + max_retries: i64, + streaming_first_byte_timeout: i64, + streaming_idle_timeout: i64, + non_streaming_timeout: i64, +} + +#[derive(Debug, Clone)] +struct LegacyV1CircuitBreaker { + id: i64, + failure_threshold: i64, + success_threshold: i64, + timeout_seconds: i64, + error_rate_threshold: f64, + min_requests: i64, +} + +pub(crate) const UNTRUSTED_MIGRATION_TIMESTAMP: &str = "1970-01-01 00:00:00"; + impl Database { - /// Construct a publish-capable stage from an empty disk file using only - /// this binary's schema and migration code. + /// Construct a publish-capable stage directly from the current schema + /// factory. Untrusted historical migration is a separate path and never + /// supplies DDL to this factory. pub(super) fn current_canonical_stage() -> Result { let file = NamedTempFile::new().map_err(|error| AppError::IoContext { context: "create canonical restore stage".to_string(), @@ -567,11 +627,12 @@ impl Database { ) .map_err(|error| AppError::Database(error.to_string()))?; - // Starting from version zero exercises the normal migration chain and - // yields the exact same current objects as a fresh production database. + // The canonical factory is intentionally not implemented by replaying + // untrusted migrations. Its only inputs are current, trusted DDL and + // canonical seed data. Self::create_tables_on_conn(&connection, MigrationRunContext::LocalUpgrade)?; - Self::set_user_version(&connection, 0)?; - Self::apply_schema_migrations_on_conn(&connection, MigrationRunContext::LocalUpgrade)?; + Self::ensure_model_pricing_seeded_on_conn(&connection)?; + Self::set_user_version(&connection, SCHEMA_VERSION)?; if Self::get_user_version(&connection)? != SCHEMA_VERSION { return Err(AppError::Database( "canonical stage factory did not reach the current schema version".to_string(), @@ -633,6 +694,12 @@ impl Database { conn: &Connection, context: MigrationRunContext, ) -> Result<(), AppError> { + if context != MigrationRunContext::LocalUpgrade { + return Err(AppError::Config( + "current-schema factory is unavailable to untrusted migration".to_string(), + )); + } + // 1. Providers 表 Self::create_canonical_table_on_conn(conn, "providers", true)?; @@ -1110,7 +1177,11 @@ impl Database { ))); } } - version = Self::get_user_version(conn)?; + let migrated_version = Self::get_user_version(conn)?; + if context == MigrationRunContext::UntrustedRestore { + Self::validate_migration_source_version(conn, migrated_version)?; + } + version = migrated_version; } Ok(()) })(); @@ -1153,6 +1224,39 @@ impl Database { )) } + fn require_untrusted_integer_range( + context: MigrationRunContext, + step: &str, + field: &str, + value: i64, + range: std::ops::RangeInclusive, + ) -> Result<(), AppError> { + if context == MigrationRunContext::UntrustedRestore && !range.contains(&value) { + return Err(AppError::InvalidInput(format!( + "untrusted migration {step} field {field}={value} is outside {}..={}", + range.start(), + range.end() + ))); + } + Ok(()) + } + + fn require_untrusted_finite_unit_interval( + context: MigrationRunContext, + step: &str, + field: &str, + value: f64, + ) -> Result<(), AppError> { + if context == MigrationRunContext::UntrustedRestore + && (!value.is_finite() || !(0.0..=1.0).contains(&value)) + { + return Err(AppError::InvalidInput(format!( + "untrusted migration {step} field {field}={value} is not finite within 0..=1" + ))); + } + Ok(()) + } + fn legacy_query_or_local_default( context: MigrationRunContext, step: &str, @@ -1171,12 +1275,61 @@ impl Database { } } + fn require_untrusted_singleton_table( + conn: &Connection, + context: MigrationRunContext, + step: &str, + table: &str, + ) -> Result<(), AppError> { + if context != MigrationRunContext::UntrustedRestore { + return Ok(()); + } + Self::validate_identifier(table, "legacy singleton table")?; + let count: i64 = conn + .query_row(&format!("SELECT COUNT(*) FROM \"{table}\""), [], |row| { + row.get(0) + }) + .map_err(|error| { + Self::untrusted_repair_error( + step, + format!("failed to count authoritative rows in {table}: {error}"), + ) + })?; + if count != 1 { + return Err(Self::untrusted_repair_error( + step, + format!("{table} must contain exactly one authoritative row, found {count}"), + )); + } + Ok(()) + } + fn legacy_bool_or_local_false( conn: &Connection, context: MigrationRunContext, step: &str, key: &str, ) -> Result { + if context == MigrationRunContext::UntrustedRestore { + let count: i64 = conn + .query_row( + "SELECT COUNT(*) FROM settings WHERE key = ?1", + [key], + |row| row.get(0), + ) + .map_err(|error| { + Self::untrusted_repair_error( + step, + format!("failed to count required key {key:?}: {error}"), + ) + })?; + if count != 1 { + return Err(Self::untrusted_repair_error( + step, + format!("required key {key:?} must occur exactly once, found {count}"), + )); + } + } let value = conn .query_row("SELECT value FROM settings WHERE key = ?1", [key], |row| { row.get::<_, String>(0) @@ -1206,60 +1359,6 @@ impl Database { } } - fn require_untrusted_column_storage( - conn: &Connection, - context: MigrationRunContext, - step: &str, - table: &str, - columns: &[(&str, &str)], - ) -> Result<(), AppError> { - if context == MigrationRunContext::LocalUpgrade { - return Ok(()); - } - for (column, expected_storage) in columns { - if !Self::has_column(conn, table, column)? { - return Err(Self::untrusted_repair_error( - step, - format!("{table}.{column} is missing and would use a fallback expression"), - )); - } - let sql = format!( - "SELECT typeof(\"{column}\") FROM \"{table}\" - WHERE typeof(\"{column}\") <> ?1 LIMIT 1" - ); - let observed = conn - .query_row(&sql, [expected_storage], |row| row.get::<_, String>(0)) - .optional() - .map_err(|error| { - AppError::Database(format!("validate storage for {table}.{column}: {error}")) - })?; - if let Some(observed) = observed { - return Err(Self::untrusted_repair_error( - step, - format!( - "{table}.{column} has storage class {observed}, expected {expected_storage}" - ), - )); - } - } - Ok(()) - } - - fn require_no_untrusted_repair_rows( - context: MigrationRunContext, - step: &str, - source: &str, - row_count: i64, - ) -> Result<(), AppError> { - if context == MigrationRunContext::UntrustedRestore && row_count != 0 { - return Err(Self::untrusted_repair_error( - step, - format!("{source} contains {row_count} rows that the repair could discard"), - )); - } - Ok(()) - } - /// v0 -> v1 迁移:补齐所有缺失列 fn migrate_v0_to_v1(conn: &Connection, _context: MigrationRunContext) -> Result<(), AppError> { // providers 表 @@ -1401,37 +1500,54 @@ impl Database { ) .map_err(|e| AppError::Database(format!("创建 failover 索引失败: {e}")))?; - // proxy_request_logs 表 - conn.execute("CREATE TABLE IF NOT EXISTS proxy_request_logs ( - request_id TEXT PRIMARY KEY, provider_id TEXT NOT NULL, app_type TEXT NOT NULL, model TEXT NOT NULL, - request_model TEXT, - input_tokens INTEGER NOT NULL DEFAULT 0, output_tokens INTEGER NOT NULL DEFAULT 0, - cache_read_tokens INTEGER NOT NULL DEFAULT 0, cache_creation_tokens INTEGER NOT NULL DEFAULT 0, - input_token_semantics INTEGER NOT NULL DEFAULT 0, - input_cost_usd TEXT NOT NULL DEFAULT '0', output_cost_usd TEXT NOT NULL DEFAULT '0', - cache_read_cost_usd TEXT NOT NULL DEFAULT '0', cache_creation_cost_usd TEXT NOT NULL DEFAULT '0', - total_cost_usd TEXT NOT NULL DEFAULT '0', latency_ms INTEGER NOT NULL, first_token_ms INTEGER, - duration_ms INTEGER, status_code INTEGER NOT NULL, error_message TEXT, session_id TEXT, - provider_type TEXT, is_streaming INTEGER NOT NULL DEFAULT 0, - cost_multiplier TEXT NOT NULL DEFAULT '1.0', created_at INTEGER NOT NULL - )", [])?; - - // 为已存在的表添加新字段 - Self::add_column_if_missing(conn, "proxy_request_logs", "provider_type", "TEXT")?; - Self::add_column_if_missing( - conn, - "proxy_request_logs", - "is_streaming", - "INTEGER NOT NULL DEFAULT 0", - )?; - Self::add_column_if_missing( - conn, - "proxy_request_logs", - "cost_multiplier", - "TEXT NOT NULL DEFAULT '1.0'", - )?; - Self::add_column_if_missing(conn, "proxy_request_logs", "first_token_ms", "INTEGER")?; - Self::add_column_if_missing(conn, "proxy_request_logs", "duration_ms", "INTEGER")?; + // v2-owned tables are created by this migration, never by the current + // canonical factory. Their columns are the declared v2 source shape. + conn.execute( + "CREATE TABLE IF NOT EXISTS provider_health ( + provider_id TEXT NOT NULL, + app_type TEXT NOT NULL, + is_healthy INTEGER NOT NULL DEFAULT 1, + consecutive_failures INTEGER NOT NULL DEFAULT 0, + last_success_at TEXT, + last_failure_at TEXT, + last_error TEXT, + updated_at TEXT NOT NULL, + PRIMARY KEY (provider_id, app_type), + FOREIGN KEY (provider_id, app_type) + REFERENCES providers(id, app_type) ON DELETE CASCADE + )", + [], + ) + .map_err(|error| AppError::Database(format!("create v2 provider_health: {error}")))?; + conn.execute( + "CREATE TABLE IF NOT EXISTS proxy_request_logs ( + request_id TEXT PRIMARY KEY, + provider_id TEXT NOT NULL, + app_type TEXT NOT NULL, + model TEXT NOT NULL, + input_tokens INTEGER NOT NULL DEFAULT 0, + output_tokens INTEGER NOT NULL DEFAULT 0, + cache_read_tokens INTEGER NOT NULL DEFAULT 0, + cache_creation_tokens INTEGER NOT NULL DEFAULT 0, + input_cost_usd TEXT NOT NULL DEFAULT '0', + output_cost_usd TEXT NOT NULL DEFAULT '0', + cache_read_cost_usd TEXT NOT NULL DEFAULT '0', + cache_creation_cost_usd TEXT NOT NULL DEFAULT '0', + total_cost_usd TEXT NOT NULL DEFAULT '0', + latency_ms INTEGER NOT NULL, + first_token_ms INTEGER, + duration_ms INTEGER, + status_code INTEGER NOT NULL, + error_message TEXT, + session_id TEXT, + provider_type TEXT, + is_streaming INTEGER NOT NULL DEFAULT 0, + cost_multiplier TEXT NOT NULL DEFAULT '1.0', + created_at INTEGER NOT NULL + )", + [], + ) + .map_err(|error| AppError::Database(format!("create v2 proxy_request_logs: {error}")))?; // Validate/convert the two legacy structures before any data seed // replacement. This makes a malformed legacy row the reported cause @@ -1450,12 +1566,35 @@ impl Database { [], )?; - if context == MigrationRunContext::UntrustedRestore { - Self::require_canonical_model_pricing(conn, "v1->v2 model-pricing replacement")?; - } else { - // Local upgrades preserve the historical repair behavior. - conn.execute("DELETE FROM model_pricing", []) - .map_err(|e| AppError::Database(format!("清空模型定价失败: {e}")))?; + conn.execute( + "CREATE TABLE IF NOT EXISTS stream_check_logs ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + provider_id TEXT NOT NULL, + provider_name TEXT NOT NULL, + app_type TEXT NOT NULL, + status TEXT NOT NULL, + success INTEGER NOT NULL, + message TEXT NOT NULL, + response_time_ms INTEGER, + http_status INTEGER, + model_used TEXT, + retry_count INTEGER DEFAULT 0, + tested_at INTEGER NOT NULL + )", + [], + ) + .map_err(|error| AppError::Database(format!("create v2 stream_check_logs: {error}")))?; + conn.execute( + "CREATE TABLE IF NOT EXISTS proxy_live_backup ( + app_type TEXT PRIMARY KEY, + original_config TEXT NOT NULL, + backed_up_at TEXT NOT NULL + )", + [], + ) + .map_err(|error| AppError::Database(format!("create v2 proxy_live_backup: {error}")))?; + + if context == MigrationRunContext::LocalUpgrade { Self::seed_model_pricing(conn)?; } @@ -1467,57 +1606,87 @@ impl Database { conn: &Connection, context: MigrationRunContext, ) -> Result<(), AppError> { - // 检查是否已经是新表结构(幂等性) if !Self::table_exists(conn, "proxy_config")? { - // 表不存在,跳过迁移(新安装) + if context == MigrationRunContext::UntrustedRestore { + return Err(AppError::InvalidInput( + "untrusted v1 source is missing proxy_config".to_string(), + )); + } return Ok(()); } - if Self::has_column(conn, "proxy_config", "app_type")? { - // 已经是三行结构,跳过迁移 + if context == MigrationRunContext::UntrustedRestore { + return Err(AppError::InvalidInput( + "untrusted v1 proxy_config already has the v2 app_type shape".to_string(), + )); + } log::info!("proxy_config 已经是三行结构,跳过迁移"); return Ok(()); } - // 读取旧配置 + Self::require_untrusted_singleton_table( + conn, + context, + "v1->v2 proxy_config id=1", + "proxy_config", + )?; let old_config_result = conn.query_row( - "SELECT listen_address, listen_port, max_retries, enable_logging, + "SELECT id, proxy_enabled, listen_address, listen_port, enable_logging, max_retries, streaming_first_byte_timeout, streaming_idle_timeout, non_streaming_timeout FROM proxy_config WHERE id = 1", [], |row| { - Ok(( - row.get::<_, String>(0)?, - row.get::<_, i32>(1)?, - row.get::<_, i32>(2)?, - row.get::<_, i32>(3)?, - row.get::<_, i32>(4)?, - row.get::<_, i32>(5)?, - row.get::<_, i32>(6)?, - )) + Ok(LegacyV1ProxyConfig { + id: row.get(0)?, + proxy_enabled: row.get(1)?, + listen_address: row.get(2)?, + listen_port: row.get(3)?, + enable_logging: row.get(4)?, + max_retries: row.get(5)?, + streaming_first_byte_timeout: row.get(6)?, + streaming_idle_timeout: row.get(7)?, + non_streaming_timeout: row.get(8)?, + }) }, ); let old_config = Self::legacy_query_or_local_default( context, "v1->v2 proxy_config id=1", old_config_result, - ("127.0.0.1".to_string(), 5000, 3, 1, 30, 60, 300), + LegacyV1ProxyConfig { + id: 1, + proxy_enabled: 0, + listen_address: "127.0.0.1".to_string(), + listen_port: 5000, + enable_logging: 1, + max_retries: 3, + streaming_first_byte_timeout: 30, + streaming_idle_timeout: 60, + non_streaming_timeout: 300, + }, )?; + Self::require_untrusted_singleton_table( + conn, + context, + "v1->v2 circuit_breaker_config id=1", + "circuit_breaker_config", + )?; let old_cb_result = if Self::table_exists(conn, "circuit_breaker_config")? { conn.query_row( - "SELECT failure_threshold, success_threshold, timeout_seconds, + "SELECT id, failure_threshold, success_threshold, timeout_seconds, error_rate_threshold, min_requests FROM circuit_breaker_config WHERE id = 1", [], |row| { - Ok(( - row.get::<_, i32>(0)?, - row.get::<_, i32>(1)?, - row.get::<_, i64>(2)?, - row.get::<_, f64>(3)?, - row.get::<_, i32>(4)?, - )) + Ok(LegacyV1CircuitBreaker { + id: row.get(0)?, + failure_threshold: row.get(1)?, + success_threshold: row.get(2)?, + timeout_seconds: row.get(3)?, + error_rate_threshold: row.get(4)?, + min_requests: row.get(5)?, + }) }, ) } else { @@ -1527,69 +1696,120 @@ impl Database { context, "v1->v2 circuit_breaker_config id=1", old_cb_result, - (5, 2, 60, 0.5, 10), + LegacyV1CircuitBreaker { + id: 1, + failure_threshold: 5, + success_threshold: 2, + timeout_seconds: 60, + error_rate_threshold: 0.5, + min_requests: 10, + }, + )?; + + Self::require_untrusted_integer_range( + context, + "v1->v2 proxy fan-out", + "proxy_config.id", + old_config.id, + 1..=1, + )?; + Self::require_untrusted_integer_range( + context, + "v1->v2 proxy fan-out", + "proxy_enabled", + old_config.proxy_enabled, + 0..=1, + )?; + Self::require_untrusted_integer_range( + context, + "v1->v2 proxy fan-out", + "listen_port", + old_config.listen_port, + 0..=u16::MAX.into(), + )?; + Self::require_untrusted_integer_range( + context, + "v1->v2 proxy fan-out", + "enable_logging", + old_config.enable_logging, + 0..=1, + )?; + Self::require_untrusted_integer_range( + context, + "v1->v2 proxy fan-out", + "max_retries", + old_config.max_retries, + 0..=u8::MAX.into(), + )?; + for (field, value) in [ + ( + "streaming_first_byte_timeout", + old_config.streaming_first_byte_timeout, + ), + ("streaming_idle_timeout", old_config.streaming_idle_timeout), + ("non_streaming_timeout", old_config.non_streaming_timeout), + ] { + Self::require_untrusted_integer_range( + context, + "v1->v2 proxy fan-out", + field, + value, + 0..=i32::MAX.into(), + )?; + } + Self::require_untrusted_integer_range( + context, + "v1->v2 circuit fan-out", + "circuit_breaker_config.id", + old_cb.id, + 1..=1, + )?; + for (field, value) in [ + ("failure_threshold", old_cb.failure_threshold), + ("success_threshold", old_cb.success_threshold), + ("timeout_seconds", old_cb.timeout_seconds), + ("min_requests", old_cb.min_requests), + ] { + Self::require_untrusted_integer_range( + context, + "v1->v2 circuit fan-out", + field, + value, + 0..=i32::MAX.into(), + )?; + } + Self::require_untrusted_finite_unit_interval( + context, + "v1->v2 circuit fan-out", + "error_rate_threshold", + old_cb.error_rate_threshold, )?; let get_bool = |key: &str| { Self::legacy_bool_or_local_false(conn, context, "v1->v2 proxy ownership settings", key) }; - let apps = [ + let mut apps = vec![ ( "claude", get_bool("proxy_takeover_claude")?, get_bool("auto_failover_enabled_claude")?, - 6, - 45, - 90, - 8, - 3, - 90, - 0.6, - 15, ), ( "codex", get_bool("proxy_takeover_codex")?, get_bool("auto_failover_enabled_codex")?, - 3, - old_config.4, - old_config.5, - old_cb.0, - old_cb.1, - old_cb.2, - old_cb.3, - old_cb.4, ), ( "gemini", get_bool("proxy_takeover_gemini")?, get_bool("auto_failover_enabled_gemini")?, - 5, - old_config.4, - old_config.5, - old_cb.0, - old_cb.1, - old_cb.2, - old_cb.3, - old_cb.4, - ), - ( - "grokbuild", - false, - false, - 3, - old_config.4, - old_config.5, - old_cb.0, - old_cb.1, - old_cb.2, - old_cb.3, - old_cb.4, ), ]; + if context == MigrationRunContext::LocalUpgrade { + apps.push(("grokbuild", false, false)); + } - // 创建新表 conn.execute("DROP TABLE IF EXISTS proxy_config_new", [])?; conn.execute("CREATE TABLE proxy_config_new ( app_type TEXT PRIMARY KEY CHECK (app_type IN ('claude','codex','gemini','grokbuild')), @@ -1601,35 +1821,64 @@ impl Database { circuit_failure_threshold INTEGER NOT NULL DEFAULT 4, circuit_success_threshold INTEGER NOT NULL DEFAULT 2, circuit_timeout_seconds INTEGER NOT NULL DEFAULT 60, circuit_error_rate_threshold REAL NOT NULL DEFAULT 0.6, circuit_min_requests INTEGER NOT NULL DEFAULT 10, - default_cost_multiplier TEXT NOT NULL DEFAULT '1', - pricing_model_source TEXT NOT NULL DEFAULT 'response', - live_takeover_active INTEGER NOT NULL DEFAULT 0, created_at TEXT NOT NULL DEFAULT (datetime('now')), updated_at TEXT NOT NULL DEFAULT (datetime('now')) )", [])?; - // 插入三行配置 - for (app, takeover, failover, retries, fb, idle, cb_f, cb_s, cb_t, cb_r, cb_m) in apps { + let structural_timestamp = if context == MigrationRunContext::UntrustedRestore { + UNTRUSTED_MIGRATION_TIMESTAMP.to_string() + } else { + conn.query_row("SELECT datetime('now')", [], |row| row.get::<_, String>(0)) + .map_err(|error| { + AppError::Database(format!( + "read v1->v2 structural migration timestamp: {error}" + )) + })? + }; + for (app, takeover, failover) in apps { conn.execute( "INSERT INTO proxy_config_new (app_type, proxy_enabled, listen_address, listen_port, enable_logging, enabled, auto_failover_enabled, max_retries, streaming_first_byte_timeout, streaming_idle_timeout, non_streaming_timeout, circuit_failure_threshold, circuit_success_threshold, circuit_timeout_seconds, - circuit_error_rate_threshold, circuit_min_requests) - VALUES (?1, 0, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15)", - rusqlite::params![app, old_config.0, old_config.1, old_config.3, + circuit_error_rate_threshold, circuit_min_requests, created_at, updated_at) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18)", + rusqlite::params![ + app, + old_config.proxy_enabled, + old_config.listen_address, + old_config.listen_port, + old_config.enable_logging, if takeover { 1 } else { 0 }, if failover { 1 } else { 0 }, - retries, fb, idle, old_config.6, cb_f, cb_s, cb_t, cb_r, cb_m] + old_config.max_retries, + old_config.streaming_first_byte_timeout, + old_config.streaming_idle_timeout, + old_config.non_streaming_timeout, + old_cb.failure_threshold, + old_cb.success_threshold, + old_cb.timeout_seconds, + old_cb.error_rate_threshold, + old_cb.min_requests, + structural_timestamp, + structural_timestamp, + ] ).map_err(|e| AppError::Database(format!("插入 {app} 配置失败: {e}")))?; } - // 替换表并清理 conn.execute("DROP TABLE IF EXISTS proxy_config", [])?; conn.execute("ALTER TABLE proxy_config_new RENAME TO proxy_config", [])?; conn.execute("DROP TABLE IF EXISTS circuit_breaker_config", [])?; - conn.execute("DELETE FROM settings WHERE key LIKE 'proxy_takeover_%'", [])?; - conn.execute( - "DELETE FROM settings WHERE key LIKE 'auto_failover_enabled_%'", - [], - )?; + if context == MigrationRunContext::LocalUpgrade { + conn.execute( + "DELETE FROM settings WHERE key IN ( + 'proxy_takeover_claude', + 'auto_failover_enabled_claude', + 'proxy_takeover_codex', + 'auto_failover_enabled_codex', + 'proxy_takeover_gemini', + 'auto_failover_enabled_gemini' + )", + [], + )?; + } log::info!("proxy_config 已迁移为三行结构"); Ok(()) @@ -1682,11 +1931,11 @@ impl Database { .prepare("SELECT key, installed, installed_at FROM skills_old") .map_err(|e| AppError::Database(format!("查询旧 skills 数据失败: {e}")))?; - let old_skills: Vec<(String, bool, i64)> = stmt + let old_skills: Vec<(String, i64, i64)> = stmt .query_map([], |row| { Ok(( row.get::<_, String>(0)?, - row.get::<_, bool>(1)?, + row.get::<_, i64>(1)?, row.get::<_, i64>(2)?, )) }) @@ -1697,6 +1946,18 @@ impl Database { let count = old_skills.len(); for (key, installed, installed_at) in old_skills { + Self::require_untrusted_integer_range( + context, + "v1->v2 skills", + "installed", + installed, + 0..=1, + )?; + let installed = if context == MigrationRunContext::LocalUpgrade { + i64::from(installed != 0) + } else { + installed + }; // 解析 key: "app:directory" 或 "directory"(默认 claude) let (app_type, directory) = if let Some((app, directory)) = key.split_once(':') { if app.is_empty() || directory.is_empty() { @@ -1705,6 +1966,14 @@ impl Database { format!("legacy skill key {key:?} has an empty component"), )); } + if context == MigrationRunContext::UntrustedRestore + && !matches!(app, "claude" | "codex" | "gemini") + { + return Err(Self::untrusted_repair_error( + "v1->v2 skills", + format!("legacy skill key {key:?} has unsupported app {app:?}"), + )); + } (app.to_string(), directory.to_string()) } else if context == MigrationRunContext::UntrustedRestore { return Err(Self::untrusted_repair_error( @@ -1749,18 +2018,62 @@ impl Database { log::info!("开始迁移 skills 表到 v3 结构(统一管理架构)..."); - // 1. 备份旧数据(用于日志和后续启动迁移) + // Capture typed rows before the shape changes. Local upgrades keep the + // historical filesystem-reconciliation marker; untrusted restores use + // a total, deterministic row mapping and never discard these rows. let old_count: i64 = conn .query_row("SELECT COUNT(*) FROM skills", [], |row| row.get(0)) .map_err(|error| AppError::Database(format!("统计旧 skills 数据失败: {error}")))?; log::info!("旧 skills 表有 {old_count} 条记录"); - Self::require_no_untrusted_repair_rows( - context, - "v2->v3 skills SSOT rebuild", - "legacy skills", - old_count, - )?; + let untrusted_rows = if context == MigrationRunContext::UntrustedRestore { + let mut statement = conn + .prepare( + "SELECT directory, app_type, installed, installed_at + FROM skills ORDER BY directory, app_type", + ) + .map_err(|error| { + AppError::Database(format!("prepare typed v2 skills migration: {error}")) + })?; + let rows = statement + .query_map([], |row| { + Ok(( + row.get::<_, String>(0)?, + row.get::<_, String>(1)?, + row.get::<_, i64>(2)?, + row.get::<_, i64>(3)?, + )) + }) + .map_err(|error| { + AppError::Database(format!("read typed v2 skills migration: {error}")) + })? + .collect::, _>>() + .map_err(|error| { + AppError::Database(format!("decode typed v2 skills migration: {error}")) + })?; + for (directory, app_type, installed, _) in &rows { + if directory.is_empty() { + return Err(AppError::InvalidInput( + "untrusted v2 skill has an empty directory".to_string(), + )); + } + if !matches!(app_type.as_str(), "claude" | "codex" | "gemini") { + return Err(AppError::InvalidInput(format!( + "untrusted v2 skill {directory:?} has unsupported app_type {app_type:?}" + ))); + } + Self::require_untrusted_integer_range( + context, + "v2->v3 skills typed mapping", + "installed", + *installed, + 0..=1, + )?; + } + rows + } else { + Vec::new() + }; if context == MigrationRunContext::LocalUpgrade { let mut stmt = conn @@ -1823,10 +2136,45 @@ impl Database { ) .map_err(|e| AppError::Database(format!("创建新 skills 表失败: {e}")))?; - log::info!( - "skills 表已迁移到 v3 结构。\n\ - 注意:旧的安装记录已清除,首次启动时将自动扫描文件系统重建数据。" - ); + if context == MigrationRunContext::UntrustedRestore { + for (directory, app_type, installed, installed_at) in untrusted_rows { + let (enabled_claude, enabled_codex, enabled_gemini) = match app_type.as_str() { + "claude" => (installed, 0, 0), + "codex" => (0, installed, 0), + "gemini" => (0, 0, installed), + _ => { + return Err(AppError::InvalidInput(format!( + "untrusted v2 skill has unsupported app_type {app_type:?}" + ))) + } + }; + let id = format!("{app_type}:{directory}"); + conn.execute( + "INSERT INTO skills ( + id, name, directory, enabled_claude, enabled_codex, + enabled_gemini, installed_at + ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)", + rusqlite::params![ + id, + directory, + directory, + enabled_claude, + enabled_codex, + enabled_gemini, + installed_at + ], + ) + .map_err(|error| { + AppError::Database(format!("map v2 skill {id:?} into v3: {error}")) + })?; + } + log::info!("v2 -> v3 untrusted skills mapped losslessly"); + } else { + log::info!( + "skills 表已迁移到 v3 结构。\n\ + 注意:旧的安装记录已清除,首次启动时将自动扫描文件系统重建数据。" + ); + } Ok(()) } @@ -1902,6 +2250,13 @@ impl Database { ) .map_err(|e| AppError::Database(format!("创建 usage_daily_rollups 表失败: {e}")))?; + if context == MigrationRunContext::UntrustedRestore { + // Provider metadata is portable user data. The canonical-stage + // provider decoder will reject malformed JSON; valid legacy values + // are preserved byte-for-byte instead of normalized in scratch. + return Ok(()); + } + // 2. 统一 Copilot 模板类型为 github_copilot let mut stmt = conn .prepare("SELECT id, app_type, meta FROM providers") @@ -1923,12 +2278,6 @@ impl Database { let mut meta = match serde_json::from_str::(&meta_str) { Ok(meta) => meta, - Err(error) if context == MigrationRunContext::UntrustedRestore => { - return Err(Self::untrusted_repair_error( - "v5->v6 provider meta", - format!("provider ({id}, {app_type}) has invalid JSON: {error}"), - )); - } Err(_) => continue, }; let mut updated = false; @@ -1936,14 +2285,6 @@ impl Database { if let Some(usage_script) = meta.get_mut("usage_script") { if let Some(template_type) = usage_script.get_mut("template_type") { if template_type == "copilot" { - if context == MigrationRunContext::UntrustedRestore { - return Err(Self::untrusted_repair_error( - "v5->v6 provider meta", - format!( - "provider ({id}, {app_type}) requires copilot template normalization" - ), - )); - } *template_type = serde_json::Value::String("github_copilot".to_string()); updated = true; } @@ -2009,16 +2350,14 @@ impl Database { ) .map_err(|e| AppError::Database(format!("创建 session_log_sync 表失败: {e}")))?; + if context == MigrationRunContext::UntrustedRestore { + // Pricing rows are portable data. Historical correction remains a + // LocalUpgrade policy; the untrusted mapping is identity. + return Ok(()); + } + // 3. 修正国产模型定价:之前误将 CNY 值存为 USD 字段,统一转换为 USD if Self::table_exists(conn, "model_pricing")? { - if context == MigrationRunContext::UntrustedRestore - && Self::model_pricing_is_empty_or_canonical(conn)? - { - log::info!( - "v7 -> v8 untrusted restore: pricing is empty/canonical; repair is a no-op" - ); - return Ok(()); - } let pricing_fixes: &[(&str, &str, &str, &str, &str)] = &[ ("deepseek-v3.2", "0.28", "0.42", "0.028", "0"), ("deepseek-v3.1", "0.55", "1.67", "0.055", "0"), @@ -2035,43 +2374,6 @@ impl Database { ("mimo-v2-flash", "0.09", "0.29", "0.009", "0"), ]; for (model_id, input, output, cache_read, cache_creation) in pricing_fixes { - if context == MigrationRunContext::UntrustedRestore { - let current = conn - .query_row( - "SELECT input_cost_per_million, output_cost_per_million, - cache_read_cost_per_million, - cache_creation_cost_per_million - FROM model_pricing WHERE model_id = ?1", - [model_id], - |row| { - Ok(( - row.get::<_, String>(0)?, - row.get::<_, String>(1)?, - row.get::<_, String>(2)?, - row.get::<_, String>(3)?, - )) - }, - ) - .optional() - .map_err(|error| { - AppError::Database(format!("读取模型 {model_id} 定价失败: {error}")) - })?; - if let Some(current) = current { - let expected = ( - (*input).to_string(), - (*output).to_string(), - (*cache_read).to_string(), - (*cache_creation).to_string(), - ); - if current != expected { - return Err(Self::untrusted_repair_error( - "v7->v8 model pricing correction", - format!("model {model_id} differs from the corrected tuple"), - )); - } - } - continue; - } conn.execute( "UPDATE model_pricing SET input_cost_per_million = ?2, @@ -2102,7 +2404,7 @@ impl Database { ) .map_err(|e| AppError::Database(format!("创建 model_pricing 表失败: {e}")))?; if context == MigrationRunContext::UntrustedRestore { - return Self::require_canonical_model_pricing(conn, "v8->v9 model-pricing replacement"); + return Ok(()); } conn.execute("DELETE FROM model_pricing", []) .map_err(|e| AppError::Database(format!("清空模型定价失败: {e}")))?; @@ -2140,7 +2442,10 @@ impl Database { /// 路由接管下 model(真实上游模型)≠ request_model(客户端别名), /// 旧 rollup 只按 model 聚合,明细 prune 后映射关系永久丢失、计费不可审计。 /// SQLite 改主键必须重建表;历史行的 request_model 已不可知,填 ''。 - fn migrate_v10_to_v11(conn: &Connection, context: MigrationRunContext) -> Result<(), AppError> { + fn migrate_v10_to_v11( + conn: &Connection, + _context: MigrationRunContext, + ) -> Result<(), AppError> { // proxy_request_logs.pricing_model:NULL = v11 前的历史行(回填走 // model → 占位符回退 request_model 的旧逻辑),'' = 未计价的错误行 if Self::table_exists(conn, "proxy_request_logs")? { @@ -2152,22 +2457,6 @@ impl Database { return Ok(()); } - if context == MigrationRunContext::UntrustedRestore { - let row_count: i64 = conn - .query_row("SELECT COUNT(*) FROM usage_daily_rollups", [], |row| { - row.get(0) - }) - .map_err(|error| { - AppError::Database(format!("统计 v10 usage_daily_rollups 失败: {error}")) - })?; - Self::require_no_untrusted_repair_rows( - context, - "v10->v11 usage rollup rebuild", - "legacy usage_daily_rollups", - row_count, - )?; - } - conn.execute_batch( "ALTER TABLE usage_daily_rollups RENAME TO usage_daily_rollups_v10; CREATE TABLE usage_daily_rollups ( @@ -2261,62 +2550,27 @@ impl Database { return Ok(()); } - const V13_PROXY_STORAGE: &[(&str, &str)] = &[ - ("app_type", "text"), - ("proxy_enabled", "integer"), - ("listen_address", "text"), - ("listen_port", "integer"), - ("enable_logging", "integer"), - ("enabled", "integer"), - ("auto_failover_enabled", "integer"), - ("max_retries", "integer"), - ("streaming_first_byte_timeout", "integer"), - ("streaming_idle_timeout", "integer"), - ("non_streaming_timeout", "integer"), - ("circuit_failure_threshold", "integer"), - ("circuit_success_threshold", "integer"), - ("circuit_timeout_seconds", "integer"), - ("circuit_error_rate_threshold", "real"), - ("circuit_min_requests", "integer"), - ("default_cost_multiplier", "text"), - ("pricing_model_source", "text"), - ("live_takeover_active", "integer"), - ("created_at", "text"), - ("updated_at", "text"), - ]; - let untrusted_source_empty = if context == MigrationRunContext::UntrustedRestore { - let (total_rows, grokbuild_rows): (i64, i64) = conn + if context == MigrationRunContext::UntrustedRestore { + let unsupported = conn .query_row( - "SELECT COUNT(*), - COUNT(CASE WHEN app_type = 'grokbuild' THEN 1 END) - FROM proxy_config", + "SELECT app_type FROM proxy_config + WHERE app_type NOT IN ('claude', 'codex', 'gemini') + ORDER BY app_type LIMIT 1", [], - |row| Ok((row.get(0)?, row.get(1)?)), + |row| row.get::<_, String>(0), ) + .optional() .map_err(|error| { - AppError::Database(format!("count v13 Grok Build proxy rows failed: {error}")) + AppError::InvalidInput(format!( + "validate v13 proxy app domain before typed mapping: {error}" + )) })?; - if total_rows != 0 { - Self::require_untrusted_column_storage( - conn, - context, - "v13->v14 proxy rebuild", - "proxy_config", - V13_PROXY_STORAGE, - )?; + if let Some(app_type) = unsupported { + return Err(AppError::InvalidInput(format!( + "untrusted v13 proxy row has unsupported app_type {app_type:?}" + ))); } - if total_rows != 0 && grokbuild_rows != 1 { - return Err(Self::untrusted_repair_error( - "v13->v14 proxy rebuild", - format!( - "expected one existing Grok Build row, found {grokbuild_rows}; row synthesis is forbidden" - ), - )); - } - total_rows == 0 - } else { - false - }; + } conn.execute("DROP TABLE IF EXISTS proxy_config_v14", []) .map_err(|e| AppError::Database(e.to_string()))?; @@ -2375,7 +2629,9 @@ impl Database { .map(|(column, fallback)| -> Result { if Self::has_column(conn, "proxy_config", column)? { Ok(format!("\"{column}\"")) - } else if context == MigrationRunContext::LocalUpgrade || untrusted_source_empty { + } else if context == MigrationRunContext::LocalUpgrade + || column == "live_takeover_active" + { Ok(fallback.into()) } else { Err(Self::untrusted_repair_error( @@ -2406,13 +2662,23 @@ impl Database { .map_err(|e| AppError::Database(e.to_string()))?; conn.execute("ALTER TABLE proxy_config_v14 RENAME TO proxy_config", []) .map_err(|e| AppError::Database(e.to_string()))?; - if context == MigrationRunContext::LocalUpgrade { + let insert_grokbuild = if context == MigrationRunContext::UntrustedRestore { conn.execute( - "INSERT OR IGNORE INTO proxy_config (app_type) VALUES ('grokbuild')", + "INSERT INTO proxy_config (app_type, created_at, updated_at) + VALUES ('grokbuild', ?1, ?1)", + [UNTRUSTED_MIGRATION_TIMESTAMP], + ) + } else { + conn.execute( + "INSERT INTO proxy_config (app_type) + SELECT 'grokbuild' + WHERE NOT EXISTS ( + SELECT 1 FROM proxy_config WHERE app_type = 'grokbuild' + )", [], ) - .map_err(|e| AppError::Database(e.to_string()))?; - } + }; + insert_grokbuild.map_err(|e| AppError::Database(e.to_string()))?; Ok(()) } @@ -2446,60 +2712,70 @@ impl Database { /// schema migration already owns the Database connection mutex. fn migrate_v15_to_v16(conn: &Connection, context: MigrationRunContext) -> Result<(), AppError> { if context == MigrationRunContext::UntrustedRestore { - let codex_logs: i64 = conn - .query_row( - "SELECT COUNT(*) FROM proxy_request_logs - WHERE data_source = 'codex_session'", - [], - |row| row.get(0), - ) - .map_err(|error| { - AppError::Database(format!("count Codex session logs: {error}")) - })?; - Self::require_no_untrusted_repair_rows( - context, - "v15->v16 Codex usage reset", - "proxy_request_logs[data_source=codex_session]", - codex_logs, - )?; - - let codex_rollups: i64 = conn - .query_row( - "SELECT COUNT(*) FROM usage_daily_rollups - WHERE provider_id = '_codex_session'", - [], - |row| row.get(0), - ) - .map_err(|error| { - AppError::Database(format!("count Codex session rollups: {error}")) - })?; - Self::require_no_untrusted_repair_rows( - context, - "v15->v16 Codex usage reset", - "usage_daily_rollups[provider_id=_codex_session]", - codex_rollups, - )?; - - // Cursor ownership is resolved against this device's filesystem. - // An untrusted backup cannot prove that relationship, so any - // incoming cursor would require a local-path repair decision. - let cursors: i64 = conn - .query_row("SELECT COUNT(*) FROM session_log_sync", [], |row| { - row.get(0) - }) - .map_err(|error| AppError::Database(format!("count session cursors: {error}")))?; - Self::require_no_untrusted_repair_rows( - context, - "v15->v16 Codex usage reset", - "session_log_sync", - cursors, - )?; + // The reset is device/filesystem policy, not a portable schema + // transformation. Preserve every incoming row here; the later + // restore-policy layer independently excludes device-local cursor + // state from publication. return Ok(()); } let codex_dir = crate::codex_config::get_codex_config_dir(); crate::services::session_usage_codex::reset_codex_usage_on_conn(conn, &codex_dir) } + fn create_v17_migration_endpoint_table( + conn: &Connection, + table_name: &str, + ) -> Result<(), AppError> { + Self::validate_identifier(table_name, "v17 migration endpoint table")?; + conn.execute( + &format!( + "CREATE TABLE \"{table_name}\" ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + provider_id TEXT NOT NULL, + app_type TEXT NOT NULL, + url TEXT NOT NULL, + added_at INTEGER, + last_used INTEGER, + FOREIGN KEY (provider_id, app_type) + REFERENCES providers(id, app_type) ON DELETE CASCADE, + UNIQUE (provider_id, app_type, url) + )" + ), + [], + ) + .map_err(|error| { + AppError::Database(format!( + "create v17 migration endpoint table '{table_name}': {error}" + )) + })?; + Ok(()) + } + + fn create_v17_migration_ledgers(conn: &Connection) -> Result<(), AppError> { + conn.execute_batch( + "CREATE TABLE IF NOT EXISTS pi_provider_projections ( + provider_id TEXT PRIMARY KEY, + provider_key TEXT NOT NULL UNIQUE, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL + ); + CREATE TABLE IF NOT EXISTS skill_deployments ( + app_type TEXT NOT NULL CHECK (app_type = 'pi'), + skill_id TEXT NOT NULL, + destination TEXT NOT NULL, + destination_key TEXT NOT NULL, + method TEXT NOT NULL CHECK (method IN ('symlink', 'copy')), + source_identity TEXT NOT NULL, + deployed_digest TEXT, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL, + PRIMARY KEY (app_type, skill_id, destination_key), + UNIQUE (app_type, destination_key) + );", + ) + .map_err(|error| AppError::Database(format!("create v17 migration ledgers: {error}"))) + } + /// v16 -> v17: add the Pi desired bit, lossless endpoint metadata, and /// device-local ownership ledgers. No ownership is inferred during /// migration; both ledgers intentionally start empty. @@ -2570,11 +2846,10 @@ impl Database { ) .map_err(|error| AppError::Database(error.to_string()))?; } - const REBUILT_ENDPOINTS: &str = "provider_endpoints_v17_canonical"; + const REBUILT_ENDPOINTS: &str = "provider_endpoints_v17_migration"; conn.execute(&format!("DROP TABLE IF EXISTS \"{REBUILT_ENDPOINTS}\""), []) .map_err(|error| AppError::Database(error.to_string()))?; - let spec = Self::canonical_table_spec("provider_endpoints")?; - Self::create_canonical_table_as_on_conn(conn, spec, REBUILT_ENDPOINTS, false)?; + Self::create_v17_migration_endpoint_table(conn, REBUILT_ENDPOINTS)?; conn.execute( &format!( "INSERT INTO \"{REBUILT_ENDPOINTS}\" @@ -2597,7 +2872,7 @@ impl Database { ) .map_err(|error| AppError::Database(error.to_string()))?; } else { - Self::create_canonical_table_on_conn(conn, "provider_endpoints", false)?; + Self::create_v17_migration_endpoint_table(conn, "provider_endpoints")?; } if Self::table_exists(conn, "skills")? { Self::add_column_if_missing( @@ -2607,8 +2882,7 @@ impl Database { "BOOLEAN NOT NULL DEFAULT 0", )?; } - Self::create_canonical_table_on_conn(conn, "pi_provider_projections", true)?; - Self::create_canonical_table_on_conn(conn, "skill_deployments", true) + Self::create_v17_migration_ledgers(conn) } /// 插入默认模型定价数据 @@ -3515,77 +3789,6 @@ impl Database { Ok(()) } - fn model_pricing_snapshot(conn: &Connection) -> Result, AppError> { - let mut statement = conn - .prepare( - "SELECT model_id, display_name, input_cost_per_million, - output_cost_per_million, cache_read_cost_per_million, - cache_creation_cost_per_million - FROM model_pricing ORDER BY model_id", - ) - .map_err(|error| { - AppError::Database(format!("prepare model-pricing snapshot: {error}")) - })?; - let rows = statement - .query_map([], |row| { - Ok(( - row.get(0)?, - row.get(1)?, - row.get(2)?, - row.get(3)?, - row.get(4)?, - row.get(5)?, - )) - }) - .map_err(|error| { - AppError::Database(format!("query model-pricing snapshot: {error}")) - })?; - rows.collect::, _>>() - .map_err(|error| AppError::Database(format!("decode model-pricing snapshot: {error}"))) - } - - fn canonical_model_pricing_snapshot() -> Result, AppError> { - let reference = Connection::open_in_memory() - .map_err(|error| AppError::Database(format!("open pricing oracle: {error}")))?; - reference - .execute( - "CREATE TABLE model_pricing ( - model_id TEXT PRIMARY KEY, - display_name TEXT NOT NULL, - input_cost_per_million TEXT NOT NULL, - output_cost_per_million TEXT NOT NULL, - cache_read_cost_per_million TEXT NOT NULL DEFAULT '0', - cache_creation_cost_per_million TEXT NOT NULL DEFAULT '0' - )", - [], - ) - .map_err(|error| AppError::Database(format!("create pricing oracle table: {error}")))?; - Self::ensure_model_pricing_seeded_on_conn(&reference)?; - Self::model_pricing_snapshot(&reference) - } - - fn model_pricing_is_empty_or_canonical(conn: &Connection) -> Result { - let actual = Self::model_pricing_snapshot(conn)?; - Ok(actual.is_empty() || actual == Self::canonical_model_pricing_snapshot()?) - } - - fn require_canonical_model_pricing(conn: &Connection, step: &str) -> Result<(), AppError> { - let actual = Self::model_pricing_snapshot(conn)?; - let expected = Self::canonical_model_pricing_snapshot()?; - if actual.is_empty() || actual == expected { - Ok(()) - } else { - Err(Self::untrusted_repair_error( - step, - format!( - "incoming model_pricing has {} rows but the canonical seed has {}; replacement is forbidden", - actual.len(), - expected.len() - ), - )) - } - } - fn repair_current_model_pricing(conn: &Connection) -> Result<(), AppError> { let pricing_fixes = [ // 2026-07-12 GPT-5.6 家族 cache write=1.25× 输入价(OpenAI 5.6 起的新规), diff --git a/tests/fixtures/pi/canonical-schema-manifest-v1.json b/tests/fixtures/pi/canonical-schema-manifest-v1.json index be6e16b1e..de2cb9ed2 100644 --- a/tests/fixtures/pi/canonical-schema-manifest-v1.json +++ b/tests/fixtures/pi/canonical-schema-manifest-v1.json @@ -21,7 +21,11 @@ ["icon_color", "TEXT", false, null, 0], ["meta", "TEXT", true, "'{}'", 0], ["is_current", "BOOLEAN", true, "0", 0], - ["in_failover_queue", "BOOLEAN", true, "0", 0] + ["in_failover_queue", "BOOLEAN", true, "0", 0], + ["cost_multiplier", "TEXT", true, "'1.0'", 0], + ["limit_daily_usd", "TEXT", false, null, 0], + ["limit_monthly_usd", "TEXT", false, null, 0], + ["provider_type", "TEXT", false, null, 0] ], "uniqueTuples": [], "foreignKeys": [],