Skip to content

Refactor: use our own uuids as PK - #116

Closed
romaintb wants to merge 1 commit into
mainfrom
enhance/add_local_uuids_as_PKs
Closed

Refactor: use our own uuids as PK#116
romaintb wants to merge 1 commit into
mainfrom
enhance/add_local_uuids_as_PKs

Conversation

@romaintb

@romaintb romaintb commented Sep 30, 2025

Copy link
Copy Markdown
Owner

this will be needed for the multi-backend coming-soon-ish implementation

Summary by CodeRabbit

  • Refactor

    • Migrated local storage to UUID-based identifiers for projects, sections, labels, and tasks; relationships and display mappings updated to use UUIDs while preserving remote IDs for sync.
  • Chores

    • Added UUID library support and updated the local database schema and indexes to the new identifier scheme.
  • Notes

    • No user-facing UI changes; improves data consistency and sync reliability.

@coderabbitai

coderabbitai Bot commented Sep 30, 2025

Copy link
Copy Markdown
Contributor

Walkthrough

Migrate storage to UUID primary keys: add uuid dependency; replace id columns with uuid and add remote_id; update foreign keys, indexes, SQL queries, Local* structs, and sync mapping; add helper to resolve local UUIDs from remote IDs.

Changes

Cohort / File(s) Change summary
Dependency
Cargo.toml
Added dependency uuid = "1.11" with features ["v4","serde"].
DB schema & helper
src/storage/db.rs
Switched tables to uuid primary keys and added remote_id unique columns; updated foreign keys/indexes to *_uuid; migrated task-label relation to UUIDs; added find_uuid_by_remote_id helper (pub(crate) async fn find_uuid_by_remote_id(...)).
Labels storage
src/storage/labels.rs
LocalLabel.iduuid; added remote_id; generate uuid on import; updated INSERT/SELECT/UPDATE bindings and mappings to use uuid/remote_id.
Projects storage
src/storage/projects.rs
LocalProject.iduuid; added remote_id; parent_idparent_uuid; two-stage persistence to resolve parent_uuid via remote_id; SQL updated to use uuid/parent_uuid.
Sections storage
src/storage/sections.rs
LocalSection.iduuid; added remote_id; project_idproject_uuid; resolve project_uuid from projects before insert; updated queries and mappings.
Tasks storage
src/storage/tasks.rs
LocalTask.iduuid; added remote_id; project/section/parent ids → *_uuid; updated upserts/selects/joins, label junction (task_uuid/label_uuid), and multi-pass parent resolution.
Sync mapping
src/sync.rs
Converting local labels to display now uses local.uuid for id.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related PRs

Suggested labels

enhancement

Pre-merge checks and finishing touches

✅ Passed checks (3 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title Check ✅ Passed The title succinctly describes the primary change of the pull request, namely refactoring the system to use locally generated UUIDs as primary keys across multiple modules and database schemas. It is concise, clear, and focused on the main objective without extraneous details. Teammates reviewing the history will immediately understand the scope of the update from the title alone.
Docstring Coverage ✅ Passed Docstring coverage is 83.33% which is sufficient. The required threshold is 80.00%.
✨ Finishing touches
  • 📝 Generate Docstrings
🧪 Generate unit tests
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch enhance/add_local_uuids_as_PKs

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 5

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (3)
src/storage/tasks.rs (1)

267-305: Missing UUID resolution in store_single_task.

store_single_task converts Task to LocalTask without resolving foreign key UUIDs (project_uuid, section_uuid, parent_uuid), unlike store_tasks which performs explicit lookups. This will insert remote IDs into UUID columns, causing foreign key violations or data integrity issues.

Apply this diff to add UUID resolution:

 pub async fn store_single_task(&self, task: Task) -> Result<()> {
     let label_names = task.labels.clone();
-    let local_task: LocalTask = task.into();
+    let mut local_task: LocalTask = task.clone().into();
 
     // Use transaction for atomic operation
     let mut tx = self.pool.begin().await?;
+
+    // Look up local project UUID from remote project_id
+    if let Some(local_project_uuid) = self.find_uuid_by_remote_id(&mut tx, "projects", &task.project_id).await? {
+        local_task.project_uuid = local_project_uuid;
+    }
+
+    // Look up local section UUID from remote section_id if present
+    if let Some(remote_section_id) = &task.section_id {
+        if let Some(local_section_uuid) = self.find_uuid_by_remote_id(&mut tx, "sections", remote_section_id).await? {
+            local_task.section_uuid = Some(local_section_uuid);
+        }
+    }
+
+    // Look up local parent UUID from remote parent_id if present
+    if let Some(remote_parent_id) = &task.parent_id {
+        if let Some(local_parent_uuid) = self.find_uuid_by_remote_id(&mut tx, "tasks", remote_parent_id).await? {
+            local_task.parent_uuid = Some(local_parent_uuid);
+        }
+    }
 
     sqlx::query(
src/storage/labels.rs (1)

99-108: Use remote_id for local storage updates
The sync service passes the Todoist label’s remote ID into update_label_name, but the SQL query filters on the local uuid column. Either change the query in src/storage/labels.rs:101 to UPDATE labels SET name = ? WHERE remote_id = ? or resolve the remote ID to the local uuid before calling.

src/storage/projects.rs (1)

96-118: Correct parent UUID resolution and use proper upsert by remote_id

  • Don’t bind project.parent_id directly—lookup the local parent UUID via find_uuid_by_remote_id (as in store_projects) before insertion so parent_uuid holds a local UUID, not the remote ID.
  • Don’t rely on INSERT OR REPLACE with a freshly generated uuid (it never conflicts). Use
    INSERT INTO projects (…) 
    VALUES (…) 
    ON CONFLICT(remote_id) DO UPDATE SET 
      name=excluded.name, color=excluded.color, …, parent_uuid=excluded.parent_uuid;
    or lookup the existing uuid by remote_id and reuse it to ensure existing rows are actually updated.
🧹 Nitpick comments (4)
src/storage/db.rs (1)

201-211: Table name interpolation is safe — optional enum refactor
Verified that find_uuid_by_remote_id is only invoked with string literals ("projects", "sections", "tasks"), so there’s no SQL injection risk today. For stricter future guarantees, you may refactor table: &str into a TableName enum.

src/storage/tasks.rs (1)

198-255: Two-pass insertion approach is sound but complex.

The two-pass approach correctly handles parent-child relationships by first inserting all tasks, then updating parent_uuid references. This avoids foreign key constraint violations when parent tasks appear after child tasks in the input vector.

However, the complexity could be reduced if the tasks were topologically sorted by parent relationships before insertion.

Consider sorting tasks before insertion to enable single-pass insertion:

// Helper function to add
fn topological_sort_tasks(tasks: &[Task]) -> Vec<&Task> {
    let mut sorted = Vec::new();
    let mut visited = std::collections::HashSet::new();
    
    fn visit<'a>(
        task: &'a Task,
        tasks: &'a [Task],
        visited: &mut std::collections::HashSet<String>,
        sorted: &mut Vec<&'a Task>,
    ) {
        if visited.contains(&task.id) {
            return;
        }
        visited.insert(task.id.clone());
        
        // Visit parent first if exists
        if let Some(parent_id) = &task.parent_id {
            if let Some(parent) = tasks.iter().find(|t| &t.id == parent_id) {
                visit(parent, tasks, visited, sorted);
            }
        }
        
        sorted.push(task);
    }
    
    for task in tasks {
        visit(task, tasks, &mut visited, &mut sorted);
    }
    
    sorted
}

Then use single-pass insertion with resolved parent_uuid.

src/storage/projects.rs (2)

10-11: Consider using uuid::Uuid type instead of String.

The fields uuid, remote_id, and parent_uuid are all typed as String. While this works with SQLite and simplifies serialization, using the uuid::Uuid type for uuid and parent_uuid would provide:

  • Compile-time validation that these fields contain valid UUIDs
  • Prevention of accidental assignment of non-UUID strings
  • More explicit domain modeling

You can still serialize to/from strings with serde derives.

Example refactor:

 pub struct LocalProject {
-    pub uuid: String,
+    pub uuid: uuid::Uuid,
     pub remote_id: String,
     pub name: String,
     pub color: String,
     pub is_favorite: bool,
     pub is_inbox_project: bool,
     pub order_index: i32,
-    pub parent_uuid: Option<String>,
+    pub parent_uuid: Option<uuid::Uuid>,
 }

Note: This would require updating conversions to use .to_string() where needed and corresponding changes to SQL binding/extraction logic.

Also applies to: 17-17


77-90: Add debug logs for missing parent mappings and batch resolution

  • The second pass silently skips missing parent_id → parent_uuid mappings for projects (and similarly for tasks/sections) without any log; add a debug-level warning when find_uuid_by_remote_id returns None to surface orphaned references.
  • Each record issues its own UPDATE (O(n) queries); for large imports, preload all remote→local UUID mappings into a map and apply updates in bulk or with a single multi-row query to reduce database round trips.
📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 4583f64 and c2279b3.

⛔ Files ignored due to path filters (1)
  • Cargo.lock is excluded by !**/*.lock
📒 Files selected for processing (7)
  • Cargo.toml (1 hunks)
  • src/storage/db.rs (7 hunks)
  • src/storage/labels.rs (6 hunks)
  • src/storage/projects.rs (7 hunks)
  • src/storage/sections.rs (5 hunks)
  • src/storage/tasks.rs (19 hunks)
  • src/sync.rs (1 hunks)
🧰 Additional context used
🧬 Code graph analysis (5)
src/storage/db.rs (4)
src/storage/projects.rs (1)
  • sqlx (151-151)
src/storage/sections.rs (2)
  • sqlx (79-79)
  • sqlx (93-93)
src/storage/tasks.rs (3)
  • sqlx (60-60)
  • sqlx (111-111)
  • sqlx (159-159)
src/storage/labels.rs (1)
  • sqlx (90-90)
src/storage/labels.rs (1)
src/storage/tasks.rs (3)
  • sqlx (60-60)
  • sqlx (111-111)
  • sqlx (159-159)
src/storage/tasks.rs (5)
src/storage/projects.rs (1)
  • sqlx (151-151)
src/storage/sections.rs (2)
  • sqlx (79-79)
  • sqlx (93-93)
src/storage/labels.rs (1)
  • sqlx (90-90)
src/storage/db.rs (1)
  • new (16-48)
src/sync.rs (2)
  • new (94-105)
  • get_tasks_for_project (143-146)
src/storage/sections.rs (2)
src/storage/projects.rs (3)
  • from (21-30)
  • from (34-45)
  • sqlx (151-151)
src/storage/tasks.rs (4)
  • from (28-54)
  • sqlx (60-60)
  • sqlx (111-111)
  • sqlx (159-159)
src/storage/projects.rs (3)
src/storage/sections.rs (4)
  • from (17-24)
  • from (28-36)
  • sqlx (79-79)
  • sqlx (93-93)
src/storage/tasks.rs (4)
  • from (28-54)
  • sqlx (60-60)
  • sqlx (111-111)
  • sqlx (159-159)
src/storage/labels.rs (2)
  • from (23-32)
  • sqlx (90-90)
🔇 Additional comments (20)
Cargo.toml (1)

43-43: LGTM! UUID dependency properly configured.

The uuid crate version 1.11 with v4 (random UUID generation) and serde features aligns well with the PR's goal of introducing UUID-based primary keys.

src/storage/db.rs (6)

71-79: LGTM! Projects table schema correctly migrated to UUIDs.

The schema properly introduces uuid as the primary key, adds remote_id with a UNIQUE constraint, and updates the parent reference to parent_uuid with the correct foreign key constraint.


88-93: LGTM! Sections table schema correctly migrated.

The uuid/remote_id pattern is consistent, and the foreign key to projects(uuid) is properly configured.


102-121: LGTM! Tasks table schema correctly handles UUID-based relationships.

The migration properly introduces uuid/remote_id columns and updates all foreign key relationships (parent_uuid, project_uuid, section_uuid) with appropriate CASCADE and SET NULL behaviors.


130-136: LGTM! Labels table schema correctly migrated.

The uuid/remote_id pattern is consistently applied.


145-150: LGTM! Junction table correctly migrated to UUID-based keys.

The composite primary key and foreign key references are properly updated to use task_uuid and label_uuid columns.


156-174: LGTM! Indexes correctly updated to target UUID columns.

All index definitions properly reference the new *_uuid columns, ensuring efficient foreign key lookups.

src/sync.rs (1)

189-190: LGTM! Label display ID correctly updated to use UUID.

The conversion from LocalLabel to LabelDisplay now correctly uses local.uuid as the identifier, aligning with the UUID-based primary key migration.

src/storage/sections.rs (3)

47-71: LGTM: UUID resolution properly implemented in store_sections.

The UUID resolution logic correctly looks up the local project UUID from the remote project_id before insertion, maintaining referential integrity with the new UUID-based schema.


16-24: SectionDisplay.id mapping verified
Conversion from LocalSection exposing local.uuid and from API Section exposing section.id is intentional and consistent with all existing usages.


92-104: Verify callers pass local project UUID: The SQL query filters on project_uuid (the local UUID). Audit all consumers of SyncService::get_sections_for_project (e.g., UI/CLI modules) to ensure they supply the local project UUID—not the remote Todoist project ID—and consider updating the method’s docstring to clarify this.

src/storage/labels.rs (4)

22-33: LGTM: UUID generation is correct.

The From<Label> implementation correctly generates a new local UUID and stores the original label ID in remote_id. Since labels have no foreign key dependencies, no additional resolution is needed.


35-56: LGTM: UUID-based insertion is correct.

The store_single_label function correctly binds uuid and remote_id in the INSERT statement, maintaining the new schema.


58-86: LGTM: Batch insertion correctly uses UUIDs.

The store_labels function correctly converts each label and inserts with proper UUID/remote_id binding.


88-97: LGTM: Query correctly selects UUID columns.

The get_all_labels query correctly selects the new uuid and remote_id columns.

src/storage/tasks.rs (2)

308-311: Ensure project_id is a local UUID at each call site

  • In src/sync.rs (around line 145): storage.get_tasks_for_project(project_id).await
  • In src/ui/core/task_manager.rs (around line 210): sync_service.get_tasks_for_project(&project.id)…
    Confirm that the values passed (project_id and project.id) are local database UUIDs, not remote IDs.

59-81: All call sites use local task.uuid — No changes needed.

src/storage/projects.rs (3)

20-31: LGTM!

The conversion from LocalProject to ProjectDisplay correctly maps the UUID-based local fields to the display model.


120-137: LGTM!

The deletion logic correctly:

  • Uses transactions for atomicity
  • Deletes related tasks first to respect foreign key constraints
  • Uses UUID-based identifiers consistently

139-161: LGTM!

Both methods correctly use UUID-based identifiers:

  • update_project_name updates by uuid
  • get_projects selects all necessary fields and properly converts to ProjectDisplay

Comment thread src/storage/projects.rs Outdated
Comment thread src/storage/projects.rs Outdated
Comment thread src/storage/sections.rs
Comment thread src/storage/tasks.rs Outdated

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (6)
src/storage/projects.rs (2)

96-118: INSERT OR REPLACE generates new UUID, breaking foreign key relationships.

The store_single_project method uses INSERT OR REPLACE, but the conversion (line 98) always generates a new UUID via From<Project> (line 36). This means:

  • Existing projects are replaced with a new UUID
  • Foreign key references from tasks (project_uuid) become invalid
  • Parent-child relationships within projects break

Consider preserving the existing UUID by looking it up before insertion:

pub async fn store_single_project(&self, project: Project) -> Result<()> {
    let mut local_project: LocalProject = project.into();
    
    // Preserve existing UUID if project already exists
    if let Some(existing_uuid) = self.find_uuid_by_remote_id(
        &mut *self.pool.begin().await?, 
        "projects", 
        &local_project.remote_id
    ).await? {
        local_project.uuid = existing_uuid;
    }
    
    sqlx::query(/* ... */)
        // ...
}

Alternatively, use INSERT ... ON CONFLICT(remote_id) DO UPDATE with a unique constraint on remote_id.


97-118: Resolve parent_uuid in store_single_project
The store_single_project conversion uses From<Project> which sets parent_uuid = None, so any existing project.parent_id is dropped. You need to resolve the parent UUID before inserting, mirroring the second pass in store_projects:

 pub async fn store_single_project(&self, project: Project) -> Result<()> {
-    let local_project: LocalProject = project.into();
+    let mut local_project: LocalProject = project.clone().into();
+
+    // resolve parent_uuid if this project has a parent
+    if let Some(remote_parent_id) = &project.parent_id {
+        if let Some(local_parent_uuid) =
+            self.find_uuid_by_remote_id(&mut *self.pool.begin().await?, "projects", remote_parent_id).await?
+        {
+            local_project.parent_uuid = Some(local_parent_uuid);
+        }
+    }

Then bind local_project.parent_uuid as before. This preserves parent relationships when storing a single project.

src/storage/tasks.rs (3)

266-324: INSERT OR REPLACE generates new UUID, breaking foreign key relationships.

Similar to store_single_project in projects.rs, this method uses INSERT OR REPLACE but always generates a new UUID via From<Task> (line 37). This breaks:

  • Subtask relationships (tasks with parent_uuid referencing this task)
  • Task-label relationships in the task_labels junction table

The clear_task_labels call (line 321) mitigates the junction table issue, but subtask relationships will break.

Consider preserving the existing UUID by looking it up before insertion:

pub async fn store_single_task(&self, task: Task) -> Result<()> {
    let label_names = task.labels.clone();
    let mut local_task: LocalTask = task.clone().into();
    
    let mut tx = self.pool.begin().await?;
    
    // Preserve existing UUID if task already exists
    if let Some(existing_uuid) = self.find_uuid_by_remote_id(
        &mut tx, 
        "tasks", 
        &local_task.remote_id
    ).await? {
        local_task.uuid = existing_uuid;
    }
    
    // ... rest of resolution logic ...
}

399-457: Standardize Task ID usage between display and storage methods
The CRUD methods in src/storage/tasks.rs (lines 399–457) all filter on WHERE uuid = ?, but TaskDisplay.id is currently populated from task.remote_id (src/storage/tasks.rs:121–123), so passing TaskDisplay.id will never match.
Choose one:

  • Update these queries to WHERE remote_id = ?
  • Or change TaskDisplay.id to use task.uuid instead of task.remote_id

188-264: Two issues: destructive DELETE and incomplete FK resolution.

Issue 1: Destructive DELETE risks data loss on sync failure.

The DELETE FROM tasks statement (line 193) clears all existing tasks before inserting new data. If the subsequent insertion fails, local tasks are permanently lost even though the transaction will roll back the deletion.

Issue 2: Incomplete FK resolution in first pass.

Lines 195-241 insert tasks with parent_uuid set to NULL (line 230), deferring parent resolution to the second pass (lines 243-255). However, project_uuid and section_uuid are resolved immediately (lines 202-215). This is inconsistent—if a task's project or section hasn't been inserted yet, the FK resolution will fail silently, leaving empty/NULL values.

Consider using an upsert strategy to preserve existing UUIDs and ensure all FK resolution happens before insertion:

         // Clear existing tasks and their label relationships
         sqlx::query("DELETE FROM task_labels").execute(&mut *tx).await?;
-        sqlx::query("DELETE FROM tasks").execute(&mut *tx).await?;
 
-        // First pass: Insert all tasks without parent_id relationships
+        // Insert all tasks with FK resolution
         let mut task_labels: Vec<(String, Vec<String>)> = Vec::new();
 
         for task in &tasks {
             let label_names = task.labels.clone();
             let mut local_task: LocalTask = task.clone().into();
 
             // Look up local project UUID from remote project_id
             if let Some(local_project_uuid) = self.find_uuid_by_remote_id(&mut tx, "projects", &task.project_id).await?
             {
                 local_task.project_uuid = local_project_uuid;
             }
 
             // Look up local section UUID from remote section_id if present
             if let Some(remote_section_id) = &task.section_id {
                 if let Some(local_section_uuid) =
                     self.find_uuid_by_remote_id(&mut tx, "sections", remote_section_id).await?
                 {
                     local_task.section_uuid = Some(local_section_uuid);
                 }
             }
+
+            // Look up local parent UUID from remote parent_id if present
+            if let Some(remote_parent_id) = &task.parent_id {
+                if let Some(local_parent_uuid) =
+                    self.find_uuid_by_remote_id(&mut tx, "tasks", remote_parent_id).await?
+                {
+                    local_task.parent_uuid = Some(local_parent_uuid);
+                }
+            }
 
             task_labels.push((local_task.uuid.clone(), label_names));
 
             sqlx::query(
                 r"
-                INSERT INTO tasks (uuid, remote_id, content, project_uuid, section_uuid, parent_uuid, priority, order_index, due_date, due_datetime, is_recurring, deadline, duration, description)
-                VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
+                INSERT INTO tasks (uuid, remote_id, content, project_uuid, section_uuid, parent_uuid, priority, order_index, due_date, due_datetime, is_recurring, deadline, duration, description)
+                VALUES (
+                    COALESCE((SELECT uuid FROM tasks WHERE remote_id = ?), ?),
+                    ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?
+                )
+                ON CONFLICT(uuid) DO UPDATE SET
+                    content = excluded.content,
+                    project_uuid = excluded.project_uuid,
+                    section_uuid = excluded.section_uuid,
+                    parent_uuid = excluded.parent_uuid,
+                    priority = excluded.priority,
+                    order_index = excluded.order_index,
+                    due_date = excluded.due_date,
+                    due_datetime = excluded.due_datetime,
+                    is_recurring = excluded.is_recurring,
+                    deadline = excluded.deadline,
+                    duration = excluded.duration,
+                    description = excluded.description
                 ",
             )
+            .bind(&local_task.remote_id)
             .bind(&local_task.uuid)
             .bind(&local_task.remote_id)
             .bind(&local_task.content)
             .bind(&local_task.project_uuid)
             .bind(&local_task.section_uuid)
-            .bind(None::<String>) // parent_uuid set to NULL for now
+            .bind(&local_task.parent_uuid)
             .bind(local_task.priority)
             .bind(local_task.order_index)
             .bind(&local_task.due_date)
             .bind(&local_task.due_datetime)
             .bind(local_task.is_recurring)
             .bind(&local_task.deadline)
             .bind(&local_task.duration)
             .bind(&local_task.description)
             .execute(&mut *tx)
             .await?;
         }
-
-        // Second pass: Update parent_uuid references to use local UUIDs
-        for task in &tasks {
-            if let Some(remote_parent_id) = &task.parent_id {
-                if let Some(local_parent_uuid) = self.find_uuid_by_remote_id(&mut tx, "tasks", remote_parent_id).await?
-                {
-                    sqlx::query("UPDATE tasks SET parent_uuid = ? WHERE remote_id = ?")
-                        .bind(&local_parent_uuid)
-                        .bind(&task.id)
-                        .execute(&mut *tx)
-                        .await?;
-                }
-            }
-        }

Note: Parent resolution in a single pass requires tasks to be inserted in dependency order (parents before children), or tasks are inserted first without parents and then a second UPDATE pass is performed. The current two-pass approach is safe if all tasks are inserted before the second pass.

src/storage/sections.rs (1)

41-75: Destructive DELETE risks data loss on sync failure.

The DELETE FROM sections statement clears all existing sections before inserting new data. If the subsequent insertion fails (network error, constraint violation, etc.), local sections are permanently lost even though the transaction will roll back the deletion.

Consider using an upsert strategy to preserve existing UUIDs:

-        // Clear existing sections
-        sqlx::query("DELETE FROM sections").execute(&mut *tx).await?;
-
         // Insert new sections with mapped project_id
         for section in &sections {
             let mut local_section: LocalSection = section.clone().into();
 
             // Look up local project UUID from remote project_id
             if let Some(local_project_uuid) =
                 self.find_uuid_by_remote_id(&mut tx, "projects", &section.project_id).await?
             {
                 local_section.project_uuid = local_project_uuid;
             }
 
             sqlx::query(
                 r"
-                INSERT INTO sections (uuid, remote_id, name, project_uuid, order_index)
-                VALUES (?, ?, ?, ?, ?)
+                INSERT INTO sections (uuid, remote_id, name, project_uuid, order_index)
+                VALUES (
+                    COALESCE((SELECT uuid FROM sections WHERE remote_id = ?), ?),
+                    ?, ?, ?, ?
+                )
+                ON CONFLICT(uuid) DO UPDATE SET
+                    name = excluded.name,
+                    project_uuid = excluded.project_uuid,
+                    order_index = excluded.order_index
                 ",
             )
+            .bind(&local_section.remote_id)
             .bind(&local_section.uuid)
             .bind(&local_section.remote_id)
             .bind(&local_section.name)
             .bind(&local_section.project_uuid)
             .bind(local_section.order_index)
             .execute(&mut *tx)
             .await?;
         }

This preserves existing UUIDs by matching on remote_id and ensures the old data remains intact if the sync operation fails.

♻️ Duplicate comments (3)
src/storage/projects.rs (2)

54-54: DELETE risks data loss on sync failure.

The DELETE FROM projects statement clears all existing projects before inserting new data. If the subsequent insertion fails, local projects are lost even though the transaction will roll back.

Consider implementing an upsert strategy (e.g., INSERT ... ON CONFLICT) that preserves existing UUIDs by matching on remote_id, or use a temporary table swap pattern.


50-94: Destructive DELETE risks data loss on sync failure.

The DELETE FROM projects statement clears all existing projects before inserting new data. If the subsequent insertion fails, local projects are permanently lost even though the transaction will roll back the deletion.

Consider using an upsert strategy to preserve existing UUIDs and handle the two-pass parent resolution:

-        // Clear existing projects
-        sqlx::query("DELETE FROM projects").execute(&mut *tx).await?;
-
         // First pass: Insert all projects without parent_id relationships
         for project in &projects {
             let local_project: LocalProject = project.clone().into();
 
             sqlx::query(
                 r"
-                INSERT INTO projects (uuid, remote_id, name, color, is_favorite, is_inbox_project, order_index, parent_uuid)
-                VALUES (?, ?, ?, ?, ?, ?, ?, NULL)
+                INSERT INTO projects (uuid, remote_id, name, color, is_favorite, is_inbox_project, order_index, parent_uuid)
+                VALUES (
+                    COALESCE((SELECT uuid FROM projects WHERE remote_id = ?), ?),
+                    ?, ?, ?, ?, ?, ?, NULL
+                )
+                ON CONFLICT(uuid) DO UPDATE SET
+                    name = excluded.name,
+                    color = excluded.color,
+                    is_favorite = excluded.is_favorite,
+                    is_inbox_project = excluded.is_inbox_project,
+                    order_index = excluded.order_index
                 ",
             )
+            .bind(&local_project.remote_id)
             .bind(&local_project.uuid)
             .bind(&local_project.remote_id)
             .bind(&local_project.name)
             .bind(&local_project.color)
             .bind(local_project.is_favorite)
             .bind(local_project.is_inbox_project)
             .bind(local_project.order_index)
             .execute(&mut *tx)
             .await?;
         }

This preserves existing UUIDs by matching on remote_id and ensures old data remains intact if sync fails.

src/storage/tasks.rs (1)

192-193: DELETE risks data loss on sync failure.

The DELETE FROM task_labels and DELETE FROM tasks statements clear all existing data before inserting new data. If the subsequent insertion fails, local tasks are lost even though the transaction will roll back.

Consider implementing an upsert strategy that preserves existing UUIDs by matching on remote_id.

📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between c2279b3 and 884a934.

📒 Files selected for processing (3)
  • src/storage/projects.rs (7 hunks)
  • src/storage/sections.rs (5 hunks)
  • src/storage/tasks.rs (19 hunks)
🧰 Additional context used
🧬 Code graph analysis (3)
src/storage/projects.rs (3)
src/storage/sections.rs (4)
  • from (17-24)
  • from (28-36)
  • sqlx (79-79)
  • sqlx (93-93)
src/storage/tasks.rs (5)
  • from (28-54)
  • None (230-230)
  • sqlx (60-60)
  • sqlx (111-111)
  • sqlx (159-159)
src/storage/labels.rs (2)
  • from (23-32)
  • sqlx (90-90)
src/storage/tasks.rs (2)
src/storage/db.rs (1)
  • new (16-48)
src/sync.rs (2)
  • new (94-105)
  • get_tasks_for_project (143-146)
src/storage/sections.rs (2)
src/storage/projects.rs (3)
  • from (21-30)
  • from (34-45)
  • sqlx (151-151)
src/storage/tasks.rs (4)
  • from (28-54)
  • sqlx (60-60)
  • sqlx (111-111)
  • sqlx (159-159)
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (5)
  • GitHub Check: Test (windows-latest, stable)
  • GitHub Check: Security Audit
  • GitHub Check: Test (macos-latest, beta)
  • GitHub Check: Test (macos-latest, stable)
  • GitHub Check: Test (windows-latest, beta)
🔇 Additional comments (37)
src/storage/sections.rs (10)

8-14: LGTM! Struct correctly migrated to UUID-based fields.

The LocalSection struct now uses uuid, remote_id, and project_uuid fields, consistent with the UUID-based storage pattern across the codebase.


16-25: LGTM! Display conversion correctly maps UUIDs.

The conversion maps uuid → id and project_uuid → project_id, exposing local UUIDs to the display layer, consistent with the pattern in projects.rs.


27-37: LGTM! Conversion correctly defers UUID resolution.

The From<Section> implementation now correctly sets project_uuid to String::new() with a comment indicating resolution happens at the storage layer. This addresses the previous review concern and aligns with the pattern in projects.rs and tasks.rs.


48-71: LGTM! UUID resolution correctly implemented.

The storage logic properly resolves the local project_uuid from the remote project_id before insertion. This two-stage pattern (convert, then resolve) is consistent with the approach in projects.rs and tasks.rs.


78-104: LGTM! Query methods correctly use UUID-based columns.

The get_sections and get_sections_for_project methods have been properly updated to select and filter on the new UUID-based columns (uuid, remote_id, project_uuid).


8-14: LGTM! Clean UUID-based structure.

The LocalSection struct correctly separates local UUID (uuid) from remote ID (remote_id) and uses project_uuid for the foreign key reference.


16-25: LGTM! Correct UUID-to-display mapping.

The conversion properly maps local uuid to the display id and project_uuid to project_id, maintaining consistency with the storage layer's UUID-based approach.


27-37: LGTM! Correct pattern for deferred FK resolution.

Setting project_uuid: String::new() is correct—the storage layer resolves it (lines 52-56) before insertion, consistent with the pattern in tasks.rs and projects.rs.


78-89: LGTM! Query updated for UUID schema.

The SELECT correctly retrieves uuid, remote_id, name, project_uuid, order_index fields and orders by project_uuid.


92-104: LGTM! Correct project filtering by UUID.

The query properly filters by project_uuid = ? and the caller passes project_id (which is now the local UUID after the migration).

src/storage/projects.rs (12)

9-18: LGTM! Struct correctly migrated to UUID-based fields.

The LocalProject struct now uses uuid, remote_id, and parent_uuid fields, consistent with the UUID-based storage pattern.


20-31: LGTM! Display conversion correctly maps UUIDs.

The conversion correctly maps uuid → id and parent_uuid → parent_id for the display layer.


33-46: LGTM! Conversion correctly defers parent UUID resolution.

The From<Project> implementation now correctly sets parent_uuid to None, deferring resolution to the storage layer. This addresses the previous review concern.


56-90: LGTM! Two-pass insert/update correctly resolves parent UUIDs.

The two-pass approach correctly:

  1. Inserts all projects with NULL parent_uuid (lines 56-75)
  2. Updates parent_uuid by resolving remote parent_id to local UUID (lines 77-90)

This ensures parent-child relationships use local UUIDs consistently.


120-161: LGTM! CRUD methods correctly use UUID-based columns.

The delete_project, update_project_name, and get_projects methods have been properly updated to reference the new UUID-based columns (uuid, remote_id, parent_uuid).


9-18: LGTM! Clean UUID-based project structure.

The LocalProject struct correctly separates local UUID (uuid) from remote ID (remote_id) and uses parent_uuid for the self-referential foreign key.


20-31: LGTM! Correct UUID-to-display mapping.

The conversion properly maps local uuid to the display id and parent_uuid to parent_id.


33-46: LGTM! Correct pattern for deferred parent resolution.

Setting parent_uuid: None is correct—the storage layer resolves it (lines 77-90) in a second pass after all projects are inserted, preventing FK constraint violations.


77-90: LGTM! Correct two-pass parent UUID resolution.

The second pass correctly resolves remote_parent_id to local_parent_uuid using find_uuid_by_remote_id, ensuring parent references use local UUIDs rather than remote IDs.


121-137: LGTM! Correct cascade deletion using UUID.

The method correctly deletes tasks by project_uuid first, then deletes the project by uuid. This maintains referential integrity.


140-147: LGTM! Correct update using UUID.

The update statement correctly targets projects by uuid.


150-161: LGTM! Query updated for UUID schema.

The SELECT correctly retrieves uuid, remote_id, name, color, is_favorite, parent_uuid, is_inbox_project, order_index fields.

src/storage/tasks.rs (15)

8-25: LGTM! Struct correctly migrated to UUID-based fields.

The LocalTask struct now uses uuid, remote_id, project_uuid, section_uuid, and parent_uuid fields, consistent with the UUID-based storage pattern.


27-55: LGTM! Conversion correctly defers UUID resolution.

The From<Task> implementation correctly sets project_uuid, section_uuid, and parent_uuid to empty/None values, deferring resolution to the storage layer. This addresses the previous review concern about direct assignment of remote IDs.


59-81: LGTM! Label retrieval correctly uses UUID-based columns.

The query correctly joins on l.uuid = tl.label_uuid and filters by task_uuid. The LabelDisplay construction maps uuid → id, consistent with the pattern in other display conversions.


143-185: LGTM! Label relationship methods correctly use UUID-based columns.

The store_task_labels and clear_task_labels methods correctly reference uuid, task_uuid, and label_uuid columns in the junction table.


195-255: LGTM! Two-pass insert correctly resolves all UUID relationships.

The storage logic correctly:

  1. Converts tasks and resolves project_uuid and section_uuid (lines 198-215)
  2. Inserts with NULL parent_uuid (lines 219-241)
  3. Updates parent_uuid by resolving remote parent_id to local UUID (lines 243-255)

This ensures all foreign key relationships use local UUIDs consistently.


326-391: LGTM! Query methods correctly use UUID-based columns.

All task retrieval methods (get_tasks_for_project, get_all_tasks, search_tasks, date-based queries) correctly reference the project_uuid and other UUID-based columns.


120-127: Ignore identifier‐mapping concern. TaskDisplay.id intentionally uses the remote API ID needed for sync operations, while project_id/section_id/parent_id are local-DB UUIDs used by the UI for grouping. This matches storage and UI code and requires no change.

Likely an incorrect or invalid review comment.


8-25: LGTM! Clean UUID-based task structure.

The LocalTask struct correctly separates local UUID (uuid) from remote ID (remote_id) and uses *_uuid fields for all foreign key references (project, section, parent).


27-55: LGTM! Correct pattern for deferred FK resolution.

Setting project_uuid: String::new(), section_uuid: None, and parent_uuid: None is correct—the storage layer resolves these before insertion (lines 202-215, 274-291), consistent with the pattern in other storage modules.


59-81: LGTM! Correct label retrieval using UUIDs.

The query correctly joins labels.uuid with task_labels.label_uuid and filters by task_labels.task_uuid. The mapping to LabelDisplay.id uses label.uuid, maintaining consistency with the UUID-based storage.


83-141: LGTM! Correct task retrieval and TaskDisplay mapping.

The query correctly selects UUID-based fields (uuid, remote_id, project_uuid, section_uuid, parent_uuid). Line 122 correctly maps task.remote_id to TaskDisplay.id, preserving the API contract where the display ID is the remote identifier, not the local UUID.


143-176: LGTM! Correct label relationship storage using UUIDs.

The method correctly queries labels by uuid, then inserts task_uuid and label_uuid into the junction table. The use of INSERT OR IGNORE prevents duplicate relationships.


179-185: LGTM! Correct label relationship cleanup.

The method correctly deletes by task_uuid.


267-324: LGTM! Correct single-task storage with full FK resolution.

The method correctly resolves all foreign keys (project_uuid, section_uuid, parent_uuid) before insertion using find_uuid_by_remote_id. This ensures no dangling references.


327-457: LGTM! All query methods correctly use UUID fields.

All methods correctly reference uuid for task identification and project_uuid/section_uuid/parent_uuid for filtering and relationships.

Comment thread src/storage/sections.rs Outdated
Comment thread src/storage/tasks.rs
this will be needed for the multi-backend coming-soon-ish implementation
@romaintb
romaintb force-pushed the enhance/add_local_uuids_as_PKs branch from 884a934 to b9c39b2 Compare September 30, 2025 19:31
@romaintb romaintb closed this Sep 30, 2025

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 6

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
src/storage/labels.rs (1)

41-53: Avoid INSERT OR REPLACE — it can cascade-delete relations and lose UUIDs.

REPLACE deletes the existing row (triggering cascades on task_labels) and inserts a fresh row with a new UUID. Use an upsert that preserves UUIDs, mirroring store_labels.

Apply this diff:

-        sqlx::query(
-            r"
-            INSERT OR REPLACE INTO labels (uuid, remote_id, name, color, order_index, is_favorite)
-            VALUES (?, ?, ?, ?, ?, ?)
-            ",
-        )
+        sqlx::query(
+            r#"
+            INSERT INTO labels (uuid, remote_id, name, color, order_index, is_favorite)
+            VALUES (?, ?, ?, ?, ?, ?)
+            ON CONFLICT(remote_id) DO UPDATE SET
+                name = excluded.name,
+                color = excluded.color,
+                order_index = excluded.order_index,
+                is_favorite = excluded.is_favorite
+            "#,
+        )
src/storage/tasks.rs (1)

415-471: All mutating ops should key by remote_id (to match Display.id).

Update, soft-delete, restore, hard-delete, and field updates currently filter by uuid. Switch to remote_id to accept TaskDisplay.id consistently.

-        sqlx::query("UPDATE tasks SET is_completed = 1 WHERE uuid = ?")
+        sqlx::query("UPDATE tasks SET is_completed = 1 WHERE remote_id = ?")

-        sqlx::query("UPDATE tasks SET is_deleted = 1 WHERE uuid = ?")
+        sqlx::query("UPDATE tasks SET is_deleted = 1 WHERE remote_id = ?")

-        sqlx::query("UPDATE tasks SET is_deleted = 0, is_completed = 0 WHERE uuid = ?")
+        sqlx::query("UPDATE tasks SET is_deleted = 0, is_completed = 0 WHERE remote_id = ?")

-        sqlx::query("DELETE FROM tasks WHERE uuid = ?")
+        sqlx::query("DELETE FROM tasks WHERE remote_id = ?")

-        sqlx::query("UPDATE tasks SET due_date = ? WHERE uuid = ?")
+        sqlx::query("UPDATE tasks SET due_date = ? WHERE remote_id = ?")

-        sqlx::query("UPDATE tasks SET priority = ? WHERE uuid = ?")
+        sqlx::query("UPDATE tasks SET priority = ? WHERE remote_id = ?")
♻️ Duplicate comments (2)
src/storage/projects.rs (1)

101-123: Make single insert transactional, upsert without REPLACE, and resolve parent_uuid.

INSERT OR REPLACE can delete the row and cascade-delete tasks. Also, parent_uuid stays None.

Apply this transactional upsert mirroring the batch path:

-    pub async fn store_single_project(&self, project: Project) -> Result<()> {
-        let local_project: LocalProject = project.into();
-
-        sqlx::query(
-            r"
-            INSERT OR REPLACE INTO projects (uuid, remote_id, name, color, is_favorite, is_inbox_project, order_index, parent_uuid)
-            VALUES (?, ?, ?, ?, ?, ?, ?, ?)
-            ",
-        )
-        .bind(&local_project.uuid)
-        .bind(&local_project.remote_id)
-        .bind(&local_project.name)
-        .bind(&local_project.color)
-        .bind(local_project.is_favorite)
-        .bind(local_project.is_inbox_project)
-        .bind(local_project.order_index)
-        .bind(&local_project.parent_uuid)
-        .execute(&self.pool)
-        .await?;
-
-        Ok(())
-    }
+    pub async fn store_single_project(&self, project: Project) -> Result<()> {
+        let mut tx = self.pool.begin().await?;
+        let remote_parent_id = project.parent_id.clone();
+        let local_project: LocalProject = project.into();
+
+        // Upsert without touching UUID; defer parent linkage to second pass.
+        sqlx::query(
+            r#"
+            INSERT INTO projects (uuid, remote_id, name, color, is_favorite, is_inbox_project, order_index, parent_uuid)
+            VALUES (?, ?, ?, ?, ?, ?, ?, NULL)
+            ON CONFLICT(remote_id) DO UPDATE SET
+                name = excluded.name,
+                color = excluded.color,
+                is_favorite = excluded.is_favorite,
+                is_inbox_project = excluded.is_inbox_project,
+                order_index = excluded.order_index
+            "#,
+        )
+        .bind(&local_project.uuid)
+        .bind(&local_project.remote_id)
+        .bind(&local_project.name)
+        .bind(&local_project.color)
+        .bind(local_project.is_favorite)
+        .bind(local_project.is_inbox_project)
+        .bind(local_project.order_index)
+        .execute(&mut *tx)
+        .await?;
+
+        // Second pass: resolve and set parent_uuid if present.
+        if let Some(remote_parent_id) = remote_parent_id {
+            if let Some(local_parent_uuid) =
+                self.find_uuid_by_remote_id(&mut tx, "projects", &remote_parent_id).await?
+            {
+                sqlx::query("UPDATE projects SET parent_uuid = ? WHERE remote_id = ?")
+                    .bind(&local_parent_uuid)
+                    .bind(&local_project.remote_id)
+                    .execute(&mut *tx)
+                    .await?;
+            }
+        }
+
+        tx.commit().await?;
+        Ok(())
+    }
src/storage/tasks.rs (1)

409-411: get_task_by_id must match Display.id (use remote_id).

Bind against remote_id so callers can pass TaskDisplay.id directly.

-        let tasks = self.get_tasks_with_labels_joined("WHERE uuid = ?", "", &[task_id]).await?;
+        let tasks = self.get_tasks_with_labels_joined("WHERE remote_id = ?", "", &[task_id]).await?;
🧹 Nitpick comments (7)
src/storage/db.rs (2)

50-63: Optional: expose a shutdown path for the keepalive task.

Return/store the JoinHandle and add a stop signal to avoid task leaks in tests/shutdown scenarios. Pool options already keep conns alive; this task is best-effort.


69-79: Future-proof for multi-backend: make (backend, remote_id) unique instead of remote_id alone.

The PR targets multi-backend “soon-ish”; remote_id may collide across providers. Add a backend TEXT NOT NULL column and use a composite UNIQUE index per table, and update lookups to include backend.

Example migration (SQLite):

ALTER TABLE projects ADD COLUMN backend TEXT NOT NULL DEFAULT 'todoist';
CREATE UNIQUE INDEX IF NOT EXISTS ux_projects_backend_remote ON projects(backend, remote_id);

ALTER TABLE sections ADD COLUMN backend TEXT NOT NULL DEFAULT 'todoist';
CREATE UNIQUE INDEX IF NOT EXISTS ux_sections_backend_remote ON sections(backend, remote_id);

ALTER TABLE tasks ADD COLUMN backend TEXT NOT NULL DEFAULT 'todoist';
CREATE UNIQUE INDEX IF NOT EXISTS ux_tasks_backend_remote ON tasks(backend, remote_id);

ALTER TABLE labels ADD COLUMN backend TEXT NOT NULL DEFAULT 'todoist';
CREATE UNIQUE INDEX IF NOT EXISTS ux_labels_backend_remote ON labels(backend, remote_id);

Then, extend find_uuid_by_remote_id to bind both backend and remote_id.

Also applies to: 86-97, 100-123, 126-139

src/storage/projects.rs (2)

129-136: Rely on FK cascade to delete tasks; drop the manual delete.

tasks.project_uuid has ON DELETE CASCADE; the extra DELETE is redundant work.

Apply:

-        // Delete tasks first, then the project
-        sqlx::query("DELETE FROM tasks WHERE project_uuid = ?")
-            .bind(project_id)
-            .execute(&mut *tx)
-            .await?;
-
         sqlx::query("DELETE FROM projects WHERE uuid = ?")
             .bind(project_id)
             .execute(&mut *tx)
             .await?;

145-152: Rename parameters to *_uuid for clarity
Change the project_id: &str parameters in update_project_name and delete_project to project_uuid: &str to match the uuid column; no call-site changes are needed since Rust uses positional arguments.

src/storage/labels.rs (1)

101-109: Rename label_idlabel_uuid in update_label_name for accuracy. If you also rename the local label_id variable in src/sync.rs, update its usage and the log message accordingly.

src/storage/sections.rs (1)

94-99: Nit: parameter naming for clarity.

project_id actually expects a local UUID. Consider renaming to project_uuid to avoid ambiguity (and update call sites).

src/storage/tasks.rs (1)

83-141: Reduce N+1 queries for labels.

get_tasks_with_labels_joined fetches labels per task, which doesn’t scale. Consider a single LEFT JOIN (tasks ↔ task_labels ↔ labels), then group labels per task in memory. This will cut DB round-trips and speed up large listings.

📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 884a934 and b9c39b2.

⛔ Files ignored due to path filters (1)
  • Cargo.lock is excluded by !**/*.lock
📒 Files selected for processing (7)
  • Cargo.toml (1 hunks)
  • src/storage/db.rs (7 hunks)
  • src/storage/labels.rs (6 hunks)
  • src/storage/projects.rs (7 hunks)
  • src/storage/sections.rs (5 hunks)
  • src/storage/tasks.rs (18 hunks)
  • src/sync.rs (1 hunks)
🚧 Files skipped from review as they are similar to previous changes (2)
  • src/sync.rs
  • Cargo.toml
🧰 Additional context used
🧬 Code graph analysis (5)
src/storage/db.rs (4)
src/storage/labels.rs (1)
  • sqlx (92-92)
src/storage/projects.rs (1)
  • sqlx (156-156)
src/storage/sections.rs (2)
  • sqlx (81-81)
  • sqlx (95-95)
src/storage/tasks.rs (3)
  • sqlx (60-60)
  • sqlx (111-111)
  • sqlx (159-159)
src/storage/sections.rs (3)
src/storage/labels.rs (1)
  • from (23-32)
src/storage/projects.rs (2)
  • from (21-30)
  • from (34-45)
src/storage/tasks.rs (1)
  • from (28-54)
src/storage/projects.rs (3)
src/storage/labels.rs (2)
  • from (23-32)
  • sqlx (92-92)
src/storage/sections.rs (4)
  • from (17-24)
  • from (28-36)
  • sqlx (81-81)
  • sqlx (95-95)
src/storage/tasks.rs (4)
  • from (28-54)
  • sqlx (60-60)
  • sqlx (111-111)
  • sqlx (159-159)
src/storage/tasks.rs (2)
src/storage/db.rs (1)
  • new (16-48)
src/sync.rs (2)
  • new (94-105)
  • get_tasks_for_project (143-146)
src/storage/labels.rs (1)
src/storage/tasks.rs (3)
  • sqlx (60-60)
  • sqlx (111-111)
  • sqlx (159-159)
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (5)
  • GitHub Check: Test (windows-latest, beta)
  • GitHub Check: Test (macos-latest, stable)
  • GitHub Check: Test (macos-latest, beta)
  • GitHub Check: Test (windows-latest, stable)
  • GitHub Check: Security Audit
🔇 Additional comments (5)
src/storage/projects.rs (1)

82-95: Nit: small consistency tweak in second-pass parent resolution.

Use &mut *tx (as elsewhere) for executor; current style already matches, just keep it consistent across files after fixing db.rs.

src/storage/sections.rs (2)

27-36: From

leaves project_uuid resolution to storage — good.

Generation of a fresh local UUID and deferring parent resolution to the storage layer matches the intended pattern and avoids mixing remote IDs into local UUID fields. Ensure callers always resolve project_uuid before insert to avoid FK issues at runtime.


19-23: Clarify what SectionDisplay.id should represent (local UUID vs remote ID).

Here id = local.uuid. Tasks expose TaskDisplay.id = remote_id, which may confuse consumers. Please confirm the contract and align across Display types (or expose both id and local_uuid consistently).

src/storage/tasks.rs (2)

37-53: From local mapping looks correct.

UUID generation and deferring of project/section/parent resolution to storage are consistent and avoid mixing remote IDs into local UUID fields.


59-79: Confirm LabelDisplay.id contract (local vs remote).

Here labels are exposed with id = label.uuid (local). If external consumers expect remote IDs like tasks, consider exposing both or switching to remote for consistency.

Comment thread src/storage/db.rs
Comment on lines +201 to +211
/// Generic helper to get local UUID from remote ID for any table
pub(crate) async fn find_uuid_by_remote_id(
&self,
tx: &mut sqlx::Transaction<'_, sqlx::Sqlite>,
table: &str,
remote_id: &str,
) -> Result<Option<String>> {
let query = format!("SELECT uuid FROM {} WHERE remote_id = ?", table);
let uuid = sqlx::query_scalar(&query).bind(remote_id).fetch_optional(&mut **tx).await?;
Ok(uuid)
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

Block SQL injection: whitelist table names and avoid format! in queries.

format!("... FROM {} ...", table) is a SQL injection vector if table is ever non-constant. Also, prefer &mut *tx for the executor.

Apply this diff to harden the helper:

-    pub(crate) async fn find_uuid_by_remote_id(
+    pub(crate) async fn find_uuid_by_remote_id(
         &self,
         tx: &mut sqlx::Transaction<'_, sqlx::Sqlite>,
-        table: &str,
+        table: &str,
         remote_id: &str,
     ) -> Result<Option<String>> {
-        let query = format!("SELECT uuid FROM {} WHERE remote_id = ?", table);
-        let uuid = sqlx::query_scalar(&query).bind(remote_id).fetch_optional(&mut **tx).await?;
+        let sql = match table {
+            "projects" => "SELECT uuid FROM projects WHERE remote_id = ?",
+            "sections" => "SELECT uuid FROM sections WHERE remote_id = ?",
+            "tasks"    => "SELECT uuid FROM tasks WHERE remote_id = ?",
+            "labels"   => "SELECT uuid FROM labels WHERE remote_id = ?",
+            _ => return Err(anyhow::anyhow!(format!("disallowed table name: {}", table))),
+        };
+        let uuid = sqlx::query_scalar(sql)
+            .bind(remote_id)
+            .fetch_optional(&mut *tx)
+            .await?;
         Ok(uuid)
     }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
/// Generic helper to get local UUID from remote ID for any table
pub(crate) async fn find_uuid_by_remote_id(
&self,
tx: &mut sqlx::Transaction<'_, sqlx::Sqlite>,
table: &str,
remote_id: &str,
) -> Result<Option<String>> {
let query = format!("SELECT uuid FROM {} WHERE remote_id = ?", table);
let uuid = sqlx::query_scalar(&query).bind(remote_id).fetch_optional(&mut **tx).await?;
Ok(uuid)
}
/// Generic helper to get local UUID from remote ID for any table
pub(crate) async fn find_uuid_by_remote_id(
&self,
tx: &mut sqlx::Transaction<'_, sqlx::Sqlite>,
table: &str,
remote_id: &str,
) -> Result<Option<String>> {
let sql = match table {
"projects" => "SELECT uuid FROM projects WHERE remote_id = ?",
"sections" => "SELECT uuid FROM sections WHERE remote_id = ?",
"tasks" => "SELECT uuid FROM tasks WHERE remote_id = ?",
"labels" => "SELECT uuid FROM labels WHERE remote_id = ?",
_ => return Err(anyhow::anyhow!(format!("disallowed table name: {}", table))),
};
let uuid = sqlx::query_scalar(sql)
.bind(remote_id)
.fetch_optional(&mut *tx)
.await?;
Ok(uuid)
}

Comment thread src/storage/sections.rs
Comment on lines +49 to +55
// Look up local project UUID from remote project_id
if let Some(local_project_uuid) =
self.find_uuid_by_remote_id(&mut tx, "projects", &section.project_id).await?
{
local_section.project_uuid = local_project_uuid;
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Guard against unresolved project mapping (avoid inserting with empty FK).

If find_uuid_by_remote_id returns None, project_uuid stays empty. With FK constraints and NOT NULL, this will fail; without them, it creates broken references. Prefer skipping such sections until the project exists.

-            if let Some(local_project_uuid) =
-                self.find_uuid_by_remote_id(&mut tx, "projects", &section.project_id).await?
-            {
-                local_section.project_uuid = local_project_uuid;
-            }
+            if let Some(local_project_uuid) =
+                self.find_uuid_by_remote_id(&mut tx, "projects", &section.project_id).await?
+            {
+                local_section.project_uuid = local_project_uuid;
+            } else {
+                // Parent project not present locally yet; skip for now
+                continue;
+            }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
// Look up local project UUID from remote project_id
if let Some(local_project_uuid) =
self.find_uuid_by_remote_id(&mut tx, "projects", &section.project_id).await?
{
local_section.project_uuid = local_project_uuid;
}
// Look up local project UUID from remote project_id
if let Some(local_project_uuid) =
self.find_uuid_by_remote_id(&mut tx, "projects", &section.project_id).await?
{
local_section.project_uuid = local_project_uuid;
} else {
// Parent project not present locally yet; skip for now
continue;
}
🤖 Prompt for AI Agents
In src/storage/sections.rs around lines 49 to 55, the code sets
local_section.project_uuid only when find_uuid_by_remote_id returns Some,
leaving project_uuid empty otherwise which can violate FK/NOT NULL or create
broken references; change the logic to detect the None case and skip
processing/inserting this section (e.g., log a warning/info with section.remote
id and continue to next item), do not attempt to insert or update a section
without a resolved project UUID, and ensure the function returns success for
skipped items while preserving transaction integrity.

Comment thread src/storage/sections.rs
Comment on lines +58 to 64
INSERT INTO sections (uuid, remote_id, name, project_uuid, order_index)
VALUES (?, ?, ?, ?, ?)
ON CONFLICT(remote_id) DO UPDATE SET
name = excluded.name,
project_uuid = excluded.project_uuid,
order_index = excluded.order_index
",

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Upsert preserves UUIDs — add pruning of stale sections after successful upserts.

Current logic doesn’t delete sections removed upstream, leaving stale rows. After the loop (before commit), delete rows whose remote_id isn’t in the incoming set.

         for section in &sections {
             /* upsert as implemented */
         }
+
+        // Prune sections not present in the incoming snapshot
+        let remote_ids: Vec<&str> = sections.iter().map(|s| s.id.as_str()).collect();
+        if !remote_ids.is_empty() {
+            let placeholders = std::iter::repeat("?").take(remote_ids.len()).collect::<Vec<_>>().join(",");
+            let sql = format!("DELETE FROM sections WHERE remote_id NOT IN ({})", placeholders);
+            let mut qb = sqlx::query(&sql);
+            for id in &remote_ids {
+                qb = qb.bind(id);
+            }
+            qb.execute(&mut *tx).await?;
+        }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
INSERT INTO sections (uuid, remote_id, name, project_uuid, order_index)
VALUES (?, ?, ?, ?, ?)
ON CONFLICT(remote_id) DO UPDATE SET
name = excluded.name,
project_uuid = excluded.project_uuid,
order_index = excluded.order_index
",
for section in &sections {
/* upsert as implemented */
}
// Prune sections not present in the incoming snapshot
let remote_ids: Vec<&str> = sections.iter().map(|s| s.id.as_str()).collect();
if !remote_ids.is_empty() {
let placeholders = std::iter::repeat("?")
.take(remote_ids.len())
.collect::<Vec<_>>()
.join(",");
let sql = format!(
"DELETE FROM sections WHERE remote_id NOT IN ({})",
placeholders
);
let mut qb = sqlx::query(&sql);
for id in &remote_ids {
qb = qb.bind(id);
}
qb.execute(&mut *tx).await?;
}
🤖 Prompt for AI Agents
In src/storage/sections.rs around lines 58 to 64, the upsert SQL correctly
updates/inserts sections but never removes sections deleted upstream; after
performing all upserts and before committing the transaction, collect the set of
incoming remote_id values and execute a parameterized DELETE that removes rows
for the same project_uuid whose remote_id is NOT in that set (if the incoming
set is empty, delete all sections for that project_uuid). Ensure this deletion
runs inside the same transaction and uses prepared/parameterized arguments to
avoid SQL injection, then commit.

Comment thread src/storage/tasks.rs
Comment on lines +120 to 127
let labels = self.get_labels_for_task(&task.uuid).await?;
task_displays.push(TaskDisplay {
id: task.id,
id: task.remote_id,
content: task.content,
project_id: task.project_id,
section_id: task.section_id,
parent_id: task.parent_id,
project_id: task.project_uuid,
section_id: task.section_uuid,
parent_id: task.parent_uuid,
priority: task.priority,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

ID semantics mismatch: Display uses remote_id; write paths below expect uuid.

TaskDisplay.id = task.remote_id, but methods like get_task_by_id, mark_task_completed/deleted, etc., query by uuid. This is a foot‑gun for callers passing TaskDisplay.id. Pick one identifier across reads/writes (suggest: remote_id externally), and update write queries accordingly (see diffs below).

🤖 Prompt for AI Agents
In src/storage/tasks.rs around lines 120–127 the TaskDisplay.id is set to
task.remote_id while downstream write operations (get_task_by_id,
mark_task_completed/deleted, etc.) expect the internal task.uuid, causing an
identifier mismatch; choose a single external identifier (suggest using
remote_id externally) and update the write paths to either accept remote_id and
resolve it to the internal uuid before executing DB queries or change their
queries to directly filter on remote_id; implement a small helper (e.g.,
get_uuid_by_remote_id) to translate remote_id→uuid and call it at the start of
each write path so all subsequent database updates continue to use uuid, or
alternatively set TaskDisplay.id = task.uuid and update any callsites that
expect remote_id—pick one approach and apply it consistently across read and
write code paths.

Comment thread src/storage/tasks.rs
Comment on lines +271 to 275
// Clear and recreate label relationships
sqlx::query("DELETE FROM task_labels").execute(&self.pool).await?;
for (task_uuid, label_names) in task_labels {
self.store_task_labels(&task_uuid, &label_names).await?;
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Avoid global wipe of task-label relations; delete only for touched tasks.

DELETE FROM task_labels drops relationships for tasks not in this sync batch. Restrict deletion to the updated task UUIDs.

-        // Clear and recreate label relationships
-        sqlx::query("DELETE FROM task_labels").execute(&self.pool).await?;
-        for (task_uuid, label_names) in task_labels {
-            self.store_task_labels(&task_uuid, &label_names).await?;
-        }
+        // Clear and recreate label relationships only for tasks we upserted
+        let touched: Vec<String> = task_labels.iter().map(|(u, _)| u.clone()).collect();
+        if !touched.is_empty() {
+            let ph = std::iter::repeat("?").take(touched.len()).collect::<Vec<_>>().join(",");
+            let sql = format!("DELETE FROM task_labels WHERE task_uuid IN ({})", ph);
+            let mut qb = sqlx::query(&sql);
+            for id in &touched {
+                qb = qb.bind(id);
+            }
+            qb.execute(&self.pool).await?;
+        }
+        for (task_uuid, label_names) in task_labels {
+            self.store_task_labels(&task_uuid, &label_names).await?;
+        }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
// Clear and recreate label relationships
sqlx::query("DELETE FROM task_labels").execute(&self.pool).await?;
for (task_uuid, label_names) in task_labels {
self.store_task_labels(&task_uuid, &label_names).await?;
}
// Clear and recreate label relationships only for tasks we upserted
let touched: Vec<String> = task_labels.iter().map(|(u, _)| u.clone()).collect();
if !touched.is_empty() {
let ph = std::iter::repeat("?")
.take(touched.len())
.collect::<Vec<_>>()
.join(",");
let sql = format!("DELETE FROM task_labels WHERE task_uuid IN ({})", ph);
let mut qb = sqlx::query(&sql);
for id in &touched {
qb = qb.bind(id);
}
qb.execute(&self.pool).await?;
}
for (task_uuid, label_names) in task_labels {
self.store_task_labels(&task_uuid, &label_names).await?;
}
🤖 Prompt for AI Agents
In src/storage/tasks.rs around lines 271 to 275, the code currently issues a
global "DELETE FROM task_labels" which removes label relations for all tasks;
instead collect the task UUIDs present in the task_labels input and delete only
rows matching those UUIDs (e.g., use a parameterized DELETE ... WHERE task_uuid
IN (...) or issue a DELETE per UUID) before recreating relations by calling
store_task_labels for each task; ensure the delete uses proper parameter binding
to avoid SQL injection and consider wrapping the delete+insert loop in the same
transaction to maintain consistency.

Comment thread src/storage/tasks.rs
Comment on lines +310 to 312
INSERT OR REPLACE INTO tasks (uuid, remote_id, content, project_uuid, section_uuid, parent_uuid, priority, order_index, due_date, due_datetime, is_recurring, deadline, duration, description, is_completed, is_deleted)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
",

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

INSERT OR REPLACE churns UUIDs and can break FKs; use upsert and preserve UUID + canonicalize label updates.

OR REPLACE deletes then inserts, assigning a fresh UUID (generated above), which can sever label relations or parent links. Mirror the upsert used in store_tasks, and base label relations on the canonical UUID looked up by remote_id.

-        sqlx::query(
-            r"
-            INSERT OR REPLACE INTO tasks (uuid, remote_id, content, project_uuid, section_uuid, parent_uuid, priority, order_index, due_date, due_datetime, is_recurring, deadline, duration, description, is_completed, is_deleted)
-            VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
-            ",
-        )
+        sqlx::query(
+            r"
+            INSERT INTO tasks (uuid, remote_id, content, project_uuid, section_uuid, parent_uuid, priority, order_index, due_date, due_datetime, is_recurring, deadline, duration, description, is_completed, is_deleted)
+            VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
+            ON CONFLICT(remote_id) DO UPDATE SET
+                content = excluded.content,
+                project_uuid = excluded.project_uuid,
+                section_uuid = excluded.section_uuid,
+                parent_uuid = excluded.parent_uuid,
+                priority = excluded.priority,
+                order_index = excluded.order_index,
+                due_date = excluded.due_date,
+                due_datetime = excluded.due_datetime,
+                is_recurring = excluded.is_recurring,
+                deadline = excluded.deadline,
+                duration = excluded.duration,
+                description = excluded.description,
+                is_completed = excluded.is_completed,
+                is_deleted = excluded.is_deleted
+            "
+        )
             .bind(&local_task.uuid)
             .bind(&local_task.remote_id)
             .bind(&local_task.content)
             .bind(&local_task.project_uuid)
             .bind(&local_task.section_uuid)
             .bind(&local_task.parent_uuid)
             .bind(local_task.priority)
             .bind(local_task.order_index)
             .bind(&local_task.due_date)
             .bind(&local_task.due_datetime)
             .bind(local_task.is_recurring)
             .bind(&local_task.deadline)
             .bind(&local_task.duration)
             .bind(&local_task.description)
             .bind(local_task.is_completed)
             .bind(local_task.is_deleted)
             .execute(&mut *tx)
             .await?;
 
-        tx.commit().await?;
-
-        // Store label relationships after transaction commits
-        self.clear_task_labels(&local_task.uuid).await?;
-        self.store_task_labels(&local_task.uuid, &label_names).await?;
+        // Resolve canonical UUID (preserved on upsert) for label relations
+        let task_uuid = self.find_uuid_by_remote_id(&mut tx, "tasks", &task.id).await?;
+
+        tx.commit().await?;
+
+        if let Some(task_uuid) = task_uuid {
+            self.clear_task_labels(&task_uuid).await?;
+            self.store_task_labels(&task_uuid, &label_names).await?;
+        }

Also applies to: 333-338

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant