Files
task_app_rust/src/infrastructure/db.rs
T
Ruben Rosario 9649ca96b0 Fix list deletion sync: SyncAction::DeleteList
- Added DeleteList variant to SyncAction enum
- Added ApiClient::delete_list() calling DELETE /users/@me/lists/{id}
- List deletion uses DeleteList action (not Delete/delete_task)
- Sync engine handles DeleteList calling api.delete_list()
2026-06-21 18:27:53 +01:00

391 lines
13 KiB
Rust

use std::sync::Mutex;
use chrono::NaiveDateTime;
use rusqlite::{params, Connection, Result as SqlResult};
use crate::domain::models::*;
pub struct Db {
conn: Mutex<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,
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%d %H:%M:%S', 'now')),
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,
retries INTEGER NOT NULL DEFAULT 0
);",
)?;
conn.execute_batch(
"ALTER TABLE sync_queue ADD COLUMN retries INTEGER NOT NULL DEFAULT 0;",
)
.ok();
conn.execute_batch(
"ALTER TABLE tasks ADD COLUMN created_at TEXT NOT NULL DEFAULT '';",
)
.ok();
conn.execute_batch(
"UPDATE tasks SET created_at = updated_at WHERE created_at = '';",
)
.ok();
Ok(Self { conn: Mutex::new(conn) })
}
pub fn get_lists(&self) -> Vec<TaskList> {
let conn = self.conn.lock().unwrap();
let mut stmt = 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<()> {
let conn = self.conn.lock().unwrap();
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<()> {
let conn = self.conn.lock().unwrap();
conn.execute("DELETE FROM tasks WHERE list_id = ?1", params![list_id])?;
conn.execute("DELETE FROM task_lists WHERE id = ?1", params![list_id])?;
Ok(())
}
pub fn get_tasks(&self, list_id: &str) -> Vec<Task> {
let conn = self.conn.lock().unwrap();
let mut stmt = conn
.prepare(
"SELECT id, list_id, title, notes, status, due, position, created_at, updated_at
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());
let created_str: String = row.get(7)?;
let updated_str: String = row.get(8)?;
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)?,
created_at: NaiveDateTime::parse_from_str(&created_str, "%Y-%m-%d %H:%M:%S").ok(),
updated_at: NaiveDateTime::parse_from_str(&updated_str, "%Y-%m-%d %H:%M:%S").ok(),
})
})
.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 conn = self.conn.lock().unwrap();
let position = if task.position == 0 {
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)
} else {
task.position
};
// Preserve existing created_at if the task already exists.
// For new tasks from API, use updated_at as created_at proxy
// (Google Tasks API does not provide a creation timestamp).
let created_at = task.created_at.unwrap_or_else(|| {
conn.query_row(
"SELECT created_at FROM tasks WHERE id = ?1",
params![task.id],
|row| row.get::<_, String>(0),
)
.ok()
.and_then(|s| NaiveDateTime::parse_from_str(&s, "%Y-%m-%d %H:%M:%S").ok())
.or_else(|| task.updated_at)
.unwrap_or_else(|| chrono::Utc::now().naive_utc())
});
let updated_at = task
.updated_at
.unwrap_or_else(|| chrono::Utc::now().naive_utc());
conn.execute(
"INSERT OR REPLACE INTO tasks (id, list_id, title, notes, status, due, position, updated_at, created_at)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)",
params![
task.id,
task.list_id,
task.title,
task.notes,
status_str,
due_str,
position,
updated_at.format("%Y-%m-%d %H:%M:%S").to_string(),
created_at.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",
};
let conn = self.conn.lock().unwrap();
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<()> {
let conn = self.conn.lock().unwrap();
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 conn = self.conn.lock().unwrap();
let (old_position, list_id): (i64, String) = 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 {
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 {
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],
)?;
}
conn.execute(
"UPDATE tasks SET position = ?1 WHERE id = ?2",
params![new_position, task_id],
)?;
Ok(())
}
pub fn replace_all_tasks(&self, list_id: &str, tasks: &[Task]) -> SqlResult<()> {
let conn = self.conn.lock().unwrap();
conn.execute("DELETE FROM tasks WHERE list_id = ?1", params![list_id])?;
drop(conn);
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<()> {
self.push_sync_with_retry(action, task_id, list_id, payload, 0)
}
pub fn push_sync_with_retry(
&self,
action: SyncAction,
task_id: &str,
list_id: &str,
payload: &str,
retries: i32,
) -> SqlResult<()> {
if retries > MAX_SYNC_RETRIES {
return Ok(());
}
let action_str = match action {
SyncAction::Create => "Create",
SyncAction::Update => "Update",
SyncAction::Delete => "Delete",
SyncAction::Reorder => "Reorder",
SyncAction::CreateList => "CreateList",
SyncAction::DeleteList => "DeleteList",
};
let conn = self.conn.lock().unwrap();
conn.execute(
"INSERT INTO sync_queue (action, task_id, list_id, payload, created_at, retries)
VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
params![
action_str,
task_id,
list_id,
payload,
chrono::Utc::now().format("%Y-%m-%d %H:%M:%S").to_string(),
retries,
],
)?;
Ok(())
}
pub fn has_pending_sync(&self) -> bool {
let conn = self.conn.lock().unwrap();
let count: i64 = conn
.query_row("SELECT COUNT(*) FROM sync_queue", [], |row| row.get(0))
.unwrap_or(0);
count > 0
}
pub fn update_task_id(&self, old_id: &str, new_id: &str) -> SqlResult<()> {
let conn = self.conn.lock().unwrap();
conn.execute(
"UPDATE tasks SET id = ?1 WHERE id = ?2",
params![new_id, old_id],
)?;
Ok(())
}
pub fn update_sync_task_id(&self, old_id: &str, new_id: &str) -> SqlResult<()> {
let conn = self.conn.lock().unwrap();
conn.execute(
"UPDATE sync_queue SET task_id = ?1 WHERE task_id = ?2",
params![new_id, old_id],
)?;
Ok(())
}
pub fn update_list_id(&self, old_id: &str, new_id: &str) -> SqlResult<()> {
let conn = self.conn.lock().unwrap();
conn.execute(
"UPDATE task_lists SET id = ?1 WHERE id = ?2",
params![new_id, old_id],
)?;
conn.execute(
"UPDATE tasks SET list_id = ?1 WHERE list_id = ?2",
params![new_id, old_id],
)?;
Ok(())
}
#[allow(dead_code)]
pub fn update_sync_list_id(&self, old_id: &str, new_id: &str) -> SqlResult<()> {
let conn = self.conn.lock().unwrap();
conn.execute(
"UPDATE sync_queue SET list_id = ?1 WHERE list_id = ?2",
params![new_id, old_id],
)?;
Ok(())
}
pub fn drain_sync(&self) -> Vec<SyncQueueItem> {
let conn = self.conn.lock().unwrap();
let items: Vec<SyncQueueItem> = {
let mut stmt = conn
.prepare("SELECT id, action, task_id, list_id, payload, created_at, retries FROM sync_queue ORDER BY id")
.unwrap();
stmt.query_map([], |row| {
let action_str: String = row.get(1)?;
let action = match action_str.as_str() {
"\"Create\"" | "Create" => SyncAction::Create,
"\"Update\"" | "Update" => SyncAction::Update,
"\"Delete\"" | "Delete" => SyncAction::Delete,
"\"Reorder\"" | "Reorder" => SyncAction::Reorder,
"\"CreateList\"" | "CreateList" => SyncAction::CreateList,
"\"DeleteList\"" | "DeleteList" => SyncAction::DeleteList,
_ => 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)?,
retries: row.get(6)?,
})
})
.unwrap()
.filter_map(|r| r.ok())
.collect()
};
if !items.is_empty() {
conn.execute("DELETE FROM sync_queue", []).unwrap();
}
items
}
}