chore: wave-0 baseline (Tauri2+React shell, platform detect, fake catalog)
This commit is contained in:
@@ -0,0 +1,11 @@
|
||||
[package]
|
||||
name = "agentdock-store"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
description = "SQLite 本地状态存储:schema 迁移与仓库(架构 §2 状态存储层)"
|
||||
|
||||
[dependencies]
|
||||
rusqlite = { version = "0.32", features = ["bundled"] }
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
@@ -0,0 +1,32 @@
|
||||
//! 存储层错误类型
|
||||
|
||||
use std::fmt;
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum StoreError {
|
||||
Io(std::io::Error),
|
||||
Sqlite(rusqlite::Error),
|
||||
}
|
||||
|
||||
impl fmt::Display for StoreError {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
StoreError::Io(e) => write!(f, "IO: {e}"),
|
||||
StoreError::Sqlite(e) => write!(f, "SQLite: {e}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for StoreError {}
|
||||
|
||||
impl From<std::io::Error> for StoreError {
|
||||
fn from(e: std::io::Error) -> Self {
|
||||
StoreError::Io(e)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<rusqlite::Error> for StoreError {
|
||||
fn from(e: rusqlite::Error) -> Self {
|
||||
StoreError::Sqlite(e)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
//! agentdock-store —— 本地状态存储(SQLite)
|
||||
//!
|
||||
//! Wave 0:SQLite 空库 + schema 迁移骨架。仓库查询接口随后续波次实现。
|
||||
|
||||
pub mod error;
|
||||
pub mod migrations;
|
||||
pub mod schema;
|
||||
pub mod store;
|
||||
|
||||
pub use error::StoreError;
|
||||
pub use store::{Store, default_data_dir};
|
||||
@@ -0,0 +1,26 @@
|
||||
//! 迁移执行器(Wave 0 骨架)
|
||||
|
||||
use rusqlite::Connection;
|
||||
|
||||
use crate::schema::{MIGRATIONS, SCHEMA_VERSION};
|
||||
|
||||
/// 应用全部迁移。所有语句均为幂等(IF NOT EXISTS),可安全重复执行。
|
||||
pub fn migrate(conn: &Connection) -> rusqlite::Result<()> {
|
||||
let tx = conn.unchecked_transaction()?;
|
||||
for sql in MIGRATIONS {
|
||||
tx.execute_batch(sql)?;
|
||||
}
|
||||
tx.execute(
|
||||
"INSERT OR REPLACE INTO schema_version (version) VALUES (?1)",
|
||||
[SCHEMA_VERSION],
|
||||
)?;
|
||||
tx.commit()
|
||||
}
|
||||
|
||||
/// 读取当前 schema 版本(未初始化返回 0)
|
||||
pub fn current_version(conn: &Connection) -> rusqlite::Result<i64> {
|
||||
conn.query_row("SELECT version FROM schema_version ORDER BY version DESC LIMIT 1", [], |r| {
|
||||
r.get(0)
|
||||
})
|
||||
.or(Ok(0))
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
//! schema 定义与迁移骨架(Wave 0)
|
||||
//!
|
||||
//! 迁移采用顺序数组:每个元素是一段幂等 SQL(CREATE TABLE IF NOT EXISTS)。
|
||||
//! 后续波次新增表时在 `MIGRATIONS` 尾部追加并提升 `SCHEMA_VERSION`。
|
||||
|
||||
/// 当前 schema 版本
|
||||
pub const SCHEMA_VERSION: i64 = 1;
|
||||
|
||||
/// 迁移列表(按顺序执行,均需幂等)
|
||||
pub const MIGRATIONS: [&str; 3] = [
|
||||
// 迁移 1:schema 版本表
|
||||
"CREATE TABLE IF NOT EXISTS schema_version (
|
||||
version INTEGER NOT NULL,
|
||||
applied_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);",
|
||||
// 迁移 2:已安装 CLI 记录
|
||||
"CREATE TABLE IF NOT EXISTS installed_clis (
|
||||
cli_id TEXT PRIMARY KEY,
|
||||
name_zh TEXT NOT NULL,
|
||||
version TEXT,
|
||||
install_channel TEXT,
|
||||
detected_at TEXT
|
||||
);",
|
||||
// 迁移 3:诊断历史
|
||||
"CREATE TABLE IF NOT EXISTS diagnostic_history (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
cli_id TEXT NOT NULL,
|
||||
rule_id TEXT NOT NULL,
|
||||
severity TEXT NOT NULL,
|
||||
message_zh TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);",
|
||||
];
|
||||
@@ -0,0 +1,105 @@
|
||||
//! Store:打开/创建 SQLite 空库并应用迁移(Wave 0)
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use rusqlite::Connection;
|
||||
|
||||
use crate::error::StoreError;
|
||||
use crate::migrations;
|
||||
|
||||
/// 本地状态存储句柄
|
||||
pub struct Store {
|
||||
conn: Connection,
|
||||
path: PathBuf,
|
||||
}
|
||||
|
||||
impl Store {
|
||||
/// 打开(不存在则创建)指定路径的 SQLite 库并应用迁移
|
||||
pub fn open<P: AsRef<Path>>(path: P) -> Result<Self, StoreError> {
|
||||
let path = path.as_ref().to_path_buf();
|
||||
if let Some(parent) = path.parent() {
|
||||
if !parent.as_os_str().is_empty() {
|
||||
std::fs::create_dir_all(parent)?;
|
||||
}
|
||||
}
|
||||
let conn = Connection::open(&path)?;
|
||||
conn.pragma_update(None, "foreign_keys", true)?;
|
||||
migrations::migrate(&conn)?;
|
||||
Ok(Self { conn, path })
|
||||
}
|
||||
|
||||
/// 打开默认数据目录下的 agentdock.db(Windows: %APPDATA%\agentdock)
|
||||
pub fn open_default() -> Result<Self, StoreError> {
|
||||
Self::open(default_data_dir().join("agentdock.db"))
|
||||
}
|
||||
|
||||
pub fn path(&self) -> &Path {
|
||||
&self.path
|
||||
}
|
||||
|
||||
pub fn connection(&self) -> &Connection {
|
||||
&self.conn
|
||||
}
|
||||
}
|
||||
|
||||
/// 默认数据目录
|
||||
pub fn default_data_dir() -> PathBuf {
|
||||
#[cfg(windows)]
|
||||
{
|
||||
if let Some(appdata) = std::env::var_os("APPDATA") {
|
||||
return PathBuf::from(appdata).join("agentdock");
|
||||
}
|
||||
PathBuf::from(".").join("agentdock")
|
||||
}
|
||||
#[cfg(not(windows))]
|
||||
{
|
||||
if let Some(xdg) = std::env::var_os("XDG_DATA_HOME") {
|
||||
return PathBuf::from(xdg).join("agentdock");
|
||||
}
|
||||
if let Some(home) = std::env::var_os("HOME") {
|
||||
return PathBuf::from(home).join(".local/share").join("agentdock");
|
||||
}
|
||||
PathBuf::from(".").join("agentdock")
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::migrations::current_version;
|
||||
use crate::schema::SCHEMA_VERSION;
|
||||
|
||||
#[test]
|
||||
fn in_memory_migration_applies_and_is_idempotent() {
|
||||
let conn = Connection::open_in_memory().expect("内存库应可打开");
|
||||
migrations::migrate(&conn).expect("首次迁移应成功");
|
||||
assert_eq!(current_version(&conn).unwrap(), SCHEMA_VERSION);
|
||||
|
||||
// 幂等:重复迁移不报错、版本不变
|
||||
migrations::migrate(&conn).expect("重复迁移应成功");
|
||||
assert_eq!(current_version(&conn).unwrap(), SCHEMA_VERSION);
|
||||
|
||||
// 关键表存在
|
||||
let tables: Vec<String> = conn
|
||||
.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name IN ('schema_version','installed_clis','diagnostic_history')")
|
||||
.unwrap()
|
||||
.query_map([], |r| r.get(0))
|
||||
.unwrap()
|
||||
.collect::<Result<_, _>>()
|
||||
.unwrap();
|
||||
assert_eq!(tables.len(), 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn open_creates_empty_db_file() {
|
||||
let dir = std::env::temp_dir().join(format!("agentdock-test-{}", std::process::id()));
|
||||
let db = dir.join("agentdock.db");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
|
||||
let store = Store::open(&db).expect("应能创建库文件");
|
||||
assert!(db.exists(), "SQLite 空库文件应已创建");
|
||||
assert_eq!(current_version(store.connection()).unwrap(), SCHEMA_VERSION);
|
||||
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user