//! Repository pattern over the SQLite schema. //! //! `LocalDatabaseRepository` is the single concrete type that owns the //! `Connection`. Repository traits (`BookmarkRepository`, //! `SettingsRepository`) describe the API surface the rest of the Rust //! core depends on. This mirrors the production split where the //! Chanora core consumes traits rather than the concrete SQLite type //! (SAD-067, SDD-077). use std::path::Path; use rusqlite::{params, Connection, OptionalExtension}; use thiserror::Error; use crate::migrate::{canonical_migrations, Migrator}; #[derive(Debug, Error)] pub enum RepoError { #[error("sqlite error: {0}")] Sqlite(#[from] rusqlite::Error), #[error("migration error: {0}")] Migration(#[from] crate::migrate::MigrationError), #[error("not found")] NotFound, } // ---------- DTOs ---------- #[derive(Debug, Clone, PartialEq, Eq)] pub struct Bookmark { pub id: i64, pub server_name: String, pub server_host: String, /// Stable reference to a secret in platform secure storage. /// **Not** the secret itself (SAD-067, see secure-storage-spike). pub identity_ref: String, pub nickname: Option, /// Unix epoch seconds; `None` if never connected. pub last_connected_at: Option, pub created_at: i64, } #[derive(Debug, Clone, PartialEq, Eq)] pub struct Setting { pub key: String, pub value: String, } // ---------- Traits ---------- pub trait BookmarkRepository { fn insert(&self, b: NewBookmark<'_>) -> Result; fn get(&self, id: i64) -> Result; fn list_ordered(&self) -> Result, RepoError>; fn touch_connected(&self, id: i64, at_epoch_secs: i64) -> Result<(), RepoError>; fn delete(&self, id: i64) -> Result<(), RepoError>; } pub trait SettingsRepository { fn put(&self, key: &str, value: &str) -> Result<(), RepoError>; fn get(&self, key: &str) -> Result, RepoError>; fn delete(&self, key: &str) -> Result; } #[derive(Debug, Clone)] pub struct NewBookmark<'a> { pub server_name: &'a str, pub server_host: &'a str, pub identity_ref: &'a str, pub nickname: Option<&'a str>, } // ---------- Concrete repository ---------- pub struct LocalDatabaseRepository { conn: Connection, } impl LocalDatabaseRepository { /// Open (or create) a SQLite database at `path` and bring the /// schema up to date via the migrator. pub fn open(path: &Path) -> Result { let mut conn = Connection::open(path)?; Self::pragmas(&conn)?; let migrator = Migrator::new(canonical_migrations()).map_err(RepoError::Migration)?; migrator.run(&mut conn).map_err(RepoError::Migration)?; Ok(Self { conn }) } /// Open an in-memory DB for tests. pub fn open_in_memory() -> Result { let mut conn = Connection::open_in_memory()?; Self::pragmas(&conn)?; let migrator = Migrator::new(canonical_migrations()).map_err(RepoError::Migration)?; migrator.run(&mut conn).map_err(RepoError::Migration)?; Ok(Self { conn }) } fn pragmas(conn: &Connection) -> Result<(), RepoError> { // Durability + concurrency defaults that match production // expectations for a desktop/mobile embedded DB. conn.execute_batch( "PRAGMA journal_mode = WAL; PRAGMA synchronous = NORMAL; PRAGMA foreign_keys = ON;", )?; Ok(()) } /// Current schema version (PRAGMA user_version). pub fn schema_version(&self) -> Result { let v: i64 = self .conn .query_row("PRAGMA user_version", [], |r| r.get(0))?; Ok(v as u32) } } impl BookmarkRepository for LocalDatabaseRepository { fn insert(&self, b: NewBookmark<'_>) -> Result { self.conn.execute( "INSERT INTO bookmarks (server_name, server_host, identity_ref, nickname) VALUES (?1, ?2, ?3, ?4)", params![b.server_name, b.server_host, b.identity_ref, b.nickname], )?; Ok(self.conn.last_insert_rowid()) } fn get(&self, id: i64) -> Result { self.conn .query_row( "SELECT id, server_name, server_host, identity_ref, nickname, last_connected_at, created_at FROM bookmarks WHERE id = ?1", params![id], row_to_bookmark, ) .optional()? .ok_or(RepoError::NotFound) } fn list_ordered(&self) -> Result, RepoError> { let mut stmt = self.conn.prepare( "SELECT id, server_name, server_host, identity_ref, nickname, last_connected_at, created_at FROM bookmarks ORDER BY last_connected_at DESC NULLS LAST, server_name ASC", )?; let rows = stmt.query_map([], row_to_bookmark)?; let mut out = Vec::new(); for r in rows { out.push(r?); } Ok(out) } fn touch_connected(&self, id: i64, at_epoch_secs: i64) -> Result<(), RepoError> { let n = self.conn.execute( "UPDATE bookmarks SET last_connected_at = ?2 WHERE id = ?1", params![id, at_epoch_secs], )?; if n == 0 { return Err(RepoError::NotFound); } Ok(()) } fn delete(&self, id: i64) -> Result<(), RepoError> { let n = self .conn .execute("DELETE FROM bookmarks WHERE id = ?1", params![id])?; if n == 0 { return Err(RepoError::NotFound); } Ok(()) } } impl SettingsRepository for LocalDatabaseRepository { fn put(&self, key: &str, value: &str) -> Result<(), RepoError> { self.conn.execute( "INSERT INTO settings (key, value) VALUES (?1, ?2) ON CONFLICT(key) DO UPDATE SET value = excluded.value", params![key, value], )?; Ok(()) } fn get(&self, key: &str) -> Result, RepoError> { Ok(self .conn .query_row( "SELECT value FROM settings WHERE key = ?1", params![key], |r| r.get(0), ) .optional()?) } fn delete(&self, key: &str) -> Result { let n = self .conn .execute("DELETE FROM settings WHERE key = ?1", params![key])?; Ok(n > 0) } } fn row_to_bookmark(row: &rusqlite::Row<'_>) -> rusqlite::Result { Ok(Bookmark { id: row.get(0)?, server_name: row.get(1)?, server_host: row.get(2)?, identity_ref: row.get(3)?, nickname: row.get(4)?, last_connected_at: row.get(5)?, created_at: row.get(6)?, }) }