mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-08-04 19:45:34 +08:00
refactor(provider): establish typed write ownership
Replace the generic provider upsert surface with strict typed create, update, rename, endpoint, and compensation operations. This app-independent prerequisite owns provider writes without depending on Pi runtime or canonical restore. The v17 migration also reserves dormant device-local ledger tables and the Pi skill bit alongside endpoint normalization. Keeping that reservation here prevents a later stacked feature from rewriting a published migration; no Pi runtime behavior is activated by this commit. 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:
@@ -6,6 +6,9 @@ pub mod failover;
|
||||
pub mod mcp;
|
||||
pub mod profiles;
|
||||
pub mod prompts;
|
||||
pub mod provider_write;
|
||||
#[cfg(test)]
|
||||
mod provider_write_certification;
|
||||
pub mod providers;
|
||||
pub mod providers_seed;
|
||||
pub mod proxy;
|
||||
|
||||
@@ -0,0 +1,640 @@
|
||||
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;
|
||||
|
||||
use super::providers::{StoredProviderRow, PROVIDER_SELECT};
|
||||
|
||||
#[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>,
|
||||
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(),
|
||||
notes: input.notes.clone(),
|
||||
meta,
|
||||
icon: input.icon.clone(),
|
||||
icon_color: input.icon_color.clone(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ProviderRowCreate {
|
||||
content: ProviderRowUpdate,
|
||||
created_at: Option<i64>,
|
||||
}
|
||||
|
||||
#[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: ProviderRowCreate,
|
||||
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 {
|
||||
let normalized_key = key.trim().trim_end_matches('/').to_string();
|
||||
let normalized_url = endpoint.url.trim().trim_end_matches('/').to_string();
|
||||
if normalized_key != normalized_url {
|
||||
return Err(AppError::InvalidInput(format!(
|
||||
"provider endpoint key '{key}' must match endpoint URL '{}'",
|
||||
endpoint.url
|
||||
)));
|
||||
}
|
||||
if !seen.insert(normalized_url.clone()) {
|
||||
return Err(AppError::InvalidInput(format!(
|
||||
"duplicate initial provider endpoint '{}'",
|
||||
endpoint.url
|
||||
)));
|
||||
}
|
||||
initial_endpoints.push(NewEndpoint::new(
|
||||
normalized_url,
|
||||
endpoint.added_at,
|
||||
endpoint.last_used,
|
||||
)?);
|
||||
}
|
||||
let key = ProviderKey::new(app_type, input.id.clone())?;
|
||||
let row = ProviderRowCreate {
|
||||
content: ProviderRowUpdate::from_input(&input)?,
|
||||
created_at: input.created_at,
|
||||
};
|
||||
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(),
|
||||
));
|
||||
}
|
||||
let mut row = ProviderRowUpdate::from_input(input)?;
|
||||
// A successful key change always remains DB-only. The service owns
|
||||
// the corresponding live-file absence check, while the DAO persists
|
||||
// the durable half of that invariant.
|
||||
row.meta.live_config_managed = Some(false);
|
||||
Ok(Self {
|
||||
source,
|
||||
target_id: input.id.clone(),
|
||||
row,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
created_at: Option<i64>,
|
||||
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,
|
||||
created_at,
|
||||
sort_index,
|
||||
row.notes,
|
||||
row.icon,
|
||||
row.icon_color,
|
||||
meta,
|
||||
is_current,
|
||||
in_failover_queue,
|
||||
],
|
||||
)
|
||||
.map_err(|error| match &error {
|
||||
rusqlite::Error::SqliteFailure(code, _)
|
||||
if matches!(
|
||||
code.extended_code,
|
||||
rusqlite::ffi::SQLITE_CONSTRAINT_PRIMARYKEY
|
||||
| rusqlite::ffi::SQLITE_CONSTRAINT_UNIQUE
|
||||
) =>
|
||||
{
|
||||
AppError::Conflict(format!(
|
||||
"provider '{}/{}' already exists",
|
||||
key.app_type, key.id
|
||||
))
|
||||
}
|
||||
_ => 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)]
|
||||
// The certification contract keeps immutable creation time separate from the
|
||||
// mutable row DTO and calls this sealed helper directly with the full snapshot.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub(super) fn restore_provider_aggregate_on_tx(
|
||||
tx: &Transaction<'_>,
|
||||
key: &ProviderKey,
|
||||
row: &ProviderRowUpdate,
|
||||
created_at: Option<i64>,
|
||||
sort_index: Option<usize>,
|
||||
is_current: bool,
|
||||
in_failover_queue: bool,
|
||||
endpoints: &[NewEndpoint],
|
||||
) -> Result<(), AppError> {
|
||||
let updated = update_row(tx, key, row)?;
|
||||
if updated == 0 {
|
||||
insert_row(
|
||||
tx,
|
||||
key,
|
||||
row,
|
||||
created_at,
|
||||
sort_index,
|
||||
is_current,
|
||||
in_failover_queue,
|
||||
)?;
|
||||
} else {
|
||||
// Exact compensation is the only path allowed to restore immutable
|
||||
// creation time after a prior aggregate mutation.
|
||||
tx.execute(
|
||||
"UPDATE providers SET created_at = ?1 WHERE id = ?2 AND app_type = ?3",
|
||||
params![created_at, key.id, key.app_type],
|
||||
)
|
||||
.map_err(|error| AppError::Database(error.to_string()))?;
|
||||
}
|
||||
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,
|
||||
notes = ?5,
|
||||
icon = ?6,
|
||||
icon_color = ?7,
|
||||
meta = ?8
|
||||
WHERE id = ?9 AND app_type = ?10",
|
||||
params![
|
||||
row.name,
|
||||
settings_config,
|
||||
row.website_url,
|
||||
row.category,
|
||||
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.content,
|
||||
input.row.created_at,
|
||||
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(crate) fn update_provider_if_content_fingerprint(
|
||||
&self,
|
||||
key: &ProviderKey,
|
||||
expected_fingerprint: &str,
|
||||
row: &ProviderRowUpdate,
|
||||
) -> Result<(), AppError> {
|
||||
let mut conn = lock_conn!(self.conn);
|
||||
let tx = conn
|
||||
.transaction()
|
||||
.map_err(|error| AppError::Database(error.to_string()))?;
|
||||
let current = tx
|
||||
.query_row(
|
||||
&format!("{PROVIDER_SELECT} WHERE id = ?1 AND app_type = ?2"),
|
||||
params![key.id, key.app_type],
|
||||
StoredProviderRow::from_row,
|
||||
)
|
||||
.optional()
|
||||
.map_err(|error| AppError::Database(error.to_string()))?
|
||||
.ok_or_else(|| AppError::NotFound(format!("provider '{}/{}'", key.app_type, key.id)))?
|
||||
.decode(key.app_type())?;
|
||||
if current.row_content_fingerprint() != expected_fingerprint {
|
||||
return Err(AppError::Conflict(format!(
|
||||
"provider '{}/{}' changed since it was read",
|
||||
key.app_type, key.id
|
||||
)));
|
||||
}
|
||||
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(crate) 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, created_at, meta
|
||||
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)?,
|
||||
row.get::<_, Option<i64>>(4)?,
|
||||
row.get::<_, String>(5)?,
|
||||
))
|
||||
},
|
||||
)
|
||||
.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 source_meta: ProviderMeta = if source_state.5.trim().is_empty() {
|
||||
ProviderMeta::default()
|
||||
} else {
|
||||
serde_json::from_str(&source_state.5).map_err(|error| {
|
||||
AppError::Database(format!(
|
||||
"invalid meta for provider '{}/{}': {error}",
|
||||
input.source.app_type, input.source.id
|
||||
))
|
||||
})?
|
||||
};
|
||||
if source_meta.live_config_managed == Some(true) {
|
||||
return Err(AppError::Conflict(format!(
|
||||
"provider '{}/{}' became live-managed before rename",
|
||||
input.source.app_type, input.source.id
|
||||
)));
|
||||
}
|
||||
let target = ProviderKey::new(&input.source.app_type, &input.target_id)?;
|
||||
insert_row(
|
||||
&tx,
|
||||
&target,
|
||||
&input.row,
|
||||
source_state.4,
|
||||
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
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user