//! Classroom SQLite persistence //! //! Persists Classroom + ChatState to a local SQLite database so that //! classroom data survives app restarts. Uses JSON blobs for simplicity //! since the data is always loaded/written as a whole unit. use std::path::PathBuf; use std::sync::Arc; use tokio::sync::Mutex; use sqlx::SqliteConnection; use sqlx::Connection; use zclaw_kernel::generation::{Classroom, ClassroomChatMessage, ClassroomChatState}; use super::{ClassroomStore, chat::ChatStore}; /// SQLite-backed persistence for classrooms and chat history. pub struct ClassroomPersistence { conn: Arc>, } impl ClassroomPersistence { /// Open an in-memory database (no persistence across restarts, used as fallback). pub async fn open_in_memory() -> Result { let conn = SqliteConnection::connect("sqlite::memory:") .await .map_err(|e| format!("Failed to open in-memory classroom DB: {}", e))?; let persist = Self { conn: Arc::new(Mutex::new(conn)), }; persist.init_schema().await?; Ok(persist) } /// Open (or create) the classroom database at the given path. pub async fn open(db_path: impl Into) -> Result { let db_path = db_path.into(); let db_url = format!("sqlite:{}?mode=rwc", db_path.display()); let conn = SqliteConnection::connect(&db_url) .await .map_err(|e| format!("Failed to open classroom DB: {}", e))?; let persist = Self { conn: Arc::new(Mutex::new(conn)), }; persist.init_schema().await?; Ok(persist) } async fn init_schema(&self) -> Result<(), String> { let mut conn = self.conn.lock().await; sqlx::query( r#" CREATE TABLE IF NOT EXISTS classrooms ( id TEXT PRIMARY KEY, data TEXT NOT NULL, created_at TEXT NOT NULL DEFAULT (datetime('now')) ); CREATE TABLE IF NOT EXISTS classroom_chats ( classroom_id TEXT PRIMARY KEY, data TEXT NOT NULL, updated_at TEXT NOT NULL DEFAULT (datetime('now')) ); "#, ) .execute(&mut *conn) .await .map_err(|e| format!("Failed to create classroom schema: {}", e))?; Ok(()) } /// Save a classroom to SQLite. pub async fn save_classroom(&self, classroom: &Classroom) -> Result<(), String> { let mut conn = self.conn.lock().await; let data = serde_json::to_string(classroom) .map_err(|e| format!("Failed to serialize classroom: {}", e))?; sqlx::query( "INSERT OR REPLACE INTO classrooms (id, data) VALUES (?, ?)" ) .bind(&classroom.id) .bind(&data) .execute(&mut *conn) .await .map_err(|e| format!("Failed to save classroom: {}", e))?; Ok(()) } /// Save chat state for a classroom. pub async fn save_chat(&self, classroom_id: &str, messages: &[ClassroomChatMessage]) -> Result<(), String> { let mut conn = self.conn.lock().await; let data = serde_json::to_string(messages) .map_err(|e| format!("Failed to serialize chat messages: {}", e))?; sqlx::query( "INSERT OR REPLACE INTO classroom_chats (classroom_id, data) VALUES (?, ?)" ) .bind(classroom_id) .bind(&data) .execute(&mut *conn) .await .map_err(|e| format!("Failed to save chat: {}", e))?; Ok(()) } /// Delete a classroom and its chat history. #[allow(dead_code)] pub async fn delete_classroom(&self, classroom_id: &str) -> Result<(), String> { let mut conn = self.conn.lock().await; sqlx::query("DELETE FROM classrooms WHERE id = ?") .bind(classroom_id) .execute(&mut *conn) .await .map_err(|e| format!("Failed to delete classroom: {}", e))?; sqlx::query("DELETE FROM classroom_chats WHERE classroom_id = ?") .bind(classroom_id) .execute(&mut *conn) .await .map_err(|e| format!("Failed to delete chat: {}", e))?; Ok(()) } /// Load all persisted classrooms into the in-memory store. pub async fn load_all(&self, store: &ClassroomStore, chat_store: &ChatStore) -> Result { let mut conn = self.conn.lock().await; // Load classrooms let rows: Vec<(String, String)> = sqlx::query_as( "SELECT id, data FROM classrooms" ) .fetch_all(&mut *conn) .await .map_err(|e| format!("Failed to load classrooms: {}", e))?; let mut loaded = 0; let mut s = store.lock().await; for (id, data) in &rows { match serde_json::from_str::(data) { Ok(classroom) => { s.insert(id.clone(), classroom); loaded += 1; } Err(e) => { tracing::warn!("Failed to deserialize classroom {}: {}", id, e); } } } drop(s); // Load chat history let chat_rows: Vec<(String, String)> = sqlx::query_as( "SELECT classroom_id, data FROM classroom_chats" ) .fetch_all(&mut *conn) .await .map_err(|e| format!("Failed to load chats: {}", e))?; let mut cs = chat_store.lock().await; for (classroom_id, data) in &chat_rows { match serde_json::from_str::>(data) { Ok(messages) => { cs.insert(classroom_id.clone(), ClassroomChatState { messages, active: true, }); } Err(e) => { tracing::warn!("Failed to deserialize chat for {}: {}", classroom_id, e); } } } Ok(loaded) } }