From 3626331a70c86da26ea8385f30604c64509855c3 Mon Sep 17 00:00:00 2001 From: Ruben Rosario Date: Sat, 20 Jun 2026 19:35:56 +0100 Subject: [PATCH] feat(infra): add sqlite storage with position-based ordering - Db struct with rusqlite Connection (WAL mode, foreign keys) - Tables: task_lists, tasks (with position column), sync_queue - CRUD: get/insert/update/delete for lists and tasks - reorder_task shifts positions of sibling tasks - replace_all_lists and replace_all_tasks for sync import - push_sync and drain_sync for offline queue management - All reads sorted by position ASC --- src/infrastructure/db.rs | 262 ++++++++++++++++++++++++++++++++++++++- 1 file changed, 261 insertions(+), 1 deletion(-) diff --git a/src/infrastructure/db.rs b/src/infrastructure/db.rs index ca8365d..0ad54b7 100644 --- a/src/infrastructure/db.rs +++ b/src/infrastructure/db.rs @@ -1 +1,261 @@ -// TODO: Fase 2 - SQLite storage implementation +use rusqlite::{params, Connection, Result as SqlResult}; +use chrono::NaiveDateTime; + +use crate::domain::models::*; + +pub struct Db { + conn: Connection, +} + +impl Db { + pub fn new(path: &str) -> SqlResult { + let conn = Connection::open(path)?; + conn.execute_batch("PRAGMA journal_mode=WAL; PRAGMA foreign_keys=ON;")?; + conn.execute_batch( + "CREATE TABLE IF NOT EXISTS task_lists ( + id TEXT PRIMARY KEY, + title TEXT NOT NULL + ); + + CREATE TABLE IF NOT EXISTS tasks ( + id TEXT PRIMARY KEY, + list_id TEXT NOT NULL, + title TEXT NOT NULL, + notes TEXT, + status TEXT NOT NULL DEFAULT 'needsAction', + due TEXT, + position INTEGER NOT NULL DEFAULT 0, + updated_at TEXT NOT NULL, + FOREIGN KEY (list_id) REFERENCES task_lists(id) ON DELETE CASCADE + ); + + CREATE TABLE IF NOT EXISTS sync_queue ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + action TEXT NOT NULL, + task_id TEXT NOT NULL, + list_id TEXT NOT NULL, + payload TEXT NOT NULL, + created_at TEXT NOT NULL + );", + )?; + Ok(Self { conn }) + } + + pub fn get_lists(&self) -> Vec { + let mut stmt = self.conn + .prepare("SELECT id, title FROM task_lists ORDER BY title") + .unwrap(); + stmt.query_map([], |row| { + Ok(TaskList { + id: row.get(0)?, + title: row.get(1)?, + }) + }) + .unwrap() + .filter_map(|r| r.ok()) + .collect() + } + + pub fn insert_list(&self, list: &TaskList) -> SqlResult<()> { + self.conn.execute( + "INSERT OR REPLACE INTO task_lists (id, title) VALUES (?1, ?2)", + params![list.id, list.title], + )?; + Ok(()) + } + + pub fn delete_list(&self, list_id: &str) -> SqlResult<()> { + self.conn.execute("DELETE FROM tasks WHERE list_id = ?1", params![list_id])?; + self.conn.execute("DELETE FROM task_lists WHERE id = ?1", params![list_id])?; + Ok(()) + } + + pub fn get_tasks(&self, list_id: &str) -> Vec { + let mut stmt = self.conn + .prepare( + "SELECT id, list_id, title, notes, status, due, position + FROM tasks WHERE list_id = ?1 ORDER BY position ASC", + ) + .unwrap(); + stmt.query_map(params![list_id], |row| { + let due_str: Option = row.get(5)?; + let due = due_str.and_then(|s| NaiveDateTime::parse_from_str(&s, "%Y-%m-%d %H:%M").ok()); + Ok(Task { + id: row.get(0)?, + list_id: row.get(1)?, + title: row.get(2)?, + notes: row.get(3)?, + status: match row.get::<_, String>(4)?.as_str() { + "completed" => TaskStatus::Completed, + _ => TaskStatus::NeedsAction, + }, + due, + position: row.get(6)?, + }) + }) + .unwrap() + .filter_map(|r| r.ok()) + .collect() + } + + pub fn insert_task(&self, task: &Task) -> SqlResult<()> { + let due_str = task.due.map(|d| d.format("%Y-%m-%d %H:%M").to_string()); + let status_str = match task.status { + TaskStatus::Completed => "completed", + TaskStatus::NeedsAction => "needsAction", + }; + let position = if task.position == 0 { + let max: i64 = self.conn + .query_row( + "SELECT COALESCE(MAX(position), -1) + 1 FROM tasks WHERE list_id = ?1", + params![task.list_id], + |row| row.get(0), + ) + .unwrap_or(0); + max + } else { + task.position + }; + self.conn.execute( + "INSERT OR REPLACE INTO tasks (id, list_id, title, notes, status, due, position, updated_at) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)", + params![ + task.id, + task.list_id, + task.title, + task.notes, + status_str, + due_str, + position, + chrono::Utc::now().format("%Y-%m-%d %H:%M:%S").to_string(), + ], + )?; + Ok(()) + } + + pub fn update_task(&self, task: &Task) -> SqlResult<()> { + let due_str = task.due.map(|d| d.format("%Y-%m-%d %H:%M").to_string()); + let status_str = match task.status { + TaskStatus::Completed => "completed", + TaskStatus::NeedsAction => "needsAction", + }; + self.conn.execute( + "UPDATE tasks SET title=?1, notes=?2, status=?3, due=?4, position=?5, updated_at=?6 + WHERE id=?7", + params![ + task.title, + task.notes, + status_str, + due_str, + task.position, + chrono::Utc::now().format("%Y-%m-%d %H:%M:%S").to_string(), + task.id, + ], + )?; + Ok(()) + } + + pub fn delete_task(&self, task_id: &str) -> SqlResult<()> { + self.conn.execute("DELETE FROM tasks WHERE id = ?1", params![task_id])?; + Ok(()) + } + + pub fn reorder_task(&self, task_id: &str, new_position: i64) -> SqlResult<()> { + let (old_position, list_id): (i64, String) = self.conn.query_row( + "SELECT position, list_id FROM tasks WHERE id = ?1", + params![task_id], + |row| Ok((row.get(0)?, row.get(1)?)), + )?; + + if old_position == new_position { + return Ok(()); + } + + if new_position > old_position { + self.conn.execute( + "UPDATE tasks SET position = position - 1 + WHERE list_id = ?1 AND position > ?2 AND position <= ?3", + params![list_id, old_position, new_position], + )?; + } else { + self.conn.execute( + "UPDATE tasks SET position = position + 1 + WHERE list_id = ?1 AND position >= ?2 AND position < ?3", + params![list_id, new_position, old_position], + )?; + } + + self.conn.execute( + "UPDATE tasks SET position = ?1 WHERE id = ?2", + params![new_position, task_id], + )?; + + Ok(()) + } + + pub fn replace_all_lists(&self, lists: &[TaskList]) -> SqlResult<()> { + self.conn.execute("DELETE FROM task_lists", [])?; + for list in lists { + self.insert_list(list)?; + } + Ok(()) + } + + pub fn replace_all_tasks(&self, list_id: &str, tasks: &[Task]) -> SqlResult<()> { + self.conn.execute("DELETE FROM tasks WHERE list_id = ?1", params![list_id])?; + for (i, task) in tasks.iter().enumerate() { + let mut t = task.clone(); + if t.position == 0 { + t.position = i as i64; + } + t.list_id = list_id.to_string(); + self.insert_task(&t)?; + } + Ok(()) + } + + pub fn push_sync(&self, action: SyncAction, task_id: &str, list_id: &str, payload: &str) -> SqlResult<()> { + let action_str = serde_json::to_string(&action).unwrap_or_default(); + self.conn.execute( + "INSERT INTO sync_queue (action, task_id, list_id, payload, created_at) + VALUES (?1, ?2, ?3, ?4, ?5)", + params![ + action_str, + task_id, + list_id, + payload, + chrono::Utc::now().format("%Y-%m-%d %H:%M:%S").to_string(), + ], + )?; + Ok(()) + } + + pub fn drain_sync(&self) -> Vec { + let items: Vec = { + let mut stmt = self.conn + .prepare("SELECT id, action, task_id, list_id, payload, created_at FROM sync_queue ORDER BY id") + .unwrap(); + stmt.query_map([], |row| { + let action_str: String = row.get(1)?; + let action: SyncAction = serde_json::from_str(&action_str).unwrap_or(SyncAction::Update); + Ok(SyncQueueItem { + id: row.get(0)?, + action, + task_id: row.get(2)?, + list_id: row.get(3)?, + payload: row.get(4)?, + created_at: row.get(5)?, + }) + }) + .unwrap() + .filter_map(|r| r.ok()) + .collect() + }; + + if !items.is_empty() { + self.conn.execute("DELETE FROM sync_queue", []).unwrap(); + } + + items + } +}