storage(pi): isolate typed provider writes and canonical restore

Replace the generic Provider save/upsert surface with strict typed
create/update/rename/endpoint operations. Keep aggregate hydration read-only,
preserve nullable endpoint timestamps end to end, and make service create own
the initial endpoint set atomically.

Restore SQL and binary backups only through UntrustedScratch, migrate and copy
fixed data columns into a fresh CanonicalStage, validate the canonical result,
then publish through the Backup API. The imported schema is never eligible to
become the live schema.

Old save_provider callsite classification
==========================================

Inventory authority: abandoned 5a385fc8 tree. The old definition at
src-tauri/src/database/dao/providers.rs:180 is deleted and is not a callsite.

Production callsites:

- src-tauri/src/commands/provider.rs:253 [create] Claude Desktop import creates
  one absent aggregate; it now strict-inserts the row and initial endpoints in
  one transaction.
- src-tauri/src/database/dao/providers.rs:638 [create] official seed first
  proves absence, then strict-creates; a racing insert is a conflict.
- src-tauri/src/database/dao/providers.rs:704 [create] on-demand seed first
  proves absence, then strict-creates; it cannot overwrite an existing row.
- src-tauri/src/services/omo.rs:291 [create] OMO import constructs a new
  aggregate and strict-creates it; OMO is not eligible for rename.
- src-tauri/src/services/provider/endpoints.rs:85 [update] endpoint last-used
  is not a Provider-row save; it now calls the exact touch endpoint operation.
- src-tauri/src/services/provider/live.rs:1567 [create/update] default live
  import is reconciliation: read first, then strict create or strict update.
- src-tauri/src/services/provider/live.rs:1743 [update] an existing OpenCode
  live provider follows the strict row-update branch.
- src-tauri/src/services/provider/live.rs:1770 [create] a new OpenCode live
  provider follows the strict aggregate-create branch.
- src-tauri/src/services/provider/live.rs:1825 [update] an existing OpenClaw
  live provider follows the strict row-update branch.
- src-tauri/src/services/provider/live.rs:1858 [create] a new OpenClaw live
  provider follows the strict aggregate-create branch.
- src-tauri/src/services/provider/live.rs:1900 [update] an existing Hermes live
  provider follows the strict row-update branch.
- src-tauri/src/services/provider/live.rs:1926 [create] a new Hermes live
  provider follows the strict aggregate-create branch.
- src-tauri/src/services/provider/mod.rs:2568 [create] ProviderService::add owns
  strict aggregate creation and all initial endpoints.
- src-tauri/src/services/provider/mod.rs:2680 [rename] an additive DB-only key
  change now uses the dedicated transactional rename after eligibility checks.
- src-tauri/src/services/provider/mod.rs:2711 [update] OMO edit updates exactly
  the existing main row after its live-file coordination.
- src-tauri/src/services/provider/mod.rs:2740 [update] additive-provider edit
  updates exactly the existing main row after resolving live ownership.
- src-tauri/src/services/provider/mod.rs:2750 [update] switch-mode edit updates
  exactly the existing main row and never inserts.
- src-tauri/src/services/provider/mod.rs:2948 [update] remove-from-live changes
  only the existing provider's live-managed marker.
- src-tauri/src/services/provider/mod.rs:3120 [update] switch backfill updates
  only the existing current provider row.
- src-tauri/src/services/provider/mod.rs:3174 [update] successful additive
  switch changes only the existing live-managed marker.
- src-tauri/src/services/provider/mod.rs:3315 [update] common-config migration
  updates only each already-read existing row.
- src-tauri/src/services/provider/mod.rs:3895 [update] Gemini credential scrub
  updates only each already-read existing row.
- src-tauri/src/services/provider/mod.rs:4082 [update] sort ordering is routed
  to the dedicated sort-index state operation, not row replacement.
- src-tauri/src/services/provider/mod.rs:4636 [create/update] universal-to-
  Claude reconciliation reads the target and selects strict create or update.
- src-tauri/src/services/provider/mod.rs:4651 [create/update] universal-to-
  Codex reconciliation reads the target and selects strict create or update.
- src-tauri/src/services/provider/mod.rs:4665 [create/update] universal-to-
  Gemini reconciliation reads the target and selects strict create or update.

Required indirect ownership paths:

- src-tauri/src/deeplink/provider.rs [create] the old indirect flow called
  ProviderService::add and then appended endpoints one by one. It now supplies
  every non-primary endpoint to one strict aggregate create, so hydration is
  complete atomically and a duplicate is zero-side-effect.
- [restore] no old generic-save callsite is reclassified as restore. Exact
  aggregate replacement exists only as the sealed
  restore_provider_aggregate_on_tx compensation primitive.

Test-only callsites:

Every item below is classified [test]. Each is fixture setup, not a production
write authority, and is migrated to a real ProviderService entry where the
behavior is under test or to the cfg(test)-only typed fixture reconciler where
the test merely needs pre-existing rows.

- src-tauri/src/codex_history_migration.rs:1442 [test] migration fixture setup.
- src-tauri/src/codex_history_migration.rs:1452 [test] migration fixture setup.
- src-tauri/src/codex_history_migration.rs:2174 [test] migration fixture setup.
- src-tauri/src/codex_history_migration.rs:2176 [test] migration fixture setup.
- src-tauri/src/codex_history_migration.rs:2199 [test] migration fixture setup.
- src-tauri/src/codex_history_migration.rs:2219 [test] migration fixture setup.
- src-tauri/src/codex_history_migration.rs:2247 [test] migration fixture setup.
- src-tauri/src/codex_history_migration.rs:2267 [test] migration fixture setup.
- src-tauri/src/codex_history_migration.rs:2288 [test] migration fixture setup.
- src-tauri/src/codex_history_migration.rs:2320 [test] migration fixture setup.
- src-tauri/src/codex_history_migration.rs:2393 [test] migration fixture setup.
- src-tauri/src/codex_history_migration.rs:2449 [test] migration fixture setup.
- src-tauri/src/codex_history_migration.rs:2498 [test] migration fixture setup.
- src-tauri/src/codex_history_migration.rs:2555 [test] migration fixture setup.
- src-tauri/src/codex_history_migration.rs:2604 [test] migration fixture setup.
- src-tauri/src/codex_history_migration.rs:2625 [test] migration fixture setup.
- src-tauri/src/database/dao/providers.rs:754 [test] DAO fixture setup.
- src-tauri/src/proxy/provider_router.rs:351 [test] router fixture setup.
- src-tauri/src/proxy/provider_router.rs:352 [test] router fixture setup.
- src-tauri/src/proxy/provider_router.rs:377 [test] router fixture setup.
- src-tauri/src/proxy/provider_router.rs:378 [test] router fixture setup.
- src-tauri/src/proxy/provider_router.rs:410 [test] router fixture setup.
- src-tauri/src/proxy/provider_router.rs:411 [test] router fixture setup.
- src-tauri/src/proxy/provider_router.rs:447 [test] router fixture setup.
- src-tauri/src/proxy/provider_router.rs:448 [test] router fixture setup.
- src-tauri/src/proxy/provider_router.rs:488 [test] router fixture setup.
- src-tauri/src/services/provider/mod.rs:485 [test] service fixture setup.
- src-tauri/src/services/provider/mod.rs:586 [test] service fixture setup.
- src-tauri/src/services/provider/mod.rs:813 [test] service fixture setup.
- src-tauri/src/services/provider/mod.rs:825 [test] service fixture setup.
- src-tauri/src/services/provider/mod.rs:1472 [test] service fixture setup.
- src-tauri/src/services/provider/mod.rs:1607 [test] service fixture setup.
- src-tauri/src/services/provider/mod.rs:1737 [test] service fixture setup.
- src-tauri/src/services/provider/mod.rs:1945 [test] service fixture setup.
- src-tauri/src/services/provider/mod.rs:1952 [test] service fixture setup.
- src-tauri/src/services/provider/mod.rs:1978 [test] service fixture setup.
- src-tauri/src/services/provider/mod.rs:2006 [test] service fixture setup.
- src-tauri/src/services/provider/mod.rs:2056 [test] service fixture setup.
- src-tauri/src/services/provider/mod.rs:2130 [test] service fixture setup.
- src-tauri/src/services/provider/mod.rs:2167 [test] service fixture setup.
- src-tauri/src/services/provider/mod.rs:2207 [test] service fixture setup.
- src-tauri/src/services/provider/mod.rs:2235 [test] service fixture setup.
- src-tauri/src/services/provider/mod.rs:2270 [test] service fixture setup.
- src-tauri/src/services/provider/mod.rs:2320 [test] service fixture setup.
- src-tauri/src/services/provider/mod.rs:2362 [test] service fixture setup.
- src-tauri/src/services/proxy.rs:3762 [test] proxy fixture setup.
- src-tauri/src/services/proxy.rs:3948 [test] proxy fixture setup.
- src-tauri/src/services/proxy.rs:4034 [test] proxy fixture setup.
- src-tauri/src/services/proxy.rs:4095 [test] proxy fixture setup.
- src-tauri/src/services/proxy.rs:4114 [test] proxy fixture setup.
- src-tauri/src/services/proxy.rs:4263 [test] proxy fixture setup.
- src-tauri/src/services/proxy.rs:4341 [test] proxy fixture setup.
- src-tauri/src/services/proxy.rs:4421 [test] proxy fixture setup.
- src-tauri/src/services/proxy.rs:4533 [test] proxy fixture setup.
- src-tauri/src/services/proxy.rs:4651 [test] proxy fixture setup.
- src-tauri/src/services/proxy.rs:4787 [test] proxy fixture setup.
- src-tauri/src/services/proxy.rs:5264 [test] proxy fixture setup.
- src-tauri/src/services/proxy.rs:5320 [test] proxy fixture setup.
- src-tauri/src/services/proxy.rs:5385 [test] proxy fixture setup.
- src-tauri/src/services/proxy.rs:5387 [test] proxy fixture setup.
- src-tauri/src/services/proxy.rs:5460 [test] proxy fixture setup.
- src-tauri/src/services/proxy.rs:5462 [test] proxy fixture setup.
- src-tauri/src/services/proxy.rs:5611 [test] proxy fixture setup.
- src-tauri/src/services/proxy.rs:5613 [test] proxy fixture setup.
- src-tauri/src/services/proxy.rs:5615 [test] proxy fixture setup.
- src-tauri/src/services/proxy.rs:5698 [test] proxy fixture setup.
- src-tauri/src/services/proxy.rs:5700 [test] proxy fixture setup.
- src-tauri/src/services/proxy.rs:5998 [test] proxy fixture setup.
- src-tauri/src/services/proxy.rs:6000 [test] proxy fixture setup.
- src-tauri/src/services/proxy.rs:6173 [test] proxy fixture setup.
- src-tauri/src/services/proxy.rs:6175 [test] proxy fixture setup.
- src-tauri/src/services/proxy.rs:6417 [test] proxy fixture setup.
- src-tauri/src/services/proxy.rs:6419 [test] proxy fixture setup.
- src-tauri/src/services/proxy.rs:6553 [test] proxy fixture setup.
- src-tauri/src/services/proxy.rs:6555 [test] proxy fixture setup.
- src-tauri/src/services/proxy.rs:6635 [test] proxy fixture setup.
- src-tauri/src/services/proxy.rs:6637 [test] proxy fixture setup.
- src-tauri/src/services/proxy.rs:6919 [test] proxy fixture setup.
- src-tauri/src/services/proxy.rs:7173 [test] proxy fixture setup.
- src-tauri/src/services/proxy.rs:7175 [test] proxy fixture setup.
- src-tauri/src/services/proxy.rs:7240 [test] proxy fixture setup.
- src-tauri/src/services/proxy.rs:7242 [test] proxy fixture setup.
- src-tauri/tests/profile_roundtrip.rs:112 [test] profile fixture create.
- src-tauri/tests/profile_roundtrip.rs:116 [test] profile fixture create.
- src-tauri/tests/profile_roundtrip.rs:126 [test] profile fixture create.
- src-tauri/tests/profile_roundtrip.rs:133 [test] profile fixture create.
- src-tauri/tests/profile_roundtrip.rs:292 [test] profile fixture create.
- src-tauri/tests/profile_roundtrip.rs:501 [test] profile fixture create.
- src-tauri/tests/profile_roundtrip.rs:505 [test] profile fixture create.
- src-tauri/tests/profile_roundtrip.rs:670 [test] profile fixture create.
- src-tauri/tests/profile_roundtrip.rs:677 [test] profile fixture create.
- src-tauri/tests/profile_roundtrip.rs:762 [test] Linux Desktop fixture create.
- src-tauri/tests/profile_roundtrip.rs:769 [test] Linux Desktop fixture create.
- src-tauri/tests/provider_commands.rs:69 [test] command fixture create.
- src-tauri/tests/provider_service.rs:2927 [test] service fixture create.
This commit is contained in:
SaladDay
2026-07-31 17:58:07 +00:00
parent 3dc188313e
commit 4f78451405
34 changed files with 6210 additions and 831 deletions
+1
View File
@@ -783,6 +783,7 @@ dependencies = [
"indexmap 2.13.0",
"json-five",
"json5",
"libc",
"log",
"objc2 0.5.2",
"objc2-app-kit 0.2.2",
+2 -1
View File
@@ -73,11 +73,12 @@ url = "2.5"
auto-launch = "0.5"
once_cell = "1.21.3"
base64 = "0.22"
rusqlite = { version = "0.31", features = ["bundled", "backup", "hooks"] }
rusqlite = { version = "0.31", features = ["bundled", "backup", "hooks", "limits"] }
indexmap = { version = "2", features = ["serde"] }
rust_decimal = "1.33"
uuid = { version = "1.11", features = ["v4"] }
sha2 = "0.10"
libc = "0.2"
hmac = "0.12"
json5 = "0.4"
json-five = "0.3.1"
+31 -16
View File
@@ -1439,7 +1439,8 @@ base_url = "https://proxy.example/v1"
),
];
for provider in providers {
db.save_provider("codex", &provider).expect("save provider");
db.reconcile_provider_fixture("codex", &provider)
.expect("save provider");
}
let mut official = Provider::with_id(
@@ -1449,7 +1450,8 @@ base_url = "https://proxy.example/v1"
None,
);
official.category = Some("official".to_string());
db.save_provider("codex", &official).expect("save official");
db.reconcile_provider_fixture("codex", &official)
.expect("save official");
let source_provider_ids = collect_source_model_provider_ids(&db).expect("collect ids");
assert_eq!(
@@ -2171,9 +2173,10 @@ base_url = "https://proxy.example/v1"
);
official.category = Some("official".to_string());
db.save_provider("codex", &third_party)
db.reconcile_provider_fixture("codex", &third_party)
.expect("save third-party");
db.save_provider("codex", &official).expect("save official");
db.reconcile_provider_fixture("codex", &official)
.expect("save official");
let ids = collect_source_model_provider_ids(&db).expect("collect ids");
assert!(ids.contains("rightcode"));
@@ -2196,7 +2199,8 @@ base_url = "https://proxy.example/v1"
);
provider.category = Some("aggregator".to_string());
db.save_provider("codex", &provider).expect("save provider");
db.reconcile_provider_fixture("codex", &provider)
.expect("save provider");
let ids = collect_source_model_provider_ids(&db).expect("collect ids");
assert!(!ids.contains("my-private-relay"));
@@ -2216,7 +2220,8 @@ base_url = "https://proxy.example/v1"
);
provider.category = Some("aggregator".to_string());
db.save_provider("codex", &provider).expect("save provider");
db.reconcile_provider_fixture("codex", &provider)
.expect("save provider");
let ids = collect_source_model_provider_ids(&db).expect("collect ids");
assert!(!ids.contains("my-private-relay"));
@@ -2244,7 +2249,8 @@ model_provider = "my-private-relay"
);
provider.category = Some("aggregator".to_string());
db.save_provider("codex", &provider).expect("save provider");
db.reconcile_provider_fixture("codex", &provider)
.expect("save provider");
let ids = collect_source_model_provider_ids(&db).expect("collect ids");
assert!(!ids.contains("my-private-relay"));
@@ -2264,7 +2270,8 @@ model_provider = "my-private-relay"
);
provider.category = Some("aggregator".to_string());
db.save_provider("codex", &provider).expect("save provider");
db.reconcile_provider_fixture("codex", &provider)
.expect("save provider");
let ids = collect_source_model_provider_ids(&db).expect("collect ids");
assert!(ids.contains("aihubmix"));
@@ -2285,7 +2292,8 @@ model_provider = "my-private-relay"
);
provider.category = Some("aggregator".to_string());
db.save_provider("codex", &provider).expect("save provider");
db.reconcile_provider_fixture("codex", &provider)
.expect("save provider");
let ids = collect_source_model_provider_ids(&db).expect("collect ids");
assert!(ids.contains("ccswitch"));
@@ -2317,7 +2325,8 @@ model = "gpt-5.4"
}),
None,
);
db.save_provider("codex", &provider).expect("save provider");
db.reconcile_provider_fixture("codex", &provider)
.expect("save provider");
let (outcome, backup_dir) = migrate_provider_templates_for_test(&db);
assert_eq!(outcome.migrated_provider_ids, vec!["legacy".to_string()]);
@@ -2390,7 +2399,8 @@ base_url = "https://aihubmix.example/v1"
}),
None,
);
db.save_provider("codex", &provider).expect("save provider");
db.reconcile_provider_fixture("codex", &provider)
.expect("save provider");
let (outcome, _backup_dir) = migrate_provider_templates_for_test(&db);
assert_eq!(
@@ -2446,7 +2456,8 @@ base_url = "http://localhost:8080/v1"
}),
None,
);
db.save_provider("codex", &provider).expect("save provider");
db.reconcile_provider_fixture("codex", &provider)
.expect("save provider");
let (outcome, _backup_dir) = migrate_provider_templates_for_test(&db);
assert!(outcome.migrated_provider_ids.is_empty());
@@ -2495,7 +2506,8 @@ base_url = "https://proxy.example/v1"
}),
None,
);
db.save_provider("codex", &provider).expect("save provider");
db.reconcile_provider_fixture("codex", &provider)
.expect("save provider");
let (outcome, _backup_dir) = migrate_provider_templates_for_test(&db);
assert!(outcome.migrated_provider_ids.is_empty());
@@ -2552,7 +2564,8 @@ model_provider = "aihubmix"
}),
None,
);
db.save_provider("codex", &provider).expect("save provider");
db.reconcile_provider_fixture("codex", &provider)
.expect("save provider");
let (outcome, _backup_dir) = migrate_provider_templates_for_test(&db);
assert_eq!(outcome.migrated_provider_ids, vec!["profiled".to_string()]);
@@ -2601,7 +2614,8 @@ model_provider = "aihubmix"
provider.category = Some("custom".to_string());
provider.created_at = Some(1);
db.save_provider("codex", &provider).expect("save provider");
db.reconcile_provider_fixture("codex", &provider)
.expect("save provider");
let ids = collect_source_model_provider_ids(&db).expect("collect ids");
assert!(!ids.contains("my-private-relay"));
@@ -2622,7 +2636,8 @@ model_provider = "aihubmix"
);
provider.category = Some("custom".to_string());
db.save_provider("codex", &provider).expect("save provider");
db.reconcile_provider_fixture("codex", &provider)
.expect("save provider");
let ids = collect_source_model_provider_ids(&db).expect("collect ids");
assert!(!ids.contains("my-local-relay"));
+11 -4
View File
@@ -4,8 +4,9 @@ use tauri::{Emitter, Manager, State};
use crate::app_config::AppType;
use crate::commands::copilot::CopilotAuthState;
use crate::commands::xai_oauth::XaiOAuthState;
use crate::database::NewProviderAggregate;
use crate::error::AppError;
use crate::provider::{ClaudeDesktopMode, Provider};
use crate::provider::{ClaudeDesktopMode, Provider, ProviderMutationInput};
use crate::services::{
EndpointLatency, ProviderService, ProviderSortUpdate, SpeedtestService, SwitchResult,
};
@@ -39,7 +40,7 @@ pub fn get_current_provider(state: State<'_, AppState>, app: String) -> Result<S
pub fn add_provider(
state: State<'_, AppState>,
app: String,
provider: Provider,
provider: ProviderMutationInput,
#[allow(non_snake_case)] addToLive: Option<bool>,
) -> Result<bool, String> {
let app_type = AppType::from_str(&app).map_err(|e| e.to_string())?;
@@ -51,7 +52,7 @@ pub fn add_provider(
pub fn update_provider(
state: State<'_, AppState>,
app: String,
provider: Provider,
provider: ProviderMutationInput,
#[allow(non_snake_case)] originalId: Option<String>,
) -> Result<bool, String> {
let app_type = AppType::from_str(&app).map_err(|e| e.to_string())?;
@@ -250,7 +251,13 @@ pub fn import_claude_desktop_providers_from_claude(
state
.db
.save_provider(AppType::ClaudeDesktop.as_str(), &desktop_provider)
.create_provider(
NewProviderAggregate::from_input(
AppType::ClaudeDesktop.as_str(),
crate::services::provider::provider_to_mutation_input(desktop_provider),
)
.map_err(|e| e.to_string())?,
)
.map_err(|e| e.to_string())?;
imported += 1;
}
File diff suppressed because it is too large Load Diff
+3
View File
@@ -4,12 +4,15 @@
pub mod failover;
pub mod mcp;
pub mod pi_projections;
pub mod profiles;
pub mod prompts;
pub mod provider_write;
pub mod providers;
pub mod providers_seed;
pub mod proxy;
pub mod settings;
pub mod skill_deployments;
pub mod skills;
pub mod stream_check;
pub mod universal_providers;
@@ -0,0 +1,204 @@
//! Device-local ownership ledger for exact keys in Pi's shared models.json.
// The projection writer is introduced in a later contract-ordered commit.
#![allow(dead_code)]
use crate::database::{lock_conn, Database};
use crate::error::AppError;
use indexmap::IndexMap;
use rusqlite::{params, OptionalExtension};
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct PiProviderProjection {
pub provider_id: String,
pub provider_key: String,
pub created_at: i64,
pub updated_at: i64,
}
fn decode_projection(row: &rusqlite::Row<'_>) -> rusqlite::Result<PiProviderProjection> {
Ok(PiProviderProjection {
provider_id: row.get(0)?,
provider_key: row.get(1)?,
created_at: row.get(2)?,
updated_at: row.get(3)?,
})
}
impl Database {
pub(crate) fn get_pi_projection(
&self,
provider_id: &str,
) -> Result<Option<PiProviderProjection>, AppError> {
let conn = lock_conn!(self.conn);
conn.query_row(
"SELECT provider_id, provider_key, created_at, updated_at
FROM pi_provider_projections WHERE provider_id = ?1",
[provider_id],
decode_projection,
)
.optional()
.map_err(|error| AppError::Database(error.to_string()))
}
pub(crate) fn get_pi_projection_for_key(
&self,
provider_key: &str,
) -> Result<Option<PiProviderProjection>, AppError> {
let conn = lock_conn!(self.conn);
conn.query_row(
"SELECT provider_id, provider_key, created_at, updated_at
FROM pi_provider_projections WHERE provider_key = ?1",
[provider_key],
decode_projection,
)
.optional()
.map_err(|error| AppError::Database(error.to_string()))
}
pub(crate) fn get_pi_projection_manifest(
&self,
) -> Result<IndexMap<String, PiProviderProjection>, AppError> {
let conn = lock_conn!(self.conn);
let mut stmt = conn
.prepare(
"SELECT provider_id, provider_key, created_at, updated_at
FROM pi_provider_projections ORDER BY provider_id",
)
.map_err(|error| AppError::Database(error.to_string()))?;
let rows = stmt
.query_map([], decode_projection)
.map_err(|error| AppError::Database(error.to_string()))?;
let mut manifest = IndexMap::new();
for row in rows {
let projection = row.map_err(|error| AppError::Database(error.to_string()))?;
manifest.insert(projection.provider_id.clone(), projection);
}
Ok(manifest)
}
/// Claim an exact key. Existing exact claims are idempotent; either-side
/// collisions fail and are never rewritten.
pub(crate) fn claim_pi_projection_key(
&self,
provider_id: &str,
provider_key: &str,
) -> Result<PiProviderProjection, AppError> {
if provider_id.trim().is_empty() || provider_key.trim().is_empty() {
return Err(AppError::Config(
"Pi projection provider id and key must be non-empty".to_string(),
));
}
let mut conn = lock_conn!(self.conn);
let tx = conn
.transaction()
.map_err(|error| AppError::Database(error.to_string()))?;
let by_provider = tx
.query_row(
"SELECT provider_id, provider_key, created_at, updated_at
FROM pi_provider_projections WHERE provider_id = ?1",
[provider_id],
decode_projection,
)
.optional()
.map_err(|error| AppError::Database(error.to_string()))?;
if let Some(existing) = by_provider {
if existing.provider_key != provider_key {
return Err(AppError::Config(format!(
"Pi provider '{provider_id}' already owns key '{}', not '{provider_key}'",
existing.provider_key
)));
}
tx.commit()
.map_err(|error| AppError::Database(error.to_string()))?;
return Ok(existing);
}
if let Some(existing_owner) = tx
.query_row(
"SELECT provider_id FROM pi_provider_projections WHERE provider_key = ?1",
[provider_key],
|row| row.get::<_, String>(0),
)
.optional()
.map_err(|error| AppError::Database(error.to_string()))?
{
return Err(AppError::Config(format!(
"Pi key '{provider_key}' is already owned by provider '{existing_owner}'"
)));
}
let now = chrono::Utc::now().timestamp_millis();
tx.execute(
"INSERT INTO pi_provider_projections
(provider_id, provider_key, created_at, updated_at)
VALUES (?1, ?2, ?3, ?3)",
params![provider_id, provider_key, now],
)
.map_err(|error| AppError::Database(error.to_string()))?;
tx.commit()
.map_err(|error| AppError::Database(error.to_string()))?;
Ok(PiProviderProjection {
provider_id: provider_id.to_string(),
provider_key: provider_key.to_string(),
created_at: now,
updated_at: now,
})
}
pub(crate) fn delete_pi_projection_key(
&self,
provider_id: &str,
expected_key: &str,
) -> Result<bool, AppError> {
let conn = lock_conn!(self.conn);
let removed = conn
.execute(
"DELETE FROM pi_provider_projections
WHERE provider_id = ?1 AND provider_key = ?2",
params![provider_id, expected_key],
)
.map_err(|error| AppError::Database(error.to_string()))?;
if removed == 0
&& conn
.query_row(
"SELECT 1 FROM pi_provider_projections WHERE provider_id = ?1",
[provider_id],
|_| Ok(()),
)
.optional()
.map_err(|error| AppError::Database(error.to_string()))?
.is_some()
{
return Err(AppError::Config(format!(
"refusing to delete Pi projection '{provider_id}': expected key changed"
)));
}
Ok(removed == 1)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn projection_claims_are_exact_idempotent_and_collision_safe() -> Result<(), AppError> {
let db = Database::memory()?;
let first = db.claim_pi_projection_key("provider-a", "native-a")?;
let repeated = db.claim_pi_projection_key("provider-a", "native-a")?;
assert_eq!(first, repeated);
assert!(db
.claim_pi_projection_key("provider-a", "native-b")
.is_err());
assert!(db
.claim_pi_projection_key("provider-b", "native-a")
.is_err());
assert_eq!(db.get_pi_projection_manifest()?.len(), 1);
assert!(db.delete_pi_projection_key("provider-a", "wrong").is_err());
assert!(db.get_pi_projection("provider-a")?.is_some());
assert!(db.delete_pi_projection_key("provider-a", "native-a")?);
assert!(db.get_pi_projection_for_key("native-a")?.is_none());
Ok(())
}
}
@@ -0,0 +1,531 @@
use crate::database::{lock_conn, Database};
use crate::error::AppError;
use crate::provider::{ProviderMeta, ProviderMutationInput};
use crate::settings::CustomEndpoint;
use rusqlite::{params, OptionalExtension, Transaction};
use serde_json::Value;
use std::collections::HashSet;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ProviderKey {
app_type: String,
id: String,
}
impl ProviderKey {
pub fn new(app_type: impl Into<String>, id: impl Into<String>) -> Result<Self, AppError> {
let app_type = app_type.into();
let id = id.into();
if app_type.trim().is_empty() || id.trim().is_empty() {
return Err(AppError::InvalidInput(
"provider app type and id must be non-empty".to_string(),
));
}
Ok(Self { app_type, id })
}
pub fn app_type(&self) -> &str {
&self.app_type
}
pub fn id(&self) -> &str {
&self.id
}
}
#[derive(Debug, Clone)]
pub struct ProviderRowUpdate {
name: String,
settings_config: Value,
website_url: Option<String>,
category: Option<String>,
created_at: Option<i64>,
notes: Option<String>,
meta: ProviderMeta,
icon: Option<String>,
icon_color: Option<String>,
}
impl ProviderRowUpdate {
pub fn from_input(input: &ProviderMutationInput) -> Result<Self, AppError> {
let meta = input.meta.clone().unwrap_or_default();
if !meta.custom_endpoints.is_empty() {
return Err(AppError::InvalidInput(
"provider update must not contain customEndpoints; use endpoint operations"
.to_string(),
));
}
Ok(Self {
name: input.name.clone(),
settings_config: input.settings_config.clone(),
website_url: input.website_url.clone(),
category: input.category.clone(),
created_at: input.created_at,
notes: input.notes.clone(),
meta,
icon: input.icon.clone(),
icon_color: input.icon_color.clone(),
})
}
}
#[derive(Debug, Clone)]
pub struct NewEndpoint {
url: String,
added_at: Option<i64>,
last_used: Option<i64>,
}
impl NewEndpoint {
pub fn new(
url: impl Into<String>,
added_at: Option<i64>,
last_used: Option<i64>,
) -> Result<Self, AppError> {
let url = url.into();
if url.trim().is_empty() {
return Err(AppError::InvalidInput(
"provider endpoint URL cannot be empty".to_string(),
));
}
Ok(Self {
url,
added_at,
last_used,
})
}
pub fn now(url: impl Into<String>) -> Result<Self, AppError> {
Self::new(url, Some(chrono::Utc::now().timestamp_millis()), None)
}
}
impl TryFrom<CustomEndpoint> for NewEndpoint {
type Error = AppError;
fn try_from(endpoint: CustomEndpoint) -> Result<Self, Self::Error> {
Self::new(endpoint.url, endpoint.added_at, endpoint.last_used)
}
}
#[derive(Debug, Clone)]
pub struct NewProviderAggregate {
key: ProviderKey,
row: ProviderRowUpdate,
sort_index: Option<usize>,
in_failover_queue: bool,
initial_endpoints: Vec<NewEndpoint>,
}
impl NewProviderAggregate {
pub fn from_input(app_type: &str, mut input: ProviderMutationInput) -> Result<Self, AppError> {
let endpoints = input
.meta
.as_mut()
.map(|meta| std::mem::take(&mut meta.custom_endpoints))
.unwrap_or_default();
let mut seen = HashSet::with_capacity(endpoints.len());
let mut initial_endpoints = Vec::with_capacity(endpoints.len());
for (key, endpoint) in endpoints {
if key != endpoint.url {
return Err(AppError::InvalidInput(format!(
"provider endpoint key '{key}' must match endpoint URL '{}'",
endpoint.url
)));
}
if !seen.insert(endpoint.url.clone()) {
return Err(AppError::InvalidInput(format!(
"duplicate initial provider endpoint '{}'",
endpoint.url
)));
}
initial_endpoints.push(endpoint.try_into()?);
}
let key = ProviderKey::new(app_type, input.id.clone())?;
let row = ProviderRowUpdate::from_input(&input)?;
Ok(Self {
key,
row,
sort_index: input.sort_index,
in_failover_queue: input.in_failover_queue,
initial_endpoints,
})
}
}
#[derive(Debug, Clone)]
pub struct RenameProvider {
source: ProviderKey,
target_id: String,
row: ProviderRowUpdate,
}
impl RenameProvider {
pub fn from_input(
source: ProviderKey,
input: &ProviderMutationInput,
) -> Result<Self, AppError> {
if !matches!(source.app_type(), "opencode" | "openclaw") {
return Err(AppError::InvalidInput(
"provider key changes are restricted to additive OpenCode/OpenClaw providers"
.to_string(),
));
}
if source.id() == input.id {
return Err(AppError::InvalidInput(
"provider rename requires a different target id".to_string(),
));
}
if input.id.trim().is_empty() {
return Err(AppError::InvalidInput(
"provider target id must be non-empty".to_string(),
));
}
Ok(Self {
source,
target_id: input.id.clone(),
row: ProviderRowUpdate::from_input(input)?,
})
}
}
fn encode_row(row: &ProviderRowUpdate) -> Result<(String, String), AppError> {
let settings_config = serde_json::to_string(&row.settings_config).map_err(|error| {
AppError::Database(format!("failed to serialize settings_config: {error}"))
})?;
let meta = serde_json::to_string(&row.meta).map_err(|error| {
AppError::Database(format!("failed to serialize provider meta: {error}"))
})?;
Ok((settings_config, meta))
}
fn insert_row(
tx: &Transaction<'_>,
key: &ProviderKey,
row: &ProviderRowUpdate,
sort_index: Option<usize>,
is_current: bool,
in_failover_queue: bool,
) -> Result<(), AppError> {
let (settings_config, meta) = encode_row(row)?;
tx.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
) VALUES (
?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14
)",
params![
key.id,
key.app_type,
row.name,
settings_config,
row.website_url,
row.category,
row.created_at,
sort_index,
row.notes,
row.icon,
row.icon_color,
meta,
is_current,
in_failover_queue,
],
)
.map_err(|error| AppError::Database(error.to_string()))?;
Ok(())
}
fn insert_endpoint(
tx: &Transaction<'_>,
key: &ProviderKey,
endpoint: &NewEndpoint,
) -> Result<(), AppError> {
tx.execute(
"INSERT INTO provider_endpoints
(provider_id, app_type, url, added_at, last_used)
VALUES (?1, ?2, ?3, ?4, ?5)",
params![
key.id,
key.app_type,
endpoint.url,
endpoint.added_at,
endpoint.last_used
],
)
.map_err(|error| AppError::Database(error.to_string()))?;
Ok(())
}
/// Exact aggregate replacement is sealed inside the DAO parent module. The
/// catalog compensation coordinator introduced with the ordered mutation
/// pipeline is the only intended caller.
#[allow(dead_code)]
pub(super) fn restore_provider_aggregate_on_tx(
tx: &Transaction<'_>,
key: &ProviderKey,
row: &ProviderRowUpdate,
sort_index: Option<usize>,
is_current: bool,
in_failover_queue: bool,
endpoints: &[NewEndpoint],
) -> Result<(), AppError> {
let updated = update_row(tx, key, row)?;
if updated != 1 {
return Err(AppError::NotFound(format!(
"provider '{}/{}'",
key.app_type, key.id
)));
}
tx.execute(
"DELETE FROM provider_endpoints WHERE provider_id = ?1 AND app_type = ?2",
params![key.id, key.app_type],
)
.map_err(|error| AppError::Database(error.to_string()))?;
for endpoint in endpoints {
insert_endpoint(tx, key, endpoint)?;
}
// State and order are maintained by their dedicated authorities. Exact
// compensation may restore their captured values without exposing them in
// ProviderRowUpdate.
tx.execute(
"UPDATE providers
SET sort_index = ?1, is_current = ?2, in_failover_queue = ?3
WHERE id = ?4 AND app_type = ?5",
params![
sort_index,
is_current,
in_failover_queue,
key.id,
key.app_type
],
)
.map_err(|error| AppError::Database(error.to_string()))?;
Ok(())
}
fn update_row(
tx: &Transaction<'_>,
key: &ProviderKey,
row: &ProviderRowUpdate,
) -> Result<usize, AppError> {
let (settings_config, meta) = encode_row(row)?;
tx.execute(
"UPDATE providers SET
name = ?1,
settings_config = ?2,
website_url = ?3,
category = ?4,
created_at = ?5,
notes = ?6,
icon = ?7,
icon_color = ?8,
meta = ?9
WHERE id = ?10 AND app_type = ?11",
params![
row.name,
settings_config,
row.website_url,
row.category,
row.created_at,
row.notes,
row.icon,
row.icon_color,
meta,
key.id,
key.app_type,
],
)
.map_err(|error| AppError::Database(error.to_string()))
}
impl Database {
pub fn create_provider(&self, input: NewProviderAggregate) -> Result<(), AppError> {
let mut conn = lock_conn!(self.conn);
let tx = conn
.transaction()
.map_err(|error| AppError::Database(error.to_string()))?;
insert_row(
&tx,
&input.key,
&input.row,
input.sort_index,
false,
input.in_failover_queue,
)?;
for endpoint in &input.initial_endpoints {
insert_endpoint(&tx, &input.key, endpoint)?;
}
tx.commit()
.map_err(|error| AppError::Database(error.to_string()))
}
pub fn update_provider(
&self,
key: &ProviderKey,
row: &ProviderRowUpdate,
) -> Result<(), AppError> {
let mut conn = lock_conn!(self.conn);
let tx = conn
.transaction()
.map_err(|error| AppError::Database(error.to_string()))?;
if update_row(&tx, key, row)? != 1 {
return Err(AppError::NotFound(format!(
"provider '{}/{}'",
key.app_type, key.id
)));
}
tx.commit()
.map_err(|error| AppError::Database(error.to_string()))
}
pub fn rename_db_only_additive_provider(&self, input: RenameProvider) -> Result<(), AppError> {
let mut conn = lock_conn!(self.conn);
let tx = conn
.transaction()
.map_err(|error| AppError::Database(error.to_string()))?;
let source_state = tx
.query_row(
"SELECT sort_index, is_current, in_failover_queue, category
FROM providers
WHERE id = ?1 AND app_type = ?2",
params![input.source.id, input.source.app_type],
|row| {
Ok((
row.get::<_, Option<usize>>(0)?,
row.get::<_, bool>(1)?,
row.get::<_, bool>(2)?,
row.get::<_, Option<String>>(3)?,
))
},
)
.optional()
.map_err(|error| AppError::Database(error.to_string()))?
.ok_or_else(|| {
AppError::NotFound(format!(
"provider '{}/{}'",
input.source.app_type, input.source.id
))
})?;
if matches!(source_state.3.as_deref(), Some("omo" | "omo-slim")) {
return Err(AppError::InvalidInput(
"OMO/OMO Slim providers cannot be renamed".to_string(),
));
}
let target = ProviderKey::new(&input.source.app_type, &input.target_id)?;
insert_row(
&tx,
&target,
&input.row,
source_state.0,
source_state.1,
source_state.2,
)?;
tx.execute(
"INSERT INTO provider_endpoints
(provider_id, app_type, url, added_at, last_used)
SELECT ?1, app_type, url, added_at, last_used
FROM provider_endpoints
WHERE provider_id = ?2 AND app_type = ?3
ORDER BY id",
params![target.id, input.source.id, input.source.app_type],
)
.map_err(|error| AppError::Database(error.to_string()))?;
if tx
.execute(
"DELETE FROM providers WHERE id = ?1 AND app_type = ?2",
params![input.source.id, input.source.app_type],
)
.map_err(|error| AppError::Database(error.to_string()))?
!= 1
{
return Err(AppError::NotFound(format!(
"provider '{}/{}'",
input.source.app_type, input.source.id
)));
}
tx.commit()
.map_err(|error| AppError::Database(error.to_string()))
}
pub fn add_provider_endpoint(
&self,
key: &ProviderKey,
endpoint: NewEndpoint,
) -> Result<(), AppError> {
let mut conn = lock_conn!(self.conn);
let tx = conn
.transaction()
.map_err(|error| AppError::Database(error.to_string()))?;
insert_endpoint(&tx, key, &endpoint)?;
tx.commit()
.map_err(|error| AppError::Database(error.to_string()))
}
pub fn remove_provider_endpoint(&self, key: &ProviderKey, url: &str) -> Result<(), AppError> {
let conn = lock_conn!(self.conn);
if conn
.execute(
"DELETE FROM provider_endpoints
WHERE provider_id = ?1 AND app_type = ?2 AND url = ?3",
params![key.id, key.app_type, url],
)
.map_err(|error| AppError::Database(error.to_string()))?
!= 1
{
return Err(AppError::NotFound(format!(
"provider endpoint '{}/{}/{}'",
key.app_type, key.id, url
)));
}
Ok(())
}
pub fn touch_provider_endpoint(
&self,
key: &ProviderKey,
url: &str,
at: i64,
) -> Result<(), AppError> {
let conn = lock_conn!(self.conn);
if conn
.execute(
"UPDATE provider_endpoints
SET last_used = ?1
WHERE provider_id = ?2 AND app_type = ?3 AND url = ?4",
params![at, key.id, key.app_type, url],
)
.map_err(|error| AppError::Database(error.to_string()))?
!= 1
{
return Err(AppError::NotFound(format!(
"provider endpoint '{}/{}/{}'",
key.app_type, key.id, url
)));
}
Ok(())
}
pub(crate) fn update_provider_sort_index(
&self,
key: &ProviderKey,
sort_index: usize,
) -> Result<(), AppError> {
let conn = lock_conn!(self.conn);
if conn
.execute(
"UPDATE providers SET sort_index = ?1 WHERE id = ?2 AND app_type = ?3",
params![sort_index, key.id, key.app_type],
)
.map_err(|error| AppError::Database(error.to_string()))?
!= 1
{
return Err(AppError::NotFound(format!(
"provider '{}/{}'",
key.app_type, key.id
)));
}
Ok(())
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,208 @@
//! Device-local evidence for Pi Skill deployments.
// Pi skill reconciliation consumes this ledger in a later contract-ordered commit.
#![allow(dead_code)]
use crate::database::{lock_conn, Database};
use crate::error::AppError;
use rusqlite::{params, OptionalExtension};
use serde::{Deserialize, Serialize};
use std::str::FromStr;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub(crate) enum SkillDeploymentMethod {
Symlink,
Copy,
}
impl SkillDeploymentMethod {
pub(crate) const fn as_str(self) -> &'static str {
match self {
Self::Symlink => "symlink",
Self::Copy => "copy",
}
}
}
impl FromStr for SkillDeploymentMethod {
type Err = AppError;
fn from_str(value: &str) -> Result<Self, Self::Err> {
match value {
"symlink" => Ok(Self::Symlink),
"copy" => Ok(Self::Copy),
_ => Err(AppError::Database(format!(
"unknown Pi Skill deployment method '{value}'"
))),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct SkillDeployment {
pub skill_id: String,
pub destination: String,
pub destination_key: String,
pub method: SkillDeploymentMethod,
pub source_identity: String,
pub deployed_digest: Option<String>,
pub created_at: i64,
pub updated_at: i64,
}
fn decode_deployment(row: &rusqlite::Row<'_>) -> rusqlite::Result<SkillDeployment> {
let method: String = row.get(3)?;
let method = method.parse().map_err(|error: AppError| {
rusqlite::Error::FromSqlConversionFailure(3, rusqlite::types::Type::Text, Box::new(error))
})?;
Ok(SkillDeployment {
skill_id: row.get(0)?,
destination: row.get(1)?,
destination_key: row.get(2)?,
method,
source_identity: row.get(4)?,
deployed_digest: row.get(5)?,
created_at: row.get(6)?,
updated_at: row.get(7)?,
})
}
impl Database {
pub(crate) fn get_pi_skill_deployment(
&self,
skill_id: &str,
destination_key: &str,
) -> Result<Option<SkillDeployment>, AppError> {
let conn = lock_conn!(self.conn);
conn.query_row(
"SELECT skill_id, destination, destination_key, method,
source_identity, deployed_digest, created_at, updated_at
FROM skill_deployments
WHERE app_type = 'pi' AND skill_id = ?1 AND destination_key = ?2",
params![skill_id, destination_key],
decode_deployment,
)
.optional()
.map_err(|error| AppError::Database(error.to_string()))
}
pub(crate) fn get_pi_skill_deployments(
&self,
skill_id: &str,
) -> Result<Vec<SkillDeployment>, AppError> {
let conn = lock_conn!(self.conn);
let mut stmt = conn
.prepare(
"SELECT skill_id, destination, destination_key, method,
source_identity, deployed_digest, created_at, updated_at
FROM skill_deployments
WHERE app_type = 'pi' AND skill_id = ?1
ORDER BY created_at, destination_key",
)
.map_err(|error| AppError::Database(error.to_string()))?;
let rows = stmt
.query_map([skill_id], decode_deployment)
.map_err(|error| AppError::Database(error.to_string()))?;
rows.map(|row| row.map_err(|error| AppError::Database(error.to_string())))
.collect()
}
pub(crate) fn save_pi_skill_deployment(
&self,
deployment: &SkillDeployment,
) -> Result<(), AppError> {
if deployment.skill_id.trim().is_empty()
|| deployment.destination.trim().is_empty()
|| deployment.destination_key.trim().is_empty()
|| deployment.source_identity.trim().is_empty()
{
return Err(AppError::Config(
"Pi Skill deployment identity fields must be non-empty".to_string(),
));
}
let conn = lock_conn!(self.conn);
conn.execute(
"INSERT INTO skill_deployments (
app_type, skill_id, destination, destination_key, method,
source_identity, deployed_digest, created_at, updated_at
) VALUES ('pi', ?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)
ON CONFLICT(app_type, skill_id, destination_key) DO UPDATE SET
destination = excluded.destination,
method = excluded.method,
source_identity = excluded.source_identity,
deployed_digest = excluded.deployed_digest,
updated_at = excluded.updated_at",
params![
deployment.skill_id,
deployment.destination,
deployment.destination_key,
deployment.method.as_str(),
deployment.source_identity,
deployment.deployed_digest,
deployment.created_at,
deployment.updated_at,
],
)
.map_err(|error| AppError::Database(error.to_string()))?;
Ok(())
}
pub(crate) fn delete_pi_skill_deployment(
&self,
skill_id: &str,
destination_key: &str,
) -> Result<bool, AppError> {
let conn = lock_conn!(self.conn);
conn.execute(
"DELETE FROM skill_deployments
WHERE app_type = 'pi' AND skill_id = ?1 AND destination_key = ?2",
params![skill_id, destination_key],
)
.map(|count| count == 1)
.map_err(|error| AppError::Database(error.to_string()))
}
}
#[cfg(test)]
mod tests {
use super::*;
fn deployment(skill_id: &str, destination_key: &str) -> SkillDeployment {
SkillDeployment {
skill_id: skill_id.into(),
destination: format!("/tmp/{destination_key}"),
destination_key: destination_key.into(),
method: SkillDeploymentMethod::Copy,
source_identity: format!("source:{skill_id}"),
deployed_digest: Some("sha256:initial".into()),
created_at: 10,
updated_at: 10,
}
}
#[test]
fn skill_ledger_preserves_created_at_and_rejects_destination_collision() -> Result<(), AppError>
{
let db = Database::memory()?;
db.save_pi_skill_deployment(&deployment("one", "destination"))?;
let mut updated = deployment("one", "destination");
updated.updated_at = 20;
updated.deployed_digest = Some("sha256:updated".into());
db.save_pi_skill_deployment(&updated)?;
let saved = db
.get_pi_skill_deployment("one", "destination")?
.expect("deployment");
assert_eq!(saved.created_at, 10);
assert_eq!(saved.updated_at, 20);
assert_eq!(saved.deployed_digest.as_deref(), Some("sha256:updated"));
assert!(db
.save_pi_skill_deployment(&deployment("two", "destination"))
.is_err());
assert_eq!(db.get_pi_skill_deployments("one")?.len(), 1);
assert!(db.delete_pi_skill_deployment("one", "destination")?);
Ok(())
}
}
+68 -2
View File
@@ -109,11 +109,28 @@ impl Database {
pub fn save_skill(&self, skill: &InstalledSkill) -> Result<(), AppError> {
let conn = lock_conn!(self.conn);
conn.execute(
"INSERT OR REPLACE INTO skills
"INSERT INTO skills
(id, name, description, directory, repo_owner, repo_name, repo_branch,
readme_url, enabled_claude, enabled_codex, enabled_gemini, enabled_grokbuild, enabled_opencode, enabled_hermes,
installed_at, content_hash, updated_at)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17)",
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17)
ON CONFLICT(id) DO UPDATE SET
name = excluded.name,
description = excluded.description,
directory = excluded.directory,
repo_owner = excluded.repo_owner,
repo_name = excluded.repo_name,
repo_branch = excluded.repo_branch,
readme_url = excluded.readme_url,
enabled_claude = excluded.enabled_claude,
enabled_codex = excluded.enabled_codex,
enabled_gemini = excluded.enabled_gemini,
enabled_grokbuild = excluded.enabled_grokbuild,
enabled_opencode = excluded.enabled_opencode,
enabled_hermes = excluded.enabled_hermes,
installed_at = excluded.installed_at,
content_hash = excluded.content_hash,
updated_at = excluded.updated_at",
params![
skill.id,
skill.name,
@@ -262,3 +279,52 @@ impl Database {
Ok(count)
}
}
#[cfg(test)]
mod tests {
use super::*;
fn installed_skill() -> InstalledSkill {
InstalledSkill {
id: "owner/repo:skill".into(),
name: "Skill".into(),
description: Some("before".into()),
directory: "skill".into(),
repo_owner: Some("owner".into()),
repo_name: Some("repo".into()),
repo_branch: Some("main".into()),
readme_url: None,
apps: SkillApps::default(),
installed_at: 10,
content_hash: Some("sha256:before".into()),
updated_at: 11,
}
}
#[test]
fn legacy_skill_save_preserves_pi_desired_state() -> Result<(), AppError> {
let db = Database::memory()?;
let mut skill = installed_skill();
db.save_skill(&skill)?;
{
let conn = lock_conn!(db.conn);
conn.execute(
"UPDATE skills SET enabled_pi = 1 WHERE id = ?1",
[&skill.id],
)?;
}
skill.name = "Updated".into();
skill.content_hash = Some("sha256:after".into());
db.save_skill(&skill)?;
let conn = lock_conn!(db.conn);
let saved: (String, String, bool) = conn.query_row(
"SELECT name, content_hash, enabled_pi FROM skills WHERE id = ?1",
[&skill.id],
|row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)),
)?;
assert_eq!(saved, ("Updated".into(), "sha256:after".into(), true));
Ok(())
}
}
+45 -1
View File
@@ -32,6 +32,9 @@ mod schema;
mod tests;
// DAO 类型导出供外部使用
pub use dao::provider_write::{
NewEndpoint, NewProviderAggregate, ProviderKey, ProviderRowUpdate, RenameProvider,
};
pub(crate) use dao::providers_seed::{
is_official_seed_id, CLAUDE_DESKTOP_OFFICIAL_PROVIDER_ID, CODEX_OFFICIAL_PROVIDER_ID,
GROKBUILD_OFFICIAL_PROVIDER_ID,
@@ -53,7 +56,7 @@ use std::sync::Mutex;
/// 当前 Schema 版本号
/// 每次修改表结构时递增,并在 schema.rs 中添加相应的迁移逻辑
pub(crate) const SCHEMA_VERSION: i32 = 16;
pub(crate) const SCHEMA_VERSION: i32 = 17;
/// 安全地序列化 JSON,避免 unwrap panic
pub(crate) fn to_json_string<T: Serialize>(value: &T) -> Result<String, AppError> {
@@ -197,6 +200,11 @@ impl Database {
conn: Mutex::new(conn),
};
db.create_tables()?;
// Keep the test database structurally identical to a fresh production
// database. Marking the base DDL as current without running the
// migration chain creates a false-current schema and makes restore
// tests certify columns that do not actually exist.
db.apply_schema_migrations()?;
db.ensure_model_pricing_seeded()?;
Ok(db)
@@ -293,3 +301,39 @@ impl Database {
Ok(count == 0)
}
}
#[cfg(test)]
impl Database {
/// Test-fixture reconciliation helper. Production code cannot call this:
/// provider writes there must choose a typed create or update operation.
pub(crate) fn reconcile_provider_fixture(
&self,
app_type: &str,
provider: &crate::provider::Provider,
) -> Result<(), AppError> {
let mut input = crate::provider::ProviderMutationInput {
id: provider.id.clone(),
name: provider.name.clone(),
settings_config: provider.settings_config.clone(),
website_url: provider.website_url.clone(),
category: provider.category.clone(),
created_at: provider.created_at,
sort_index: provider.sort_index,
notes: provider.notes.clone(),
meta: provider.meta.clone(),
icon: provider.icon.clone(),
icon_color: provider.icon_color.clone(),
in_failover_queue: provider.in_failover_queue,
};
if self.get_provider_aggregate(app_type, &input.id)?.is_some() {
if let Some(meta) = input.meta.as_mut() {
meta.custom_endpoints.clear();
}
let key = ProviderKey::new(app_type, input.id.clone())?;
let row = ProviderRowUpdate::from_input(&input)?;
self.update_provider(&key, &row)
} else {
self.create_provider(NewProviderAggregate::from_input(app_type, input)?)
}
}
}
+808 -35
View File
@@ -6,6 +6,498 @@ use super::{lock_conn, Database, SCHEMA_VERSION};
use crate::error::AppError;
use rusqlite::{params, Connection};
use serde::Serialize;
use tempfile::NamedTempFile;
/// A disk-backed database whose schema was created from this binary's current
/// schema code. Its fields are intentionally private: untrusted connections
/// cannot be wrapped or converted into a publishable stage.
pub(super) struct CanonicalStage {
connection: Connection,
_file: NamedTempFile,
}
impl CanonicalStage {
pub(super) fn connection(&self) -> &Connection {
&self.connection
}
pub(super) fn connection_mut(&mut self) -> &mut Connection {
&mut self.connection
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "snake_case")]
pub(crate) enum CanonicalRestoreClass {
MigrateAndValidate,
RebuildAndPreserveLocal,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct CanonicalColumnSpec {
pub name: &'static str,
pub data_type: &'static str,
pub not_null: bool,
pub default: Option<&'static str>,
pub pk_position: i64,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct CanonicalIndexedColumnSpec {
pub name: &'static str,
pub collation: &'static str,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct CanonicalUniqueSpec {
pub columns: &'static [CanonicalIndexedColumnSpec],
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct CanonicalForeignKeySpec {
pub from: &'static [&'static str],
pub table: &'static str,
pub to: &'static [&'static str],
pub on_update: &'static str,
pub on_delete: &'static str,
pub match_type: &'static str,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "snake_case")]
pub(crate) enum SchemaInvariantKind {
EndpointIdentityUnique,
EndpointParentForeignKey,
EndpointDeleteCascade,
ProjectionProviderKeyUnique,
SkillAppTypePiOnly,
SkillMethodAllowed,
SkillDestinationOwnedOnce,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct SchemaInvariantSpec {
pub name: &'static str,
pub kind: SchemaInvariantKind,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) struct CanonicalTableSpec {
pub name: &'static str,
pub definition: &'static str,
pub restore_class: CanonicalRestoreClass,
pub columns: &'static [CanonicalColumnSpec],
pub unique_tuples: &'static [CanonicalUniqueSpec],
pub foreign_keys: &'static [CanonicalForeignKeySpec],
pub invariants: &'static [SchemaInvariantSpec],
}
const PROVIDERS_COLUMNS: &[CanonicalColumnSpec] = &[
CanonicalColumnSpec {
name: "id",
data_type: "TEXT",
not_null: true,
default: None,
pk_position: 1,
},
CanonicalColumnSpec {
name: "app_type",
data_type: "TEXT",
not_null: true,
default: None,
pk_position: 2,
},
CanonicalColumnSpec {
name: "name",
data_type: "TEXT",
not_null: true,
default: None,
pk_position: 0,
},
CanonicalColumnSpec {
name: "settings_config",
data_type: "TEXT",
not_null: true,
default: None,
pk_position: 0,
},
CanonicalColumnSpec {
name: "website_url",
data_type: "TEXT",
not_null: false,
default: None,
pk_position: 0,
},
CanonicalColumnSpec {
name: "category",
data_type: "TEXT",
not_null: false,
default: None,
pk_position: 0,
},
CanonicalColumnSpec {
name: "created_at",
data_type: "INTEGER",
not_null: false,
default: None,
pk_position: 0,
},
CanonicalColumnSpec {
name: "sort_index",
data_type: "INTEGER",
not_null: false,
default: None,
pk_position: 0,
},
CanonicalColumnSpec {
name: "notes",
data_type: "TEXT",
not_null: false,
default: None,
pk_position: 0,
},
CanonicalColumnSpec {
name: "icon",
data_type: "TEXT",
not_null: false,
default: None,
pk_position: 0,
},
CanonicalColumnSpec {
name: "icon_color",
data_type: "TEXT",
not_null: false,
default: None,
pk_position: 0,
},
CanonicalColumnSpec {
name: "meta",
data_type: "TEXT",
not_null: true,
default: Some("'{}'"),
pk_position: 0,
},
CanonicalColumnSpec {
name: "is_current",
data_type: "BOOLEAN",
not_null: true,
default: Some("0"),
pk_position: 0,
},
CanonicalColumnSpec {
name: "in_failover_queue",
data_type: "BOOLEAN",
not_null: true,
default: Some("0"),
pk_position: 0,
},
];
const PROVIDER_ENDPOINT_COLUMNS: &[CanonicalColumnSpec] = &[
CanonicalColumnSpec {
name: "id",
data_type: "INTEGER",
not_null: false,
default: None,
pk_position: 1,
},
CanonicalColumnSpec {
name: "provider_id",
data_type: "TEXT",
not_null: true,
default: None,
pk_position: 0,
},
CanonicalColumnSpec {
name: "app_type",
data_type: "TEXT",
not_null: true,
default: None,
pk_position: 0,
},
CanonicalColumnSpec {
name: "url",
data_type: "TEXT",
not_null: true,
default: None,
pk_position: 0,
},
CanonicalColumnSpec {
name: "added_at",
data_type: "INTEGER",
not_null: false,
default: None,
pk_position: 0,
},
CanonicalColumnSpec {
name: "last_used",
data_type: "INTEGER",
not_null: false,
default: None,
pk_position: 0,
},
];
const PI_PROJECTION_COLUMNS: &[CanonicalColumnSpec] = &[
CanonicalColumnSpec {
name: "provider_id",
data_type: "TEXT",
not_null: false,
default: None,
pk_position: 1,
},
CanonicalColumnSpec {
name: "provider_key",
data_type: "TEXT",
not_null: true,
default: None,
pk_position: 0,
},
CanonicalColumnSpec {
name: "created_at",
data_type: "INTEGER",
not_null: true,
default: None,
pk_position: 0,
},
CanonicalColumnSpec {
name: "updated_at",
data_type: "INTEGER",
not_null: true,
default: None,
pk_position: 0,
},
];
const SKILL_DEPLOYMENT_COLUMNS: &[CanonicalColumnSpec] = &[
CanonicalColumnSpec {
name: "app_type",
data_type: "TEXT",
not_null: true,
default: None,
pk_position: 1,
},
CanonicalColumnSpec {
name: "skill_id",
data_type: "TEXT",
not_null: true,
default: None,
pk_position: 2,
},
CanonicalColumnSpec {
name: "destination",
data_type: "TEXT",
not_null: true,
default: None,
pk_position: 0,
},
CanonicalColumnSpec {
name: "destination_key",
data_type: "TEXT",
not_null: true,
default: None,
pk_position: 3,
},
CanonicalColumnSpec {
name: "method",
data_type: "TEXT",
not_null: true,
default: None,
pk_position: 0,
},
CanonicalColumnSpec {
name: "source_identity",
data_type: "TEXT",
not_null: true,
default: None,
pk_position: 0,
},
CanonicalColumnSpec {
name: "deployed_digest",
data_type: "TEXT",
not_null: false,
default: None,
pk_position: 0,
},
CanonicalColumnSpec {
name: "created_at",
data_type: "INTEGER",
not_null: true,
default: None,
pk_position: 0,
},
CanonicalColumnSpec {
name: "updated_at",
data_type: "INTEGER",
not_null: true,
default: None,
pk_position: 0,
},
];
const ENDPOINT_IDENTITY_COLUMNS: &[CanonicalIndexedColumnSpec] = &[
CanonicalIndexedColumnSpec {
name: "provider_id",
collation: "BINARY",
},
CanonicalIndexedColumnSpec {
name: "app_type",
collation: "BINARY",
},
CanonicalIndexedColumnSpec {
name: "url",
collation: "BINARY",
},
];
const PROJECTION_KEY_COLUMNS: &[CanonicalIndexedColumnSpec] = &[CanonicalIndexedColumnSpec {
name: "provider_key",
collation: "BINARY",
}];
const SKILL_DESTINATION_COLUMNS: &[CanonicalIndexedColumnSpec] = &[
CanonicalIndexedColumnSpec {
name: "app_type",
collation: "BINARY",
},
CanonicalIndexedColumnSpec {
name: "destination_key",
collation: "BINARY",
},
];
const ENDPOINT_FOREIGN_KEYS: &[CanonicalForeignKeySpec] = &[CanonicalForeignKeySpec {
from: &["provider_id", "app_type"],
table: "providers",
to: &["id", "app_type"],
on_update: "NO ACTION",
on_delete: "CASCADE",
match_type: "NONE",
}];
const ENDPOINT_INVARIANTS: &[SchemaInvariantSpec] = &[
SchemaInvariantSpec {
name: "endpoint_identity_unique",
kind: SchemaInvariantKind::EndpointIdentityUnique,
},
SchemaInvariantSpec {
name: "endpoint_parent_fk",
kind: SchemaInvariantKind::EndpointParentForeignKey,
},
SchemaInvariantSpec {
name: "endpoint_delete_cascade",
kind: SchemaInvariantKind::EndpointDeleteCascade,
},
];
const PROJECTION_INVARIANTS: &[SchemaInvariantSpec] = &[SchemaInvariantSpec {
name: "provider_key_unique",
kind: SchemaInvariantKind::ProjectionProviderKeyUnique,
}];
const SKILL_INVARIANTS: &[SchemaInvariantSpec] = &[
SchemaInvariantSpec {
name: "app_type_pi_only",
kind: SchemaInvariantKind::SkillAppTypePiOnly,
},
SchemaInvariantSpec {
name: "method_symlink_or_copy",
kind: SchemaInvariantKind::SkillMethodAllowed,
},
SchemaInvariantSpec {
name: "destination_owned_once",
kind: SchemaInvariantKind::SkillDestinationOwnedOnce,
},
];
pub(crate) const CANONICAL_TABLE_SPECS: &[CanonicalTableSpec] = &[
CanonicalTableSpec {
name: "providers",
definition: "providers (
id TEXT NOT NULL,
app_type TEXT NOT NULL,
name TEXT NOT NULL,
settings_config TEXT NOT NULL,
website_url TEXT,
category TEXT,
created_at INTEGER,
sort_index INTEGER,
notes TEXT,
icon TEXT,
icon_color TEXT,
meta TEXT NOT NULL DEFAULT '{}',
is_current BOOLEAN NOT NULL DEFAULT 0,
in_failover_queue BOOLEAN NOT NULL DEFAULT 0,
PRIMARY KEY (id, app_type)
)",
restore_class: CanonicalRestoreClass::MigrateAndValidate,
columns: PROVIDERS_COLUMNS,
unique_tuples: &[],
foreign_keys: &[],
invariants: &[],
},
CanonicalTableSpec {
name: "provider_endpoints",
definition: "provider_endpoints (
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)
)",
restore_class: CanonicalRestoreClass::MigrateAndValidate,
columns: PROVIDER_ENDPOINT_COLUMNS,
unique_tuples: &[CanonicalUniqueSpec {
columns: ENDPOINT_IDENTITY_COLUMNS,
}],
foreign_keys: ENDPOINT_FOREIGN_KEYS,
invariants: ENDPOINT_INVARIANTS,
},
CanonicalTableSpec {
name: "pi_provider_projections",
definition: "pi_provider_projections (
provider_id TEXT PRIMARY KEY,
provider_key TEXT NOT NULL UNIQUE,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
)",
restore_class: CanonicalRestoreClass::RebuildAndPreserveLocal,
columns: PI_PROJECTION_COLUMNS,
unique_tuples: &[CanonicalUniqueSpec {
columns: PROJECTION_KEY_COLUMNS,
}],
foreign_keys: &[],
invariants: PROJECTION_INVARIANTS,
},
CanonicalTableSpec {
name: "skill_deployments",
definition: "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)
)",
restore_class: CanonicalRestoreClass::RebuildAndPreserveLocal,
columns: SKILL_DEPLOYMENT_COLUMNS,
unique_tuples: &[CanonicalUniqueSpec {
columns: SKILL_DESTINATION_COLUMNS,
}],
foreign_keys: &[],
invariants: SKILL_INVARIANTS,
},
];
#[derive(Serialize)]
struct LegacySkillMigrationRow {
@@ -14,6 +506,77 @@ struct LegacySkillMigrationRow {
}
impl Database {
/// Construct a publish-capable stage from an empty disk file using only
/// this binary's schema and migration code.
pub(super) fn current_canonical_stage() -> Result<CanonicalStage, AppError> {
let file = NamedTempFile::new().map_err(|error| AppError::IoContext {
context: "create canonical restore stage".to_string(),
source: error,
})?;
let connection =
Connection::open(file.path()).map_err(|error| AppError::Database(error.to_string()))?;
connection
.execute_batch(
"PRAGMA foreign_keys = ON;
PRAGMA trusted_schema = OFF;",
)
.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.
Self::create_tables_on_conn(&connection)?;
Self::set_user_version(&connection, 0)?;
Self::apply_schema_migrations_on_conn(&connection)?;
if Self::get_user_version(&connection)? != SCHEMA_VERSION {
return Err(AppError::Database(
"canonical stage factory did not reach the current schema version".to_string(),
));
}
Ok(CanonicalStage {
connection,
_file: file,
})
}
pub(crate) fn canonical_table_spec(
name: &str,
) -> Result<&'static CanonicalTableSpec, AppError> {
CANONICAL_TABLE_SPECS
.iter()
.find(|spec| spec.name == name)
.ok_or_else(|| AppError::Config(format!("unknown canonical table '{name}'")))
}
fn create_canonical_table_on_conn(
conn: &Connection,
name: &str,
if_not_exists: bool,
) -> Result<(), AppError> {
let spec = Self::canonical_table_spec(name)?;
Self::create_canonical_table_as_on_conn(conn, spec, name, if_not_exists)
}
fn create_canonical_table_as_on_conn(
conn: &Connection,
spec: &CanonicalTableSpec,
target_name: &str,
if_not_exists: bool,
) -> Result<(), AppError> {
let qualifier = if if_not_exists { " IF NOT EXISTS" } else { "" };
let definition = if target_name == spec.name {
spec.definition.to_string()
} else {
spec.definition.replacen(spec.name, target_name, 1)
};
conn.execute(&format!("CREATE TABLE{qualifier} {definition}"), [])
.map_err(|error| {
AppError::Database(format!(
"failed to create canonical table '{target_name}': {error}"
))
})?;
Ok(())
}
/// 创建所有数据库表
pub(crate) fn create_tables(&self) -> Result<(), AppError> {
let conn = lock_conn!(self.conn);
@@ -23,41 +586,10 @@ impl Database {
/// 在指定连接上创建表(供迁移和测试使用)
pub(crate) fn create_tables_on_conn(conn: &Connection) -> Result<(), AppError> {
// 1. Providers 表
conn.execute(
"CREATE TABLE IF NOT EXISTS providers (
id TEXT NOT NULL,
app_type TEXT NOT NULL,
name TEXT NOT NULL,
settings_config TEXT NOT NULL,
website_url TEXT,
category TEXT,
created_at INTEGER,
sort_index INTEGER,
notes TEXT,
icon TEXT,
icon_color TEXT,
meta TEXT NOT NULL DEFAULT '{}',
is_current BOOLEAN NOT NULL DEFAULT 0,
in_failover_queue BOOLEAN NOT NULL DEFAULT 0,
PRIMARY KEY (id, app_type)
)",
[],
)
.map_err(|e| AppError::Database(e.to_string()))?;
Self::create_canonical_table_on_conn(conn, "providers", true)?;
// 2. Provider Endpoints 表
conn.execute(
"CREATE TABLE IF NOT EXISTS provider_endpoints (
id INTEGER PRIMARY KEY AUTOINCREMENT,
provider_id TEXT NOT NULL,
app_type TEXT NOT NULL,
url TEXT NOT NULL,
added_at INTEGER,
FOREIGN KEY (provider_id, app_type) REFERENCES providers(id, app_type) ON DELETE CASCADE
)",
[],
)
.map_err(|e| AppError::Database(e.to_string()))?;
Self::create_canonical_table_on_conn(conn, "provider_endpoints", true)?;
// 3. MCP Servers 表
conn.execute(
@@ -97,6 +629,7 @@ impl Database {
enabled_grokbuild BOOLEAN NOT NULL DEFAULT 0,
enabled_opencode BOOLEAN NOT NULL DEFAULT 0,
enabled_hermes BOOLEAN NOT NULL DEFAULT 0,
enabled_pi BOOLEAN NOT NULL DEFAULT 0,
installed_at INTEGER NOT NULL DEFAULT 0,
content_hash TEXT,
updated_at INTEGER NOT NULL DEFAULT 0
@@ -105,6 +638,14 @@ impl Database {
)
.map_err(|e| AppError::Database(e.to_string()))?;
// Exact models.json ownership is device-local and must never be
// inferred from provider names, content, or prefixes.
Self::create_canonical_table_on_conn(conn, "pi_provider_projections", true)?;
// Pi Skill deployment ownership is independent from desired enablement
// in `skills.enabled_pi` and from live discovery.
Self::create_canonical_table_on_conn(conn, "skill_deployments", true)?;
// 6. Skill Repos 表
conn.execute(
"CREATE TABLE IF NOT EXISTS skill_repos (
@@ -511,6 +1052,13 @@ impl Database {
Self::migrate_v15_to_v16(conn)?;
Self::set_user_version(conn, 16)?;
}
16 => {
log::info!(
"迁移数据库从 v16 到 v17(添加 Pi aggregate 与设备本地 ledger"
);
Self::migrate_v16_to_v17(conn)?;
Self::set_user_version(conn, 17)?;
}
_ => {
return Err(AppError::Database(format!(
"未知的数据库版本 {version},无法迁移到 {SCHEMA_VERSION}"
@@ -1523,6 +2071,88 @@ impl Database {
crate::services::session_usage_codex::reset_codex_usage_on_conn(conn, &codex_dir)
}
/// 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.
fn migrate_v16_to_v17(conn: &Connection) -> Result<(), AppError> {
if Self::table_exists(conn, "provider_endpoints")? {
Self::add_column_if_missing(conn, "provider_endpoints", "last_used", "INTEGER")?;
// Older builds allowed duplicate rows for one logical endpoint.
// Merge their timestamps before rebuilding from the canonical
// definition. The fixed-column copy makes FK/UNIQUE/collation
// semantics part of migration rather than an optional index patch.
conn.execute_batch(
"UPDATE provider_endpoints AS kept
SET added_at = (
SELECT MIN(other.added_at)
FROM provider_endpoints AS other
WHERE other.provider_id = kept.provider_id
AND other.app_type = kept.app_type
AND other.url = kept.url
),
last_used = (
SELECT MAX(other.last_used)
FROM provider_endpoints AS other
WHERE other.provider_id = kept.provider_id
AND other.app_type = kept.app_type
AND other.url = kept.url
)
WHERE kept.id = (
SELECT MIN(other.id)
FROM provider_endpoints AS other
WHERE other.provider_id = kept.provider_id
AND other.app_type = kept.app_type
AND other.url = kept.url
);
DELETE FROM provider_endpoints
WHERE id NOT IN (
SELECT MIN(id)
FROM provider_endpoints
GROUP BY provider_id, app_type, url
);",
)
.map_err(|error| AppError::Database(error.to_string()))?;
const REBUILT_ENDPOINTS: &str = "provider_endpoints_v17_canonical";
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)?;
conn.execute(
&format!(
"INSERT INTO \"{REBUILT_ENDPOINTS}\"
(id, provider_id, app_type, url, added_at, last_used)
SELECT id, provider_id, app_type, url, added_at, last_used
FROM provider_endpoints"
),
[],
)
.map_err(|error| {
AppError::Database(format!(
"failed to copy provider_endpoints into canonical v17 table: {error}"
))
})?;
conn.execute("DROP TABLE provider_endpoints", [])
.map_err(|error| AppError::Database(error.to_string()))?;
conn.execute(
&format!("ALTER TABLE \"{REBUILT_ENDPOINTS}\" RENAME TO provider_endpoints"),
[],
)
.map_err(|error| AppError::Database(error.to_string()))?;
} else {
Self::create_canonical_table_on_conn(conn, "provider_endpoints", false)?;
}
if Self::table_exists(conn, "skills")? {
Self::add_column_if_missing(
conn,
"skills",
"enabled_pi",
"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)
}
/// 插入默认模型定价数据
/// 格式: (model_id, display_name, input, output, cache_read, cache_creation)
/// 注意: model_id 使用短横线格式(如 claude-haiku-4-5),与 API 返回的模型名称标准化后一致
@@ -2780,7 +3410,7 @@ impl Database {
Self::ensure_model_pricing_seeded_on_conn(&conn)
}
fn ensure_model_pricing_seeded_on_conn(conn: &Connection) -> Result<(), AppError> {
pub(crate) fn ensure_model_pricing_seeded_on_conn(conn: &Connection) -> Result<(), AppError> {
// 每次启动都执行 INSERT OR IGNORE,增量追加新模型;仅修复仍等于旧内置值的定价。
Self::seed_model_pricing(conn)?;
Self::repair_current_model_pricing(conn)
@@ -2938,6 +3568,73 @@ impl Database {
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
fn canonical_manifest_from_specs() -> serde_json::Value {
let tables = CANONICAL_TABLE_SPECS
.iter()
.map(|spec| {
json!({
"name": spec.name,
"restoreClass": spec.restore_class,
"columns": spec.columns.iter().map(|column| json!([
column.name,
column.data_type,
column.not_null,
column.default,
column.pk_position
])).collect::<Vec<_>>(),
"uniqueTuples": spec.unique_tuples.iter().map(|tuple| {
tuple.columns.iter().map(|column| {
json!([column.name, column.collation])
}).collect::<Vec<_>>()
}).collect::<Vec<_>>(),
"foreignKeys": spec.foreign_keys.iter().map(|foreign_key| json!({
"from": foreign_key.from,
"table": foreign_key.table,
"to": foreign_key.to,
"onUpdate": foreign_key.on_update,
"onDelete": foreign_key.on_delete,
"match": foreign_key.match_type
})).collect::<Vec<_>>(),
"checks": spec.invariants.iter().map(|invariant| invariant.name).collect::<Vec<_>>()
})
})
.collect::<Vec<_>>();
json!({
"manifestVersion": 1,
"schemaVersion": SCHEMA_VERSION,
"codeAuthority": "src-tauri/src/database/schema.rs",
"comparison": "semantic",
"tables": tables,
"futureUsageActivation": {
"commit": 13,
"proxy_request_logs.input_token_semantics": {
"type": "INTEGER",
"notNull": true,
"default": null,
"allowed": [1, 2, 3, 4]
},
"usage_daily_rollups.input_token_semantics": {
"type": "INTEGER",
"notNull": true,
"default": null,
"allowed": [2]
}
}
})
}
#[test]
fn canonical_schema_specs_match_review_manifest() -> Result<(), AppError> {
let expected: serde_json::Value = serde_json::from_str(include_str!(
"../../../tests/fixtures/pi/canonical-schema-manifest-v1.json"
))
.expect("parse canonical schema manifest");
assert_eq!(canonical_manifest_from_specs(), expected);
Ok(())
}
#[test]
fn migrate_v12_to_v13_adds_input_token_semantics_columns() -> Result<(), AppError> {
@@ -3080,7 +3777,7 @@ mod tests {
Database::apply_schema_migrations_on_conn(&conn)?;
assert_eq!(Database::get_user_version(&conn)?, 16);
assert_eq!(Database::get_user_version(&conn)?, SCHEMA_VERSION);
let counts: (i64, i64, i64, i64) = conn.query_row(
"SELECT
(SELECT COUNT(*) FROM proxy_request_logs WHERE data_source = 'codex_session'),
@@ -3093,4 +3790,80 @@ mod tests {
assert_eq!(counts, (0, 1, 0, 1));
Ok(())
}
#[test]
fn migrate_v16_to_v17_adds_pi_ledgers_without_inferred_ownership() -> Result<(), AppError> {
let conn = Connection::open_in_memory()?;
conn.execute_batch(
"CREATE TABLE providers (
id TEXT NOT NULL,
app_type TEXT NOT NULL,
PRIMARY KEY (id, app_type)
);
CREATE TABLE provider_endpoints (
id INTEGER PRIMARY KEY,
provider_id TEXT NOT NULL,
app_type TEXT NOT NULL,
url TEXT NOT NULL,
added_at INTEGER
);
CREATE TABLE skills (
id TEXT PRIMARY KEY,
enabled_codex BOOLEAN NOT NULL DEFAULT 0
);
INSERT INTO providers (id, app_type) VALUES ('provider', 'pi');
INSERT INTO provider_endpoints
(id, provider_id, app_type, url, added_at)
VALUES
(1, 'provider', 'pi', 'https://duplicate.test', 20),
(2, 'provider', 'pi', 'https://duplicate.test', 10);
INSERT INTO skills (id, enabled_codex) VALUES ('existing', 1);",
)?;
Database::set_user_version(&conn, 16)?;
Database::apply_schema_migrations_on_conn(&conn)?;
assert_eq!(Database::get_user_version(&conn)?, SCHEMA_VERSION);
assert!(Database::has_column(
&conn,
"provider_endpoints",
"last_used"
)?);
assert!(Database::has_column(&conn, "skills", "enabled_pi")?);
assert!(Database::table_exists(&conn, "pi_provider_projections")?);
assert!(Database::table_exists(&conn, "skill_deployments")?);
let desired: i64 = conn.query_row(
"SELECT enabled_pi FROM skills WHERE id = 'existing'",
[],
|row| row.get(0),
)?;
let ledgers: (i64, i64) = conn.query_row(
"SELECT
(SELECT COUNT(*) FROM pi_provider_projections),
(SELECT COUNT(*) FROM skill_deployments)",
[],
|row| Ok((row.get(0)?, row.get(1)?)),
)?;
assert_eq!(desired, 0);
assert_eq!(ledgers, (0, 0));
let endpoint: (i64, Option<i64>) = conn.query_row(
"SELECT COUNT(*), MIN(added_at)
FROM provider_endpoints
WHERE provider_id = 'provider'
AND app_type = 'pi'
AND url = 'https://duplicate.test'",
[],
|row| Ok((row.get(0)?, row.get(1)?)),
)?;
assert_eq!(endpoint, (1, Some(10)));
assert!(conn
.execute(
"INSERT INTO provider_endpoints
(provider_id, app_type, url, added_at)
VALUES ('provider', 'pi', 'https://duplicate.test', 30)",
[],
)
.is_err());
Ok(())
}
}
+24 -16
View File
@@ -109,27 +109,35 @@ pub fn import_provider_from_deeplink(
let provider_id = provider.id.clone();
// Use ProviderService to add the provider
ProviderService::add(state, app_type.clone(), provider, true)?;
// Add extra endpoints as custom endpoints (skip first one as it's the primary)
for ep in all_endpoints.iter().skip(1) {
let normalized = ep.trim().trim_end_matches('/').to_string();
// All endpoints supplied by one import request belong to the same create
// intent. Put the non-primary endpoints into the initial aggregate so the
// provider row and its complete endpoint set commit atomically.
let initial_endpoints = &mut provider
.meta
.get_or_insert_with(ProviderMeta::default)
.custom_endpoints;
for endpoint in all_endpoints.iter().skip(1) {
let normalized = endpoint.trim().trim_end_matches('/').to_string();
if !normalized.is_empty() {
if let Err(e) = ProviderService::add_custom_endpoint(
state,
app_type.clone(),
&provider_id,
initial_endpoints.insert(
normalized.clone(),
) {
log::warn!(
"Failed to add custom endpoint '{}': {e}",
crate::url_for_log(&normalized)
);
}
crate::settings::CustomEndpoint {
url: normalized,
added_at: Some(timestamp),
last_used: None,
},
);
}
}
// ProviderService owns the strict aggregate create.
ProviderService::add(
state,
app_type.clone(),
crate::services::provider::provider_to_mutation_input(provider),
true,
)?;
// If enabled=true, set as current provider
if merged_request.enabled.unwrap_or(false) {
ProviderService::switch(state, app_type.clone(), &provider_id)?;
+34 -1
View File
@@ -3,7 +3,7 @@
use super::mcp::parse_mcp_apps;
use super::parser::parse_deeplink_url;
use super::prompt::import_prompt_from_deeplink;
use super::provider::parse_and_merge_config;
use super::provider::{import_provider_from_deeplink, parse_and_merge_config};
use super::utils::{infer_homepage_from_endpoint, validate_url};
use super::DeepLinkImportRequest;
use crate::AppType;
@@ -952,6 +952,39 @@ fn test_parse_multiple_endpoints_comma_separated() {
assert!(endpoint.contains("https://api3.example.com"));
}
#[test]
#[serial_test::serial]
fn provider_deeplink_creates_all_initial_endpoints_in_one_aggregate() {
let _test_home = TestHomeGuard::new();
let request = parse_deeplink_url(
"ccswitch://v1/import?resource=provider&app=claude&name=Endpoint%20Aggregate&endpoint=https%3A%2F%2Fprimary.example.com,https%3A%2F%2Fsecond.example.com%2F,https%3A%2F%2Fthird.example.com&apiKey=sk-test",
)
.expect("parse provider deeplink");
let state = AppState::new(Arc::new(Database::memory().expect("create memory db")));
let provider_id =
import_provider_from_deeplink(&state, request).expect("import provider aggregate");
let aggregate = state
.db
.get_provider_aggregate(AppType::Claude.as_str(), &provider_id)
.expect("read provider aggregate")
.expect("provider exists");
assert_eq!(aggregate.endpoints.len(), 2);
assert_eq!(
aggregate.endpoints["https://second.example.com"].url,
"https://second.example.com"
);
assert_eq!(
aggregate.endpoints["https://third.example.com"].url,
"https://third.example.com"
);
assert!(aggregate
.endpoints
.values()
.all(|endpoint| endpoint.added_at.is_some() && endpoint.last_used.is_none()));
}
#[test]
fn test_parse_single_endpoint_backward_compatible() {
// Old format with single endpoint should still work
+2
View File
@@ -9,6 +9,8 @@ pub enum AppError {
Config(String),
#[error("无效输入: {0}")]
InvalidInput(String),
#[error("未找到: {0}")]
NotFound(String),
#[error("IO 错误: {path}: {source}")]
Io {
path: String,
+5 -2
View File
@@ -44,7 +44,10 @@ pub use codex_config::{get_codex_auth_path, get_codex_config_path, write_codex_l
pub use commands::open_provider_terminal;
pub use commands::*;
pub use config::{get_claude_mcp_path, get_claude_settings_path, read_json_file};
pub use database::{Database, Profile};
pub use database::{
Database, NewEndpoint, NewProviderAggregate, Profile, ProviderKey, ProviderRowUpdate,
RenameProvider,
};
pub use deeplink::{import_provider_from_deeplink, parse_deeplink_url, DeepLinkImportRequest};
pub use error::AppError;
pub use grok_config::get_grok_config_path;
@@ -56,7 +59,7 @@ pub use mcp::{
sync_single_server_to_gemini, sync_single_server_to_grokbuild,
};
pub use prompt::Prompt;
pub use provider::{Provider, ProviderMeta};
pub use provider::{Provider, ProviderAggregate, ProviderMeta, ProviderMutationInput};
pub use services::{
profile::{ProfilePayload, ProfileScope, ProfileService},
provider::reapply_current_codex_official_live,
+78
View File
@@ -43,6 +43,84 @@ pub struct Provider {
pub in_failover_queue: bool,
}
/// IPC/service input for creating or editing a provider.
///
/// This deliberately is not the hydrated [`Provider`] read projection. In
/// particular, callers cannot pass a DAO aggregate back into the provider-row
/// writer without first crossing the service boundary, where endpoint
/// ownership is checked.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ProviderMutationInput {
pub id: String,
pub name: String,
#[serde(rename = "settingsConfig")]
pub settings_config: Value,
#[serde(skip_serializing_if = "Option::is_none")]
#[serde(rename = "websiteUrl")]
pub website_url: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub category: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
#[serde(rename = "createdAt")]
pub created_at: Option<i64>,
#[serde(skip_serializing_if = "Option::is_none")]
#[serde(rename = "sortIndex")]
pub sort_index: Option<usize>,
#[serde(skip_serializing_if = "Option::is_none")]
pub notes: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub meta: Option<ProviderMeta>,
#[serde(skip_serializing_if = "Option::is_none")]
pub icon: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
#[serde(rename = "iconColor")]
pub icon_color: Option<String>,
#[serde(default)]
#[serde(rename = "inFailoverQueue")]
pub in_failover_queue: bool,
}
impl From<ProviderMutationInput> for Provider {
fn from(input: ProviderMutationInput) -> Self {
Self {
id: input.id,
name: input.name,
settings_config: input.settings_config,
website_url: input.website_url,
category: input.category,
created_at: input.created_at,
sort_index: input.sort_index,
notes: input.notes,
meta: input.meta,
icon: input.icon,
icon_color: input.icon_color,
in_failover_queue: input.in_failover_queue,
}
}
}
/// A provider row and every endpoint owned by that row.
///
/// SQLite stores endpoints separately from provider metadata. This aggregate
/// is the only lossless DAO boundary; legacy `Provider` reads are projections
/// of it for API compatibility.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ProviderAggregate {
pub provider: Provider,
#[serde(default)]
pub endpoints: IndexMap<String, crate::settings::CustomEndpoint>,
}
impl ProviderAggregate {
pub(crate) fn into_provider(mut self) -> Provider {
self.provider
.meta
.get_or_insert_with(ProviderMeta::default)
.custom_endpoints = self.endpoints.into_iter().collect();
self.provider
}
}
impl Provider {
/// 从现有ID创建供应商
pub fn with_id(
+18 -9
View File
@@ -348,8 +348,10 @@ mod tests {
let provider_b =
Provider::with_id("b".to_string(), "Provider B".to_string(), json!({}), None);
db.save_provider("claude", &provider_a).unwrap();
db.save_provider("claude", &provider_b).unwrap();
db.reconcile_provider_fixture("claude", &provider_a)
.unwrap();
db.reconcile_provider_fixture("claude", &provider_b)
.unwrap();
db.set_current_provider("claude", "a").unwrap();
db.add_to_failover_queue("claude", "b").unwrap();
@@ -374,8 +376,10 @@ mod tests {
Provider::with_id("b".to_string(), "Provider B".to_string(), json!({}), None);
provider_b.sort_index = Some(1);
db.save_provider("claude", &provider_a).unwrap();
db.save_provider("claude", &provider_b).unwrap();
db.reconcile_provider_fixture("claude", &provider_a)
.unwrap();
db.reconcile_provider_fixture("claude", &provider_b)
.unwrap();
db.set_current_provider("claude", "a").unwrap();
db.add_to_failover_queue("claude", "b").unwrap();
@@ -407,8 +411,10 @@ mod tests {
Provider::with_id("b".to_string(), "Provider B".to_string(), json!({}), None);
provider_b.sort_index = Some(1);
db.save_provider("claude", &provider_a).unwrap();
db.save_provider("claude", &provider_b).unwrap();
db.reconcile_provider_fixture("claude", &provider_a)
.unwrap();
db.reconcile_provider_fixture("claude", &provider_b)
.unwrap();
db.set_current_provider("claude", "a").unwrap();
// 只把 b 加入故障转移队列(模拟“当前供应商不在队列里”的常见配置)
@@ -444,8 +450,10 @@ mod tests {
let provider_b =
Provider::with_id("b".to_string(), "Provider B".to_string(), json!({}), None);
db.save_provider("claude", &provider_a).unwrap();
db.save_provider("claude", &provider_b).unwrap();
db.reconcile_provider_fixture("claude", &provider_a)
.unwrap();
db.reconcile_provider_fixture("claude", &provider_b)
.unwrap();
db.add_to_failover_queue("claude", "a").unwrap();
db.add_to_failover_queue("claude", "b").unwrap();
@@ -485,7 +493,8 @@ mod tests {
let provider_a =
Provider::with_id("a".to_string(), "Provider A".to_string(), json!({}), None);
db.save_provider("claude", &provider_a).unwrap();
db.reconcile_provider_fixture("claude", &provider_a)
.unwrap();
db.add_to_failover_queue("claude", "a").unwrap();
// 启用自动故障转移
+5 -1
View File
@@ -1,4 +1,5 @@
use crate::config::{atomic_write, write_json_file};
use crate::database::NewProviderAggregate;
use crate::error::AppError;
use crate::opencode_config::get_opencode_dir;
use crate::provider::Provider;
@@ -288,7 +289,10 @@ impl OmoService {
in_failover_queue: false,
};
state.db.save_provider("opencode", &provider)?;
state.db.create_provider(NewProviderAggregate::from_input(
"opencode",
crate::services::provider::provider_to_mutation_input(provider.clone()),
)?)?;
state
.db
.set_omo_provider_current("opencode", &provider.id, v.category)?;
+9 -15
View File
@@ -5,6 +5,7 @@
use std::time::{SystemTime, UNIX_EPOCH};
use crate::app_config::AppType;
use crate::database::{NewEndpoint, ProviderKey};
use crate::error::AppError;
use crate::settings::CustomEndpoint;
use crate::store::AppState;
@@ -47,9 +48,10 @@ pub fn add_custom_endpoint(
));
}
let key = ProviderKey::new(app_type.as_str(), provider_id)?;
state
.db
.add_custom_endpoint(app_type.as_str(), provider_id, &normalized)?;
.add_provider_endpoint(&key, NewEndpoint::now(normalized)?)?;
Ok(())
}
@@ -61,9 +63,8 @@ pub fn remove_custom_endpoint(
url: String,
) -> Result<(), AppError> {
let normalized = url.trim().trim_end_matches('/').to_string();
state
.db
.remove_custom_endpoint(app_type.as_str(), provider_id, &normalized)?;
let key = ProviderKey::new(app_type.as_str(), provider_id)?;
state.db.remove_provider_endpoint(&key, &normalized)?;
Ok(())
}
@@ -76,17 +77,10 @@ pub fn update_endpoint_last_used(
) -> Result<(), AppError> {
let normalized = url.trim().trim_end_matches('/').to_string();
// Get provider, update last_used, save back
let mut providers = state.db.get_all_providers(app_type.as_str())?;
if let Some(provider) = providers.get_mut(provider_id) {
if let Some(meta) = provider.meta.as_mut() {
if let Some(endpoint) = meta.custom_endpoints.get_mut(&normalized) {
endpoint.last_used = Some(now_millis());
state.db.save_provider(app_type.as_str(), provider)?;
}
}
}
Ok(())
let key = ProviderKey::new(app_type.as_str(), provider_id)?;
state
.db
.touch_provider_endpoint(&key, &normalized, now_millis())
}
/// Get current timestamp in milliseconds
+38 -11
View File
@@ -19,7 +19,9 @@ use crate::store::AppState;
use super::gemini_auth::{
detect_gemini_auth_type, ensure_google_oauth_security_flag, GeminiAuthType,
};
use super::normalize_claude_models_in_value;
use super::{
normalize_claude_models_in_value, provider_to_mutation_input, reconcile_provider_record,
};
/// ChatGPT Codex catalogs gpt-5.6 at a 372K context window with a ~353K
/// effective budget (openai/codex#31860), far below the 1.05M API spec.
@@ -1564,7 +1566,11 @@ pub fn import_default_config(state: &AppState, app_type: AppType) -> Result<bool
.to_string(),
);
state.db.save_provider(app_type.as_str(), &provider)?;
reconcile_provider_record(
&state.db,
app_type.as_str(),
provider_to_mutation_input(provider.clone()),
)?;
state
.db
.set_current_provider(app_type.as_str(), &provider.id)?;
@@ -1732,15 +1738,20 @@ pub fn import_opencode_providers_from_live(state: &AppState) -> Result<usize, Ap
};
if existing_ids.contains(&id) {
match state.db.get_provider_by_id(&id, "opencode") {
match state.db.get_provider_aggregate("opencode", &id) {
Ok(Some(existing)) => {
let existing = existing.provider;
let display_name = config.name.clone().unwrap_or_else(|| existing.name.clone());
if existing.settings_config != settings_config || existing.name != display_name
{
let mut provider = existing;
provider.name = display_name;
provider.settings_config = settings_config;
if let Err(e) = state.db.save_provider("opencode", &provider) {
if let Err(e) = reconcile_provider_record(
&state.db,
"opencode",
provider_to_mutation_input(provider),
) {
log::warn!(
"Failed to update OpenCode provider '{id}' from live config: {e}"
);
@@ -1767,7 +1778,9 @@ pub fn import_opencode_providers_from_live(state: &AppState) -> Result<usize, Ap
});
// Save to database
if let Err(e) = state.db.save_provider("opencode", &provider) {
if let Err(e) =
reconcile_provider_record(&state.db, "opencode", provider_to_mutation_input(provider))
{
log::warn!("Failed to import OpenCode provider '{id}': {e}");
continue;
}
@@ -1817,12 +1830,17 @@ pub fn import_openclaw_providers_from_live(state: &AppState) -> Result<usize, Ap
};
if existing_ids.contains(&id) {
match state.db.get_provider_by_id(&id, "openclaw") {
match state.db.get_provider_aggregate("openclaw", &id) {
Ok(Some(existing)) => {
let existing = existing.provider;
if existing.settings_config != settings_config {
let mut provider = existing;
provider.settings_config = settings_config;
if let Err(e) = state.db.save_provider("openclaw", &provider) {
if let Err(e) = reconcile_provider_record(
&state.db,
"openclaw",
provider_to_mutation_input(provider),
) {
log::warn!(
"Failed to update OpenClaw provider '{id}' from live config: {e}"
);
@@ -1855,7 +1873,9 @@ pub fn import_openclaw_providers_from_live(state: &AppState) -> Result<usize, Ap
});
// Save to database
if let Err(e) = state.db.save_provider("openclaw", &provider) {
if let Err(e) =
reconcile_provider_record(&state.db, "openclaw", provider_to_mutation_input(provider))
{
log::warn!("Failed to import OpenClaw provider '{id}': {e}");
continue;
}
@@ -1892,12 +1912,17 @@ pub fn import_hermes_providers_from_live(state: &AppState) -> Result<usize, AppE
}
if existing_ids.contains(&name) {
match state.db.get_provider_by_id(&name, "hermes") {
match state.db.get_provider_aggregate("hermes", &name) {
Ok(Some(existing)) => {
let existing = existing.provider;
if existing.settings_config != config {
let mut provider = existing;
provider.settings_config = config;
if let Err(e) = state.db.save_provider("hermes", &provider) {
if let Err(e) = reconcile_provider_record(
&state.db,
"hermes",
provider_to_mutation_input(provider),
) {
log::warn!(
"Failed to update Hermes provider '{name}' from live config: {e}"
);
@@ -1923,7 +1948,9 @@ pub fn import_hermes_providers_from_live(state: &AppState) -> Result<usize, AppE
});
// Save to database
if let Err(e) = state.db.save_provider("hermes", &provider) {
if let Err(e) =
reconcile_provider_record(&state.db, "hermes", provider_to_mutation_input(provider))
{
log::warn!("Failed to import Hermes provider '{name}': {e}");
continue;
}
File diff suppressed because it is too large Load Diff
+38 -37
View File
@@ -3759,7 +3759,7 @@ mod tests {
}),
None,
);
db.save_provider("claude", &provider)
db.reconcile_provider_fixture("claude", &provider)
.expect("save provider");
db.set_current_provider("claude", "p1")
.expect("set db current provider");
@@ -3945,7 +3945,7 @@ wire_api = "responses"
None,
);
provider.category = Some("cn_official".to_string());
db.save_provider("codex", &provider)
db.reconcile_provider_fixture("codex", &provider)
.expect("save DeepSeek provider");
db.set_current_provider("codex", "deepseek")
.expect("set current provider");
@@ -4031,7 +4031,7 @@ wire_api = "responses"
None,
);
provider.category = Some("official".to_string());
db.save_provider("codex", &provider)
db.reconcile_provider_fixture("codex", &provider)
.expect("save misclassified DeepSeek provider");
db.set_current_provider("codex", "deepseek")
.expect("set current provider");
@@ -4092,7 +4092,7 @@ wire_api = "responses"
None,
);
official.category = Some("official".to_string());
db.save_provider("codex", &official)
db.reconcile_provider_fixture("codex", &official)
.expect("save official provider");
let mut third_party = Provider::with_id(
@@ -4111,7 +4111,7 @@ wire_api = "responses"
None,
);
third_party.category = Some("custom".to_string());
db.save_provider("codex", &third_party)
db.reconcile_provider_fixture("codex", &third_party)
.expect("save third-party provider");
db.set_current_provider("codex", "codex-official")
.expect("set current provider");
@@ -4260,7 +4260,8 @@ wire_api = "responses"
None,
);
official.category = Some("official".to_string());
db.save_provider("codex", &official).expect("save official");
db.reconcile_provider_fixture("codex", &official)
.expect("save official");
db.set_current_provider("codex", crate::database::CODEX_OFFICIAL_PROVIDER_ID)
.expect("set current");
crate::settings::set_current_provider(
@@ -4338,7 +4339,7 @@ wire_api = "responses"
None,
);
provider.category = Some("official".to_string());
db.save_provider("codex", &provider)
db.reconcile_provider_fixture("codex", &provider)
.expect("save misclassified DeepSeek provider");
db.set_current_provider("codex", "deepseek")
.expect("set current provider");
@@ -4418,7 +4419,7 @@ wire_api = "responses"
None,
);
provider.category = Some("official".to_string());
db.save_provider("codex", &provider)
db.reconcile_provider_fixture("codex", &provider)
.expect("save misclassified DeepSeek provider");
db.set_current_provider("codex", "deepseek")
.expect("set current provider");
@@ -4530,7 +4531,7 @@ wire_api = "responses"
None,
);
provider.category = Some("official".to_string());
db.save_provider("codex", &provider)
db.reconcile_provider_fixture("codex", &provider)
.expect("save misclassified DeepSeek provider");
db.set_current_provider("codex", "deepseek")
.expect("set current provider");
@@ -4648,7 +4649,7 @@ wire_api = "responses"
None,
);
provider.category = Some("official".to_string());
db.save_provider("codex", &provider)
db.reconcile_provider_fixture("codex", &provider)
.expect("save misclassified DeepSeek provider");
db.set_current_provider("codex", "deepseek")
.expect("set current provider");
@@ -4784,7 +4785,7 @@ wire_api = "responses"
None,
);
provider.category = Some("cn_official".to_string());
db.save_provider("codex", &provider)
db.reconcile_provider_fixture("codex", &provider)
.expect("save DeepSeek provider");
db.set_current_provider("codex", "deepseek")
.expect("set current provider");
@@ -5261,7 +5262,7 @@ model = "gpt-5.1-codex"
}),
None,
);
db.save_provider("claude", &provider)
db.reconcile_provider_fixture("claude", &provider)
.expect("save provider");
db.set_current_provider("claude", "p1")
.expect("set current provider");
@@ -5317,7 +5318,7 @@ model = "gpt-5.1-codex"
}),
None,
);
db.save_provider("claude", &provider)
db.reconcile_provider_fixture("claude", &provider)
.expect("save provider");
db.set_current_provider("claude", "p1")
.expect("set current provider");
@@ -5382,9 +5383,9 @@ model = "gpt-5.1-codex"
}),
None,
);
db.save_provider("claude", &provider_a)
db.reconcile_provider_fixture("claude", &provider_a)
.expect("save provider a");
db.save_provider("claude", &provider_b)
db.reconcile_provider_fixture("claude", &provider_b)
.expect("save provider b");
db.set_current_provider("claude", "a")
.expect("set current provider");
@@ -5457,9 +5458,9 @@ model = "gpt-5.1-codex"
None,
);
db.save_provider("claude", &provider_a)
db.reconcile_provider_fixture("claude", &provider_a)
.expect("save provider a");
db.save_provider("claude", &provider_b)
db.reconcile_provider_fixture("claude", &provider_b)
.expect("save provider b");
db.set_current_provider("claude", "a")
.expect("set current provider");
@@ -5608,11 +5609,11 @@ model = "gpt-5.1-codex"
None,
);
db.save_provider("claude", &provider_a)
db.reconcile_provider_fixture("claude", &provider_a)
.expect("save provider a");
db.save_provider("claude", &provider_b)
db.reconcile_provider_fixture("claude", &provider_b)
.expect("save provider b");
db.save_provider("claude", &provider_c)
db.reconcile_provider_fixture("claude", &provider_c)
.expect("save provider c");
db.set_current_provider("claude", "a")
.expect("set current provider");
@@ -5695,9 +5696,9 @@ model = "gpt-5.1-codex"
None,
);
db.save_provider("claude", &provider_a)
db.reconcile_provider_fixture("claude", &provider_a)
.expect("save provider a");
db.save_provider("claude", &provider_b)
db.reconcile_provider_fixture("claude", &provider_b)
.expect("save provider b");
db.set_current_provider("claude", "a")
.expect("set current provider");
@@ -5995,9 +5996,9 @@ requires_openai_auth = true
None,
);
db.save_provider("codex", &provider_a)
db.reconcile_provider_fixture("codex", &provider_a)
.expect("save provider a");
db.save_provider("codex", &provider_b)
db.reconcile_provider_fixture("codex", &provider_b)
.expect("save provider b");
db.set_current_provider("codex", "a")
.expect("set current provider");
@@ -6170,9 +6171,9 @@ requires_openai_auth = true
..Default::default()
});
db.save_provider("codex", &provider_a)
db.reconcile_provider_fixture("codex", &provider_a)
.expect("save provider a");
db.save_provider("codex", &provider_b)
db.reconcile_provider_fixture("codex", &provider_b)
.expect("save provider b");
db.set_current_provider("codex", "a")
.expect("set current provider");
@@ -6414,9 +6415,9 @@ requires_openai_auth = true
..Default::default()
});
db.save_provider("codex", &provider_a)
db.reconcile_provider_fixture("codex", &provider_a)
.expect("save provider a");
db.save_provider("codex", &provider_b)
db.reconcile_provider_fixture("codex", &provider_b)
.expect("save provider b");
db.set_current_provider("codex", "a")
.expect("set current provider a");
@@ -6550,9 +6551,9 @@ requires_openai_auth = true
None,
);
db.save_provider("codex", &provider_a)
db.reconcile_provider_fixture("codex", &provider_a)
.expect("save provider a");
db.save_provider("codex", &provider_b)
db.reconcile_provider_fixture("codex", &provider_b)
.expect("save provider b");
db.set_current_provider("codex", "a")
.expect("set current provider a");
@@ -6632,9 +6633,9 @@ requires_openai_auth = true
}),
None,
);
db.save_provider("codex", &provider_a)
db.reconcile_provider_fixture("codex", &provider_a)
.expect("save provider a");
db.save_provider("codex", &provider_b)
db.reconcile_provider_fixture("codex", &provider_b)
.expect("save provider b");
db.set_current_provider("codex", "a")
.expect("set current provider a");
@@ -6916,7 +6917,7 @@ requires_openai_auth = true
}),
None,
);
db.save_provider("claude", &provider)
db.reconcile_provider_fixture("claude", &provider)
.expect("save provider");
db.set_current_provider("claude", "p1")
.expect("set current provider");
@@ -7170,9 +7171,9 @@ experimental_bearer_token = "PROXY_MANAGED"
grok_provider_config("https://b.example.com/v1", "b-key"),
None,
);
db.save_provider("grokbuild", &provider_a)
db.reconcile_provider_fixture("grokbuild", &provider_a)
.expect("save provider a");
db.save_provider("grokbuild", &provider_b)
db.reconcile_provider_fixture("grokbuild", &provider_b)
.expect("save provider b");
db.set_current_provider("grokbuild", "grok-a")
.expect("set db current");
@@ -7237,9 +7238,9 @@ experimental_bearer_token = "PROXY_MANAGED"
json!({ "config": "not valid toml = [" }),
None,
);
db.save_provider("grokbuild", &provider_a)
db.reconcile_provider_fixture("grokbuild", &provider_a)
.expect("save provider a");
db.save_provider("grokbuild", &provider_b)
db.reconcile_provider_fixture("grokbuild", &provider_b)
.expect("save provider b");
db.set_current_provider("grokbuild", "grok-a")
.expect("set db current");
+2 -2
View File
@@ -8,11 +8,11 @@ use crate::error::AppError;
use crate::services::skill::{SkillStorageLocation, SyncMethod};
/// 自定义端点配置(历史兼容,实际存储在 provider.meta.custom_endpoints
#[derive(Debug, Clone, Serialize, Deserialize)]
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct CustomEndpoint {
pub url: String,
pub added_at: i64,
pub added_at: Option<i64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub last_used: Option<i64>,
}
+72 -59
View File
@@ -7,13 +7,14 @@ use std::fs;
use serde_json::json;
use cc_switch_lib::{
AppType, InstalledSkill, McpServer, McpService, ProfilePayload, ProfileScope, ProfileService,
Prompt, PromptService, Provider, ProviderService, SkillApps, SkillService,
AppType, InstalledSkill, McpServer, McpService, NewProviderAggregate, ProfilePayload,
ProfileScope, ProfileService, Prompt, PromptService, Provider, ProviderService, SkillApps,
SkillService,
};
#[path = "support.rs"]
mod support;
use support::{create_test_state, ensure_test_home, reset_test_fs, test_mutex};
use support::{create_test_state, ensure_test_home, new_provider_input, reset_test_fs, test_mutex};
fn claude_provider(id: &str, token: &str) -> Provider {
Provider::with_id(
@@ -107,34 +108,41 @@ fn profile_snapshot_apply_roundtrip_restores_configuration() {
let state = create_test_state().expect("create test state");
// ---- 种子数据:2 个 Claude 供应商(p1 为当前)+ 2 个 MCP + 1 个 Skill + 2 个 Prompt ----
state
.db
.save_provider(AppType::Claude.as_str(), &claude_provider("p1", "key-1"))
.expect("save provider p1");
state
.db
.save_provider(AppType::Claude.as_str(), &claude_provider("p2", "key-2"))
.expect("save provider p2");
ProviderService::add(
&state,
AppType::Claude,
new_provider_input(claude_provider("p1", "key-1")),
false,
)
.expect("create provider p1");
ProviderService::add(
&state,
AppType::Claude,
new_provider_input(claude_provider("p2", "key-2")),
false,
)
.expect("create provider p2");
state
.db
.set_current_provider(AppType::Claude.as_str(), "p1")
.expect("set current provider p1");
// Claude Desktop 只有供应商一个活跃维度(MCP/Skills/Prompt 对它不适用)
state
.db
.save_provider(
AppType::ClaudeDesktop.as_str(),
&desktop_provider("d1", "dk-1"),
)
.expect("save desktop provider d1");
state
.db
.save_provider(
AppType::ClaudeDesktop.as_str(),
&desktop_provider("d2", "dk-2"),
)
.expect("save desktop provider d2");
for provider in [
desktop_provider("d1", "dk-1"),
desktop_provider("d2", "dk-2"),
] {
state
.db
.create_provider(
NewProviderAggregate::from_input(
AppType::ClaudeDesktop.as_str(),
new_provider_input(provider),
)
.expect("build typed desktop create"),
)
.expect("create desktop provider");
}
state
.db
.set_current_provider(AppType::ClaudeDesktop.as_str(), "d1")
@@ -287,10 +295,13 @@ fn shared_profile_sides_are_isolated_and_mergeable() {
let state = create_test_state().expect("create test state");
// 种子:Claude 侧有当前供应商 + 启用的 MCP
state
.db
.save_provider(AppType::Claude.as_str(), &claude_provider("p1", "key-1"))
.expect("save provider p1");
ProviderService::add(
&state,
AppType::Claude,
new_provider_input(claude_provider("p1", "key-1")),
false,
)
.expect("create provider p1");
state
.db
.set_current_provider(AppType::Claude.as_str(), "p1")
@@ -496,14 +507,20 @@ fn switching_profile_autosaves_previous_profile_state() {
let state = create_test_state().expect("create test state");
// ---- 种子:Claude 侧两套供应商 / MCP / Prompt ----
state
.db
.save_provider(AppType::Claude.as_str(), &claude_provider("p1", "key-1"))
.expect("save provider p1");
state
.db
.save_provider(AppType::Claude.as_str(), &claude_provider("p2", "key-2"))
.expect("save provider p2");
ProviderService::add(
&state,
AppType::Claude,
new_provider_input(claude_provider("p1", "key-1")),
false,
)
.expect("create provider p1");
ProviderService::add(
&state,
AppType::Claude,
new_provider_input(claude_provider("p2", "key-2")),
false,
)
.expect("create provider p2");
state
.db
.set_current_provider(AppType::Claude.as_str(), "p1")
@@ -665,17 +682,13 @@ fn profile_switch_auto_disables_takeover_before_apply() {
// ---- 两个 Claude 供应商:custom1 与 custom2 ----
let mut custom1 = claude_provider("custom1", "custom-key-1");
custom1.category = Some("custom".to_string());
state
.db
.save_provider(AppType::Claude.as_str(), &custom1)
.expect("save custom1 provider");
ProviderService::add(&state, AppType::Claude, new_provider_input(custom1), false)
.expect("create custom1 provider");
let mut custom2 = claude_provider("custom2", "custom-key-2");
custom2.category = Some("custom".to_string());
state
.db
.save_provider(AppType::Claude.as_str(), &custom2)
.expect("save custom2 provider");
ProviderService::add(&state, AppType::Claude, new_provider_input(custom2), false)
.expect("create custom2 provider");
// 初始状态:custom1 + 代理接管
ProviderService::switch(&state, AppType::Claude, "custom1").expect("switch to custom1");
@@ -757,20 +770,20 @@ fn claude_desktop_profile_scope_is_independent() {
let state = create_test_state().expect("create test state");
state
.db
.save_provider(
AppType::ClaudeDesktop.as_str(),
&desktop_provider("d1", "dk-1"),
)
.expect("save desktop provider d1");
state
.db
.save_provider(
AppType::ClaudeDesktop.as_str(),
&desktop_provider("d2", "dk-2"),
)
.expect("save desktop provider d2");
ProviderService::add(
&state,
AppType::ClaudeDesktop,
new_provider_input(desktop_provider("d1", "dk-1")),
false,
)
.expect("create desktop provider d1");
ProviderService::add(
&state,
AppType::ClaudeDesktop,
new_provider_input(desktop_provider("d2", "dk-2")),
false,
)
.expect("create desktop provider d2");
state
.db
.set_current_provider(AppType::ClaudeDesktop.as_str(), "d1")
+13 -13
View File
@@ -12,7 +12,7 @@ mod support;
use std::collections::HashMap;
use support::{
create_test_state, create_test_state_with_config, enable_codex_official_auth_preservation,
ensure_test_home, reset_test_fs, test_mutex,
ensure_test_home, new_provider_input, reset_test_fs, test_mutex,
};
fn settings_path(home: &Path) -> PathBuf {
@@ -64,18 +64,18 @@ fn grokbuild_import_and_switch_write_live_config() {
);
let next_config = grokbuild_config("Relay", "https://new.example/v1", "new-key");
state
.db
.save_provider(
AppType::GrokBuild.as_str(),
&Provider::with_id(
"relay".to_string(),
"Relay".to_string(),
json!({ "config": next_config }),
None,
),
)
.expect("save second Grok Build provider");
ProviderService::add(
&state,
AppType::GrokBuild,
new_provider_input(Provider::with_id(
"relay".to_string(),
"Relay".to_string(),
json!({ "config": next_config }),
None,
)),
false,
)
.expect("create second Grok Build provider");
switch_provider_test_hook(&state, AppType::GrokBuild, "relay")
.expect("switch Grok Build provider");
+3 -5
View File
@@ -9,7 +9,7 @@ use cc_switch_lib::{
mod support;
use support::{
create_test_state, create_test_state_with_config, enable_codex_official_auth_preservation,
ensure_test_home, reset_test_fs, test_mutex,
ensure_test_home, new_provider_input, reset_test_fs, test_mutex,
};
fn sanitize_provider_name(name: &str) -> String {
@@ -2922,10 +2922,8 @@ fn recover_from_crash_without_backup_cleans_placeholder_instead_of_writing_it_ba
taken_over_live.clone(),
None,
);
state
.db
.save_provider(AppType::Claude.as_str(), &provider)
.expect("save placeholder provider");
ProviderService::add(&state, AppType::Claude, new_provider_input(provider), false)
.expect("create placeholder provider");
state
.db
.set_current_provider(AppType::Claude.as_str(), "default")
+25 -1
View File
@@ -1,7 +1,31 @@
use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex, OnceLock};
use cc_switch_lib::{update_settings, AppSettings, AppState, Database, MultiAppConfig};
use cc_switch_lib::{
update_settings, AppSettings, AppState, Database, MultiAppConfig, Provider,
ProviderMutationInput,
};
/// Build the public write DTO explicitly for integration tests. Keeping this
/// conversion test-only avoids reintroducing a production `From<Provider>`
/// path from hydrated read projections to provider mutations.
#[allow(dead_code)]
pub fn new_provider_input(provider: Provider) -> ProviderMutationInput {
ProviderMutationInput {
id: provider.id,
name: provider.name,
settings_config: provider.settings_config,
website_url: provider.website_url,
category: provider.category,
created_at: provider.created_at,
sort_index: provider.sort_index,
notes: provider.notes,
meta: provider.meta,
icon: provider.icon,
icon_color: provider.icon_color,
in_failover_queue: provider.in_failover_queue,
}
}
/// 为测试设置隔离的 HOME 目录,避免污染真实用户数据。
pub fn ensure_test_home() -> &'static Path {
@@ -1537,8 +1537,16 @@ function ProviderFormFull({
}
}
const baseMeta: ProviderMeta | undefined =
payload.meta ?? (initialData?.meta ? { ...initialData.meta } : undefined);
const metaSource = payload.meta ?? initialData?.meta;
const baseMeta: ProviderMeta | undefined = metaSource
? { ...metaSource }
: undefined;
// Existing-provider edits never own endpoint membership. The backend
// rejects endpoint-bearing update payloads; add/remove/touch use their
// dedicated commands and remain safe from stale form snapshots.
if (isEditMode && baseMeta) {
delete baseMeta.custom_endpoints;
}
// 确定 providerType(新建时从预设获取,编辑时从现有数据获取)
const providerType = presetProviderType || initialData?.meta?.providerType;
+1 -1
View File
@@ -38,7 +38,7 @@ export interface AppConfig {
// 自定义端点配置
export interface CustomEndpoint {
url: string;
addedAt: number;
addedAt: number | null;
lastUsed?: number;
}
+107
View File
@@ -0,0 +1,107 @@
{
"manifestVersion": 1,
"schemaVersion": 17,
"codeAuthority": "src-tauri/src/database/schema.rs",
"comparison": "semantic",
"tables": [
{
"name": "providers",
"restoreClass": "migrate_and_validate",
"columns": [
["id", "TEXT", true, null, 1],
["app_type", "TEXT", true, null, 2],
["name", "TEXT", true, null, 0],
["settings_config", "TEXT", true, null, 0],
["website_url", "TEXT", false, null, 0],
["category", "TEXT", false, null, 0],
["created_at", "INTEGER", false, null, 0],
["sort_index", "INTEGER", false, null, 0],
["notes", "TEXT", false, null, 0],
["icon", "TEXT", false, null, 0],
["icon_color", "TEXT", false, null, 0],
["meta", "TEXT", true, "'{}'", 0],
["is_current", "BOOLEAN", true, "0", 0],
["in_failover_queue", "BOOLEAN", true, "0", 0]
],
"uniqueTuples": [],
"foreignKeys": [],
"checks": []
},
{
"name": "provider_endpoints",
"restoreClass": "migrate_and_validate",
"columns": [
["id", "INTEGER", false, null, 1],
["provider_id", "TEXT", true, null, 0],
["app_type", "TEXT", true, null, 0],
["url", "TEXT", true, null, 0],
["added_at", "INTEGER", false, null, 0],
["last_used", "INTEGER", false, null, 0]
],
"uniqueTuples": [
[["provider_id", "BINARY"], ["app_type", "BINARY"], ["url", "BINARY"]]
],
"foreignKeys": [
{
"from": ["provider_id", "app_type"],
"table": "providers",
"to": ["id", "app_type"],
"onUpdate": "NO ACTION",
"onDelete": "CASCADE",
"match": "NONE"
}
],
"checks": ["endpoint_identity_unique", "endpoint_parent_fk", "endpoint_delete_cascade"]
},
{
"name": "pi_provider_projections",
"restoreClass": "rebuild_and_preserve_local",
"columns": [
["provider_id", "TEXT", false, null, 1],
["provider_key", "TEXT", true, null, 0],
["created_at", "INTEGER", true, null, 0],
["updated_at", "INTEGER", true, null, 0]
],
"uniqueTuples": [
[["provider_key", "BINARY"]]
],
"foreignKeys": [],
"checks": ["provider_key_unique"]
},
{
"name": "skill_deployments",
"restoreClass": "rebuild_and_preserve_local",
"columns": [
["app_type", "TEXT", true, null, 1],
["skill_id", "TEXT", true, null, 2],
["destination", "TEXT", true, null, 0],
["destination_key", "TEXT", true, null, 3],
["method", "TEXT", true, null, 0],
["source_identity", "TEXT", true, null, 0],
["deployed_digest", "TEXT", false, null, 0],
["created_at", "INTEGER", true, null, 0],
["updated_at", "INTEGER", true, null, 0]
],
"uniqueTuples": [
[["app_type", "BINARY"], ["destination_key", "BINARY"]]
],
"foreignKeys": [],
"checks": ["app_type_pi_only", "method_symlink_or_copy", "destination_owned_once"]
}
],
"futureUsageActivation": {
"commit": 13,
"proxy_request_logs.input_token_semantics": {
"type": "INTEGER",
"notNull": true,
"default": null,
"allowed": [1, 2, 3, 4]
},
"usage_daily_rollups.input_token_semantics": {
"type": "INTEGER",
"notNull": true,
"default": null,
"allowed": [2]
}
}
}
+35
View File
@@ -0,0 +1,35 @@
{
"manifestVersion": 1,
"codeAuthority": "src-tauri/src/database/dao/provider_write.rs",
"types": {
"ProviderKey": ["app_type", "id"],
"ProviderRowUpdate": [
"name",
"settings_config",
"website_url",
"category",
"created_at",
"notes",
"meta",
"icon",
"icon_color"
],
"NewEndpoint": ["url", "added_at", "last_used"],
"NewProviderAggregate": [
"key",
"row",
"sort_index",
"in_failover_queue",
"initial_endpoints"
],
"RenameProvider": ["source", "target_id", "row"]
},
"databaseMethods": {
"create_provider": ["NewProviderAggregate"],
"update_provider": ["ProviderKey", "ProviderRowUpdate"],
"rename_db_only_additive_provider": ["RenameProvider"],
"add_provider_endpoint": ["ProviderKey", "NewEndpoint"],
"remove_provider_endpoint": ["ProviderKey", "str"],
"touch_provider_endpoint": ["ProviderKey", "str", "i64"]
}
}
+31
View File
@@ -0,0 +1,31 @@
{
"manifestVersion": 1,
"codeAuthority": "src-tauri/src/database/backup.rs",
"schemaVersion": 17,
"limits": {
"sqlImportBytes": 268435456,
"binaryRestoreBytes": 2147483648,
"scratchBytes": 2147483648
},
"specSha256": "c0a680de97b4d3eb291114ebfdf50fdfd65ce7f63819a0bc84f97cec6466bf07",
"tables": [
{"name": "providers", "policy": "portable_incoming"},
{"name": "provider_endpoints", "policy": "portable_incoming"},
{"name": "mcp_servers", "policy": "portable_incoming"},
{"name": "prompts", "policy": "portable_incoming"},
{"name": "skills", "policy": "portable_incoming"},
{"name": "skill_repos", "policy": "portable_incoming"},
{"name": "settings", "policy": "portable_incoming"},
{"name": "proxy_config", "policy": "portable_incoming"},
{"name": "provider_health", "policy": "rebuild_runtime"},
{"name": "proxy_request_logs", "policy": "portable_incoming"},
{"name": "model_pricing", "policy": "portable_incoming"},
{"name": "stream_check_logs", "policy": "portable_incoming"},
{"name": "proxy_live_backup", "policy": "portable_incoming"},
{"name": "usage_daily_rollups", "policy": "portable_incoming"},
{"name": "session_log_sync", "policy": "portable_incoming"},
{"name": "profiles", "policy": "portable_incoming"},
{"name": "pi_provider_projections", "policy": "preserve_live"},
{"name": "skill_deployments", "policy": "preserve_live"}
]
}