use rusqlite::{Connection, params}; use std::path::Path; #[derive(Debug, thiserror::Error)] pub enum StoreError { #[error("sqlite error: {0}")] Sqlite(#[from] rusqlite::Error), #[error("json error: {0}")] Json(#[from] serde_json::Error), } pub struct Store { conn: Connection, } impl Store { pub fn open(path: &Path) -> Result { let conn = Connection::open(path)?; let store = Self { conn }; store.migrate()?; Ok(store) } pub fn open_memory() -> Result { let conn = Connection::open_in_memory()?; let store = Self { conn }; store.migrate()?; Ok(store) } pub fn migrate(&self) -> Result<(), StoreError> { self.conn.execute_batch( r#" PRAGMA foreign_keys = ON; CREATE TABLE IF NOT EXISTS meta ( key TEXT PRIMARY KEY, value TEXT NOT NULL ); CREATE TABLE IF NOT EXISTS agents ( agent_id TEXT PRIMARY KEY, public_key TEXT NOT NULL, created_at_ms INTEGER NOT NULL ); CREATE TABLE IF NOT EXISTS nodes ( node_id TEXT PRIMARY KEY, name TEXT NOT NULL, agent_id TEXT, created_at_ms INTEGER NOT NULL ); CREATE TABLE IF NOT EXISTS resources ( resource_id TEXT PRIMARY KEY, kind TEXT NOT NULL, name TEXT NOT NULL, authority_ref TEXT NOT NULL, local_role TEXT NOT NULL, replication_policy TEXT NOT NULL, retention_policy TEXT NOT NULL, status TEXT NOT NULL, created_at_ms INTEGER NOT NULL ); CREATE TABLE IF NOT EXISTS keychain_ops ( op_id TEXT PRIMARY KEY, op_json TEXT NOT NULL, created_at_ms INTEGER NOT NULL ); CREATE TABLE IF NOT EXISTS auth_ops ( op_id TEXT PRIMARY KEY, resource_id TEXT NOT NULL, op_json TEXT NOT NULL, created_at_ms INTEGER NOT NULL ); CREATE TABLE IF NOT EXISTS grants ( grant_id TEXT PRIMARY KEY, resource_id TEXT NOT NULL, principal_id TEXT NOT NULL, capability TEXT NOT NULL, revoked INTEGER NOT NULL DEFAULT 0 ); CREATE TABLE IF NOT EXISTS resource_secrets ( secret_id TEXT PRIMARY KEY, resource_id TEXT NOT NULL, epoch INTEGER NOT NULL, status TEXT NOT NULL, created_at_ms INTEGER NOT NULL ); CREATE TABLE IF NOT EXISTS cas_objects ( hash TEXT PRIMARY KEY, size_bytes INTEGER NOT NULL, path TEXT NOT NULL, created_at_ms INTEGER NOT NULL ); CREATE TABLE IF NOT EXISTS cas_pins ( hash TEXT PRIMARY KEY, pinned_at_ms INTEGER NOT NULL ); CREATE TABLE IF NOT EXISTS kv_stores ( kv_id TEXT PRIMARY KEY, resource_id TEXT NOT NULL, name TEXT NOT NULL ); CREATE TABLE IF NOT EXISTS db_resources ( db_id TEXT PRIMARY KEY, resource_id TEXT NOT NULL, path TEXT NOT NULL ); CREATE TABLE IF NOT EXISTS document_resources ( document_id TEXT PRIMARY KEY, resource_id TEXT NOT NULL, name TEXT NOT NULL ); CREATE TABLE IF NOT EXISTS peer_cards ( peer_id TEXT PRIMARY KEY, card_json TEXT NOT NULL, updated_at_ms INTEGER NOT NULL ); CREATE TABLE IF NOT EXISTS module_state ( module TEXT PRIMARY KEY, state_json TEXT NOT NULL, updated_at_ms INTEGER NOT NULL ); INSERT OR IGNORE INTO meta(key, value) VALUES ('schema_version', '1'); "#, )?; Ok(()) } pub fn upsert_agent(&self, agent_id: &str, public_key: &str) -> Result<(), StoreError> { self.conn.execute( "INSERT OR IGNORE INTO agents(agent_id, public_key, created_at_ms) VALUES (?1, ?2, ?3)", params![agent_id, public_key, now_ms()], )?; Ok(()) } pub fn upsert_node(&self, node_id: &str, name: &str, agent_id: &str) -> Result<(), StoreError> { self.conn.execute( "INSERT OR IGNORE INTO nodes(node_id, name, agent_id, created_at_ms) VALUES (?1, ?2, ?3, ?4)", params![node_id, name, agent_id, now_ms()], )?; Ok(()) } pub fn list_resources(&self) -> Result, StoreError> { let mut stmt = self.conn.prepare( "SELECT resource_id, kind, name, status FROM resources ORDER BY kind, name, resource_id", )?; let rows = stmt.query_map([], |row| { Ok(StoredResource { resource_id: row.get(0)?, kind: row.get(1)?, name: row.get(2)?, status: row.get(3)?, }) })?; rows.collect::, _>>() .map_err(StoreError::from) } pub fn insert_resource(&self, resource: &StoredResource) -> Result<(), StoreError> { self.conn.execute( r#"INSERT OR IGNORE INTO resources( resource_id, kind, name, authority_ref, local_role, replication_policy, retention_policy, status, created_at_ms ) VALUES (?1, ?2, ?3, 'local', 'owner', 'local-only', 'keep', ?4, ?5)"#, params![ resource.resource_id, resource.kind, resource.name, resource.status, now_ms() ], )?; Ok(()) } pub fn record_cas_object( &self, hash: &str, size_bytes: u64, path: &str, ) -> Result<(), StoreError> { self.conn.execute( "INSERT OR REPLACE INTO cas_objects(hash, size_bytes, path, created_at_ms) VALUES (?1, ?2, ?3, ?4)", params![hash, size_bytes as i64, path, now_ms()], )?; Ok(()) } pub fn list_cas_objects(&self) -> Result, StoreError> { let mut stmt = self.conn.prepare( "SELECT hash, size_bytes, path FROM cas_objects ORDER BY created_at_ms, hash", )?; let rows = stmt.query_map([], |row| { Ok(CasObject { hash: row.get(0)?, size_bytes: row.get::<_, i64>(1)? as u64, path: row.get(2)?, }) })?; rows.collect::, _>>() .map_err(StoreError::from) } } #[derive(Clone, Debug, PartialEq, Eq)] pub struct StoredResource { pub resource_id: String, pub kind: String, pub name: String, pub status: String, } #[derive(Clone, Debug, PartialEq, Eq)] pub struct CasObject { pub hash: String, pub size_bytes: u64, pub path: String, } #[must_use] pub fn now_ms() -> i64 { let now = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .unwrap_or_default(); i64::try_from(now.as_millis()).unwrap_or(i64::MAX) } #[cfg(test)] mod tests { use super::*; #[test] fn migrations_are_idempotent() { let store = Store::open_memory().expect("open"); store.migrate().expect("migrate again"); store.migrate().expect("migrate third time"); let resources = store.list_resources().expect("resources"); assert!(resources.is_empty()); } }