Make tasks belong to a single backlog
change xtmlwzttvksxmyqoyqmoysywpwywzplt
commit d5597c88cb1d667af0bd21c213750e283fb17c45
author Alpha Chen <alpha@kejadlen.dev>
date
parent qoptxslv
diff --git a/AGENTS.md b/AGENTS.md
index de9f5f7..0d3b178 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -72,8 +72,8 @@ When adding queued tasks, consider where they belong relative to existing work.
 
 ```bash
 ranger backlog show <name>                                     # see current order
-ranger task move <key> --backlog <name> --before <key>         # place before a task
-ranger task move <key> --backlog <name> --after <key>          # place after a task
+ranger task move <key> --before <key>                          # place before a task
+ranger task move <key> --after <key>                           # place after a task
 ```
 
 Top of the queue = most important. Only move tasks within the queued state — don't reposition done, in_progress, or icebox tasks. Ask the user where a task should go if priority isn't obvious. Don't just append everything to the bottom — a backlog that isn't ordered isn't useful.
@@ -118,7 +118,7 @@ tests/
 - **Keys**: jj-style random strings (16 chars, `k-z` alphabet). Reference by shortest unique prefix.
 - **Positioning**: Lexicographic string-based ordering within backlogs. Insert between two positions without renumbering.
 - **Single crate**: Library (`src/lib.rs`) and binary (`src/bin/ranger/`) in one crate. No workspace.
-- **Tasks in multiple backlogs**: A task can belong to multiple backlogs via `backlog_tasks` join table, with independent positions.
+- **Single backlog per task**: Each task belongs to exactly one backlog. `backlog_id` and `position` live directly on the `tasks` table.
 - **Subtasks are tasks**: `parent_id` on tasks — subtasks get full task capabilities.
 - **No compile-time checked queries**: Using `sqlx::query_as` with runtime binding, not `query_as!` macros. No need for `DATABASE_URL` at build time.
 
diff --git a/migrations/004_single_backlog.sql b/migrations/004_single_backlog.sql
new file mode 100644
index 0000000..7dce23d
--- /dev/null
+++ b/migrations/004_single_backlog.sql
@@ -0,0 +1,24 @@
+-- Move from many-to-many (backlog_tasks) to direct ownership (tasks.backlog_id + tasks.position).
+-- For tasks in multiple backlogs, keep the first (lowest backlog_id) association.
+
+ALTER TABLE tasks ADD COLUMN backlog_id INTEGER REFERENCES backlogs(id) ON DELETE CASCADE;
+ALTER TABLE tasks ADD COLUMN position TEXT NOT NULL DEFAULT '';
+
+-- Populate from backlog_tasks — pick the lowest backlog_id per task
+UPDATE tasks SET
+    backlog_id = (
+        SELECT bt.backlog_id FROM backlog_tasks bt
+        WHERE bt.task_id = tasks.id
+        ORDER BY bt.backlog_id ASC LIMIT 1
+    ),
+    position = (
+        SELECT bt.position FROM backlog_tasks bt
+        WHERE bt.task_id = tasks.id
+        ORDER BY bt.backlog_id ASC LIMIT 1
+    );
+
+-- Drop orphaned tasks (not in any backlog) — shouldn't happen, but be safe
+DELETE FROM tasks WHERE backlog_id IS NULL;
+
+-- Drop the join table
+DROP TABLE backlog_tasks;
diff --git a/src/bin/ranger/commands/task.rs b/src/bin/ranger/commands/task.rs
index b1c718d..e03b4bc 100644
--- a/src/bin/ranger/commands/task.rs
+++ b/src/bin/ranger/commands/task.rs
@@ -40,7 +40,7 @@ pub enum TaskCommands {
     Create {
         /// Task title
         title: String,
-        /// Backlog key or prefix
+        /// Backlog name
         #[arg(long, env = "RANGER_DEFAULT_BACKLOG")]
         backlog: String,
         /// Task description
@@ -61,7 +61,7 @@ pub enum TaskCommands {
     /// List tasks
     #[command(visible_alias = "ls")]
     List {
-        /// Filter by backlog key or prefix
+        /// Filter by backlog name
         #[arg(long, env = "RANGER_DEFAULT_BACKLOG")]
         backlog: Option<String>,
         /// Filter by state
@@ -89,35 +89,15 @@ pub enum TaskCommands {
         #[arg(long)]
         state: Option<String>,
     },
-    /// Move a task's position within a backlog
+    /// Move a task's position within its backlog
     #[command(visible_alias = "mv")]
     Move {
         /// Task key or prefix
         key: String,
-        /// Backlog to reorder within
-        #[arg(long, env = "RANGER_DEFAULT_BACKLOG")]
-        backlog: String,
         #[command(flatten)]
         position: PositionArgs,
     },
-    /// Add a task to a backlog
-    #[command(visible_alias = "a")]
-    Add {
-        /// Task key or prefix
-        task: String,
-        /// Backlog key or prefix
-        #[arg(long, env = "RANGER_DEFAULT_BACKLOG")]
-        backlog: String,
-    },
-    /// Remove a task from a backlog
-    #[command(visible_alias = "rm")]
-    Remove {
-        /// Task key or prefix
-        task: String,
-        /// Backlog key or prefix
-        #[arg(long, env = "RANGER_DEFAULT_BACKLOG")]
-        backlog: String,
-    },
+
     /// Delete a task entirely
     #[command(visible_alias = "del")]
     Delete {
@@ -161,7 +141,7 @@ pub async fn run(pool: &SqlitePool, command: TaskCommands, json: bool) -> Result
             .await?;
 
             if before_id.is_some() || after_id.is_some() {
-                ops::task::move_task(&mut tx, task.id, bl.id, before_id, after_id).await?;
+                ops::task::move_task(&mut tx, task.id, before_id, after_id).await?;
             }
 
             if let Some(tags) = &tag {
@@ -179,8 +159,8 @@ pub async fn run(pool: &SqlitePool, command: TaskCommands, json: bool) -> Result
             let mut conn = pool.acquire().await?;
             let state = state.map(|s| s.parse::<State>()).transpose()?;
 
-            if let Some(backlog_key) = &backlog {
-                let bl = ops::backlog::get_by_name(&mut conn, backlog_key).await?;
+            if let Some(backlog_name) = &backlog {
+                let bl = ops::backlog::get_by_name(&mut conn, backlog_name).await?;
                 let tasks = ops::task::list(&mut conn, bl.id, state).await?;
                 output::print_list(&tasks, json, print_task);
             } else {
@@ -257,33 +237,14 @@ pub async fn run(pool: &SqlitePool, command: TaskCommands, json: bool) -> Result
             .await?;
             output::print(&updated, json, print_task);
         }
-        TaskCommands::Move {
-            key,
-            backlog,
-            position,
-        } => {
+        TaskCommands::Move { key, position } => {
             let mut conn = pool.acquire().await?;
-            let bl = ops::backlog::get_by_name(&mut conn, &backlog).await?;
             let task = ops::task::get_by_key_prefix(&mut conn, &key).await?;
             let (before_id, after_id) = position.resolve(&mut conn).await?;
 
-            ops::task::move_task(&mut conn, task.id, bl.id, before_id, after_id).await?;
+            ops::task::move_task(&mut conn, task.id, before_id, after_id).await?;
             println!("Moved {} {}", &task.key[..8], task.title);
         }
-        TaskCommands::Add { task, backlog } => {
-            let mut conn = pool.acquire().await?;
-            let t = ops::task::get_by_key_prefix(&mut conn, &task).await?;
-            let bl = ops::backlog::get_by_name(&mut conn, &backlog).await?;
-            ops::task::add_to_backlog(&mut conn, t.id, bl.id).await?;
-            println!("Added {} to {}", &t.key[..8], bl.name);
-        }
-        TaskCommands::Remove { task, backlog } => {
-            let mut conn = pool.acquire().await?;
-            let t = ops::task::get_by_key_prefix(&mut conn, &task).await?;
-            let bl = ops::backlog::get_by_name(&mut conn, &backlog).await?;
-            ops::task::remove_from_backlog(&mut conn, t.id, bl.id).await?;
-            println!("Removed {} from {}", &t.key[..8], bl.name);
-        }
         TaskCommands::Delete { key } => {
             let mut conn = pool.acquire().await?;
             let task = ops::task::get_by_key_prefix(&mut conn, &key).await?;
diff --git a/src/db.rs b/src/db.rs
index dd6e63f..b6444df 100644
--- a/src/db.rs
+++ b/src/db.rs
@@ -50,10 +50,10 @@ mod tests {
 
         assert!(table_names.contains(&"backlogs".to_string()));
         assert!(table_names.contains(&"tasks".to_string()));
-        assert!(table_names.contains(&"backlog_tasks".to_string()));
         assert!(table_names.contains(&"comments".to_string()));
         assert!(table_names.contains(&"blockers".to_string()));
         assert!(table_names.contains(&"tags".to_string()));
         assert!(table_names.contains(&"task_tags".to_string()));
+        assert!(!table_names.contains(&"backlog_tasks".to_string()));
     }
 }
diff --git a/src/models.rs b/src/models.rs
index d328663..6b385bb 100644
--- a/src/models.rs
+++ b/src/models.rs
@@ -80,10 +80,12 @@ pub struct Backlog {
 pub struct Task {
     pub id: i64,
     pub key: String,
+    pub backlog_id: i64,
     pub parent_id: Option<i64>,
     pub title: String,
     pub description: Option<String>,
     pub state: State,
+    pub position: String,
     pub created_at: Timestamp,
     pub updated_at: Timestamp,
 }
diff --git a/src/ops/task.rs b/src/ops/task.rs
index 448b4f4..49f8bc9 100644
--- a/src/ops/task.rs
+++ b/src/ops/task.rs
@@ -4,6 +4,9 @@ use crate::models::{State, Task};
 use crate::position;
 use sqlx::sqlite::SqliteConnection;
 
+const TASK_COLUMNS: &str =
+    "id, key, backlog_id, parent_id, title, description, state, position, created_at, updated_at";
+
 pub struct CreateTask<'a> {
     pub title: &'a str,
     pub backlog_id: i64,
@@ -19,21 +22,8 @@ pub async fn create(
     let key = key::generate_key();
     let state = params.state.unwrap_or(State::Icebox);
 
-    let task = sqlx::query_as::<_, Task>(
-        "INSERT INTO tasks (key, parent_id, title, description, state) \
-         VALUES (?, ?, ?, ?, ?) \
-         RETURNING id, key, parent_id, title, description, state, created_at, updated_at",
-    )
-    .bind(&key)
-    .bind(params.parent_id)
-    .bind(params.title)
-    .bind(params.description)
-    .bind(state.as_str())
-    .fetch_one(&mut *conn)
-    .await?;
-
     let last_pos: Option<String> = sqlx::query_scalar(
-        "SELECT position FROM backlog_tasks \
+        "SELECT position FROM tasks \
          WHERE backlog_id = ? \
          ORDER BY position DESC LIMIT 1",
     )
@@ -43,11 +33,21 @@ pub async fn create(
 
     let new_pos = position::between(last_pos.as_deref().unwrap_or(""), "");
 
-    sqlx::query("INSERT INTO backlog_tasks (backlog_id, task_id, position) VALUES (?, ?, ?)")
+    let query = format!(
+        "INSERT INTO tasks (key, backlog_id, parent_id, title, description, state, position) \
+         VALUES (?, ?, ?, ?, ?, ?, ?) \
+         RETURNING {TASK_COLUMNS}"
+    );
+
+    let task = sqlx::query_as::<_, Task>(&query)
+        .bind(&key)
         .bind(params.backlog_id)
-        .bind(task.id)
+        .bind(params.parent_id)
+        .bind(params.title)
+        .bind(params.description)
+        .bind(state.as_str())
         .bind(&new_pos)
-        .execute(&mut *conn)
+        .fetch_one(&mut *conn)
         .await?;
 
     Ok(task)
@@ -59,42 +59,36 @@ pub async fn list(
     state_filter: Option<State>,
 ) -> Result<Vec<Task>, RangerError> {
     let tasks = if let Some(state) = state_filter {
-        sqlx::query_as::<_, Task>(
-            "SELECT t.id, t.key, t.parent_id, t.title, t.description, t.state, \
-             t.created_at, t.updated_at \
-             FROM tasks t \
-             JOIN backlog_tasks bt ON bt.task_id = t.id \
-             WHERE bt.backlog_id = ? AND t.state = ? \
-             ORDER BY bt.position",
-        )
-        .bind(backlog_id)
-        .bind(state.as_str())
-        .fetch_all(&mut *conn)
-        .await?
+        let query = format!(
+            "SELECT {TASK_COLUMNS} FROM tasks \
+             WHERE backlog_id = ? AND state = ? \
+             ORDER BY position"
+        );
+        sqlx::query_as::<_, Task>(&query)
+            .bind(backlog_id)
+            .bind(state.as_str())
+            .fetch_all(&mut *conn)
+            .await?
     } else {
-        sqlx::query_as::<_, Task>(
-            "SELECT t.id, t.key, t.parent_id, t.title, t.description, t.state, \
-             t.created_at, t.updated_at \
-             FROM tasks t \
-             JOIN backlog_tasks bt ON bt.task_id = t.id \
-             WHERE bt.backlog_id = ? \
-             ORDER BY bt.position",
-        )
-        .bind(backlog_id)
-        .fetch_all(&mut *conn)
-        .await?
+        let query = format!(
+            "SELECT {TASK_COLUMNS} FROM tasks \
+             WHERE backlog_id = ? \
+             ORDER BY position"
+        );
+        sqlx::query_as::<_, Task>(&query)
+            .bind(backlog_id)
+            .fetch_all(&mut *conn)
+            .await?
     };
     Ok(tasks)
 }
 
 pub async fn get_by_id(conn: &mut SqliteConnection, id: i64) -> Result<Task, RangerError> {
-    let task = sqlx::query_as::<_, Task>(
-        "SELECT id, key, parent_id, title, description, state, created_at, updated_at \
-         FROM tasks WHERE id = ?",
-    )
-    .bind(id)
-    .fetch_one(&mut *conn)
-    .await?;
+    let query = format!("SELECT {TASK_COLUMNS} FROM tasks WHERE id = ?");
+    let task = sqlx::query_as::<_, Task>(&query)
+        .bind(id)
+        .fetch_one(&mut *conn)
+        .await?;
     Ok(task)
 }
 
@@ -103,13 +97,11 @@ pub async fn get_by_key_prefix(
     prefix: &str,
 ) -> Result<Task, RangerError> {
     let pattern = format!("{prefix}%");
-    let matches = sqlx::query_as::<_, Task>(
-        "SELECT id, key, parent_id, title, description, state, created_at, updated_at \
-         FROM tasks WHERE key LIKE ?",
-    )
-    .bind(&pattern)
-    .fetch_all(&mut *conn)
-    .await?;
+    let query = format!("SELECT {TASK_COLUMNS} FROM tasks WHERE key LIKE ?");
+    let matches = sqlx::query_as::<_, Task>(&query)
+        .bind(&pattern)
+        .fetch_all(&mut *conn)
+        .await?;
 
     match matches.len() {
         0 => Err(RangerError::KeyNotFound(prefix.to_string())),
@@ -147,67 +139,62 @@ pub async fn edit(
             .await?;
     }
 
-    let task = sqlx::query_as::<_, Task>(
-        "SELECT id, key, parent_id, title, description, state, created_at, updated_at \
-         FROM tasks WHERE id = ?",
-    )
-    .bind(task_id)
-    .fetch_one(&mut *conn)
-    .await?;
+    let query = format!("SELECT {TASK_COLUMNS} FROM tasks WHERE id = ?");
+    let task = sqlx::query_as::<_, Task>(&query)
+        .bind(task_id)
+        .fetch_one(&mut *conn)
+        .await?;
     Ok(task)
 }
 
 pub async fn move_task(
     conn: &mut SqliteConnection,
     task_id: i64,
-    backlog_id: i64,
     before_task_id: Option<i64>,
     after_task_id: Option<i64>,
 ) -> Result<(), RangerError> {
-    // "before" task = the task we want to appear after us (upper bound)
-    // "after" task = the task we want to appear before us (lower bound)
+    // Look up the task's backlog_id so position queries are scoped correctly
+    let backlog_id: i64 = sqlx::query_scalar("SELECT backlog_id FROM tasks WHERE id = ?")
+        .bind(task_id)
+        .fetch_one(&mut *conn)
+        .await?;
+
     let upper: Option<String> = if let Some(id) = before_task_id {
-        sqlx::query_scalar(
-            "SELECT position FROM backlog_tasks WHERE backlog_id = ? AND task_id = ?",
-        )
-        .bind(backlog_id)
-        .bind(id)
-        .fetch_optional(&mut *conn)
-        .await?
+        sqlx::query_scalar("SELECT position FROM tasks WHERE id = ? AND backlog_id = ?")
+            .bind(id)
+            .bind(backlog_id)
+            .fetch_optional(&mut *conn)
+            .await?
     } else {
         None
     };
 
     let lower: Option<String> = if let Some(id) = after_task_id {
-        sqlx::query_scalar(
-            "SELECT position FROM backlog_tasks WHERE backlog_id = ? AND task_id = ?",
-        )
-        .bind(backlog_id)
-        .bind(id)
-        .fetch_optional(&mut *conn)
-        .await?
+        sqlx::query_scalar("SELECT position FROM tasks WHERE id = ? AND backlog_id = ?")
+            .bind(id)
+            .bind(backlog_id)
+            .fetch_optional(&mut *conn)
+            .await?
     } else {
         None
     };
 
     let new_pos = match (lower.as_deref(), upper.as_deref()) {
-        // No bounds: append to end of backlog
         (None, None) => {
             let last_pos: Option<String> = sqlx::query_scalar(
-                "SELECT bt.position FROM backlog_tasks bt \
-                 WHERE bt.backlog_id = ? \
-                 ORDER BY bt.position DESC LIMIT 1",
+                "SELECT position FROM tasks \
+                 WHERE backlog_id = ? \
+                 ORDER BY position DESC LIMIT 1",
             )
             .bind(backlog_id)
             .fetch_optional(&mut *conn)
             .await?;
             position::between(last_pos.as_deref().unwrap_or(""), "")
         }
-        // After only: find the next task as upper bound
         (Some(low), None) => {
             let next: Option<String> = sqlx::query_scalar(
-                "SELECT position FROM backlog_tasks \
-                 WHERE backlog_id = ? AND task_id != ? AND position > ? \
+                "SELECT position FROM tasks \
+                 WHERE backlog_id = ? AND id != ? AND position > ? \
                  ORDER BY position ASC LIMIT 1",
             )
             .bind(backlog_id)
@@ -217,11 +204,10 @@ pub async fn move_task(
             .await?;
             position::between(low, next.as_deref().unwrap_or(""))
         }
-        // Before only: find the previous task as lower bound
         (None, Some(up)) => {
             let prev: Option<String> = sqlx::query_scalar(
-                "SELECT position FROM backlog_tasks \
-                 WHERE backlog_id = ? AND task_id != ? AND position < ? \
+                "SELECT position FROM tasks \
+                 WHERE backlog_id = ? AND id != ? AND position < ? \
                  ORDER BY position DESC LIMIT 1",
             )
             .bind(backlog_id)
@@ -231,59 +217,18 @@ pub async fn move_task(
             .await?;
             position::between(prev.as_deref().unwrap_or(""), up)
         }
-        // Both bounds given
         (Some(low), Some(up)) => position::between(low, up),
     };
 
-    sqlx::query("UPDATE backlog_tasks SET position = ? WHERE backlog_id = ? AND task_id = ?")
+    sqlx::query("UPDATE tasks SET position = ? WHERE id = ?")
         .bind(&new_pos)
-        .bind(backlog_id)
-        .bind(task_id)
-        .execute(&mut *conn)
-        .await?;
-
-    Ok(())
-}
-
-pub async fn add_to_backlog(
-    conn: &mut SqliteConnection,
-    task_id: i64,
-    backlog_id: i64,
-) -> Result<(), RangerError> {
-    let last_pos: Option<String> = sqlx::query_scalar(
-        "SELECT position FROM backlog_tasks \
-         WHERE backlog_id = ? \
-         ORDER BY position DESC LIMIT 1",
-    )
-    .bind(backlog_id)
-    .fetch_optional(&mut *conn)
-    .await?;
-
-    let new_pos = position::between(last_pos.as_deref().unwrap_or(""), "");
-
-    sqlx::query("INSERT INTO backlog_tasks (backlog_id, task_id, position) VALUES (?, ?, ?)")
-        .bind(backlog_id)
         .bind(task_id)
-        .bind(&new_pos)
         .execute(&mut *conn)
         .await?;
 
     Ok(())
 }
 
-pub async fn remove_from_backlog(
-    conn: &mut SqliteConnection,
-    task_id: i64,
-    backlog_id: i64,
-) -> Result<(), RangerError> {
-    sqlx::query("DELETE FROM backlog_tasks WHERE backlog_id = ? AND task_id = ?")
-        .bind(backlog_id)
-        .bind(task_id)
-        .execute(&mut *conn)
-        .await?;
-    Ok(())
-}
-
 pub async fn delete(conn: &mut SqliteConnection, task_id: i64) -> Result<(), RangerError> {
     sqlx::query("DELETE FROM tasks WHERE id = ?")
         .bind(task_id)
@@ -326,18 +271,9 @@ mod tests {
 
         assert_eq!(task.title, "My Task");
         assert_eq!(task.state, State::Icebox);
+        assert_eq!(task.backlog_id, bl.id);
         assert!(!task.key.is_empty());
-
-        // Verify backlog_tasks join row exists
-        let count: i64 = sqlx::query_scalar(
-            "SELECT COUNT(*) FROM backlog_tasks WHERE backlog_id = ? AND task_id = ?",
-        )
-        .bind(bl.id)
-        .bind(task.id)
-        .fetch_one(&mut *conn)
-        .await
-        .unwrap();
-        assert_eq!(count, 1);
+        assert!(!task.position.is_empty());
     }
 
     #[tokio::test]
@@ -483,60 +419,6 @@ mod tests {
         assert_eq!(updated.state, State::Queued);
     }
 
-    #[tokio::test]
-    async fn add_task_to_second_backlog() {
-        let pool = test_pool().await;
-        let mut conn = pool.acquire().await.unwrap();
-        let bl1 = backlog::create(&mut conn, "First").await.unwrap();
-        let bl2 = backlog::create(&mut conn, "Second").await.unwrap();
-        let task = create(
-            &mut conn,
-            CreateTask {
-                title: "Shared",
-                backlog_id: bl1.id,
-                state: None,
-                parent_id: None,
-                description: None,
-            },
-        )
-        .await
-        .unwrap();
-
-        add_to_backlog(&mut conn, task.id, bl2.id).await.unwrap();
-
-        let tasks1 = list(&mut conn, bl1.id, None).await.unwrap();
-        let tasks2 = list(&mut conn, bl2.id, None).await.unwrap();
-        assert_eq!(tasks1.len(), 1);
-        assert_eq!(tasks2.len(), 1);
-        assert_eq!(tasks1[0].id, tasks2[0].id);
-    }
-
-    #[tokio::test]
-    async fn remove_task_from_backlog() {
-        let pool = test_pool().await;
-        let mut conn = pool.acquire().await.unwrap();
-        let bl = backlog::create(&mut conn, "Test").await.unwrap();
-        let task = create(
-            &mut conn,
-            CreateTask {
-                title: "Remove me",
-                backlog_id: bl.id,
-                state: None,
-                parent_id: None,
-                description: None,
-            },
-        )
-        .await
-        .unwrap();
-
-        remove_from_backlog(&mut conn, task.id, bl.id)
-            .await
-            .unwrap();
-
-        let tasks = list(&mut conn, bl.id, None).await.unwrap();
-        assert_eq!(tasks.len(), 0);
-    }
-
     #[tokio::test]
     async fn move_task_before() {
         let pool = test_pool().await;
@@ -580,7 +462,7 @@ mod tests {
         .unwrap();
 
         // Move t3 before t1 — should produce order: t3, t1, t2
-        move_task(&mut conn, t3.id, bl.id, Some(t1.id), None)
+        move_task(&mut conn, t3.id, Some(t1.id), None)
             .await
             .unwrap();
 
@@ -633,7 +515,7 @@ mod tests {
         .unwrap();
 
         // Move t1 after t3 — should produce order: t2, t3, t1
-        move_task(&mut conn, t1.id, bl.id, None, Some(t3.id))
+        move_task(&mut conn, t1.id, None, Some(t3.id))
             .await
             .unwrap();
 
@@ -686,7 +568,7 @@ mod tests {
         .unwrap();
 
         // Move t3 after t1 (but before t2) — should produce order: t1, t3, t2
-        move_task(&mut conn, t3.id, bl.id, None, Some(t1.id))
+        move_task(&mut conn, t3.id, None, Some(t1.id))
             .await
             .unwrap();
 
@@ -739,7 +621,7 @@ mod tests {
         .unwrap();
 
         // Move t1 before t3 (but after t2) — should produce order: t2, t1, t3
-        move_task(&mut conn, t1.id, bl.id, Some(t3.id), None)
+        move_task(&mut conn, t1.id, Some(t3.id), None)
             .await
             .unwrap();
 
@@ -792,7 +674,7 @@ mod tests {
         .unwrap();
 
         // Move t3 after t1 and before t2 — should produce order: t1, t3, t2
-        move_task(&mut conn, t3.id, bl.id, Some(t2.id), Some(t1.id))
+        move_task(&mut conn, t3.id, Some(t2.id), Some(t1.id))
             .await
             .unwrap();
 
@@ -825,7 +707,6 @@ mod tests {
         let result = get_by_key_prefix(&mut conn, &task.key).await;
         assert!(result.is_err());
 
-        // backlog_tasks should be cleaned up by cascade
         let tasks = list(&mut conn, bl.id, None).await.unwrap();
         assert_eq!(tasks.len(), 0);
     }
@@ -856,19 +737,20 @@ mod tests {
     async fn get_by_key_prefix_ambiguous() {
         let pool = test_pool().await;
         let mut conn = pool.acquire().await.unwrap();
+        let bl = backlog::create(&mut conn, "Test").await.unwrap();
 
         // Insert two tasks with keys that share a prefix, bypassing
         // the random key generator
-        sqlx::query("INSERT INTO tasks (key, title, state) VALUES ('kkkkaaaa', 'First', 'icebox')")
+        sqlx::query("INSERT INTO tasks (key, backlog_id, title, state, position) VALUES ('kkkkaaaa', ?, 'First', 'icebox', 'a')")
+            .bind(bl.id)
+            .execute(&mut *conn)
+            .await
+            .unwrap();
+        sqlx::query("INSERT INTO tasks (key, backlog_id, title, state, position) VALUES ('kkkkbbbb', ?, 'Second', 'icebox', 'b')")
+            .bind(bl.id)
             .execute(&mut *conn)
             .await
             .unwrap();
-        sqlx::query(
-            "INSERT INTO tasks (key, title, state) VALUES ('kkkkbbbb', 'Second', 'icebox')",
-        )
-        .execute(&mut *conn)
-        .await
-        .unwrap();
 
         let result = get_by_key_prefix(&mut conn, "kkkk").await;
         assert!(result.is_err());
@@ -930,9 +812,7 @@ mod tests {
         .unwrap();
 
         // Move first task to end (no before/after)
-        move_task(&mut conn, t1.id, bl.id, None, None)
-            .await
-            .unwrap();
+        move_task(&mut conn, t1.id, None, None).await.unwrap();
 
         let tasks = list(&mut conn, bl.id, None).await.unwrap();
         assert_eq!(tasks[0].id, t2.id);