mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-08-04 11:43:57 +08:00
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:
@@ -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(())
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user