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
This commit is contained in:
+261
-1
@@ -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<Self> {
|
||||
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<TaskList> {
|
||||
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<Task> {
|
||||
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<String> = 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<SyncQueueItem> {
|
||||
let items: Vec<SyncQueueItem> = {
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user