Repository: shuttle-hq/shuttle (Rust)

Issue:
[Feature]: Refactor GET-many endpoints in deployer to allow pagination
### Describe the feature

We should implement pagination for the endpoints that get a list of items from the deployer and gateway. This will be useful for CLI users so they can get only a subset of e.g. projects, rather than all of them. It will also be useful for the console, so we can fetch a subset at a time, rather than fetch them all and then paginate them in the browser.

- [x] Implement pagination for project list (”/projects” endpoint on the router in gateway/api/latest)
- [x] Implement pagination for deployment list
- [x] Consider whether we should follow this up with some filtering, e.g. should we be able to filter project list by project state?

### Suggestion or Example of how the feature would be used

Are there more endpoints we should implement this for? Should we consider using [cursor pagination](https://dev.to/jackmarchant/offset-and-cursor-pagination-explained-b89) for this?

### Duplicate declaration

- [X] I have searched the issues and this feature has not been requested before.



Fix (patch):
diff --git a/cargo-shuttle/src/args.rs b/cargo-shuttle/src/args.rs
index 1cf9844c5b..6a14cfc25c 100644
--- a/cargo-shuttle/src/args.rs
+++ b/cargo-shuttle/src/args.rs
@@ -141,7 +141,15 @@ pub enum Command {
 #[derive(Parser)]
 pub enum DeploymentCommand {
     /// List all the deployments for a service
-    List,
+    List {
+        #[arg(long, default_value = "1")]
+        /// Which page to display
+        page: u32,
+
+        #[arg(long, default_value = "10")]
+        /// How many projects per page to display
+        limit: u32,
+    },
     /// View status of a deployment
     Status {
         /// ID of deployment to get status for
@@ -170,7 +178,15 @@ pub enum ProjectCommand {
     /// Destroy and create an environment for this project on shuttle
     Restart(ProjectStartArgs),
     /// List all projects belonging to the calling account
-    List,
+    List {
+        #[arg(long, default_value = "1")]
+        /// Which page to display
+        page: u32,
+
+        #[arg(long, default_value = "10")]
+        /// How many projects per page to display
+        limit: u32,
+    },
 }
 
 #[derive(Parser, Debug)]
diff --git a/cargo-shuttle/src/client.rs b/cargo-shuttle/src/client.rs
index 37625f4330..d31d7e85b7 100644
--- a/cargo-shuttle/src/client.rs
+++ b/cargo-shuttle/src/client.rs
@@ -93,7 +93,7 @@ impl Client {
         let path = format!(
             "/projects/{}/services/{}/resources",
             project.as_str(),
-            project.as_str()
+            project.as_str(),
         );
 
         self.get(path).await
@@ -129,8 +129,8 @@ impl Client {
         self.get(path).await
     }
 
-    pub async fn get_projects_list(&self) -> Result<Vec<project::Response>> {
-        let path = "/projects".to_string();
+    pub async fn get_projects_list(&self, page: u32, limit: u32) -> Result<Vec<project::Response>> {
+        let path = format!("/projects?page={}&limit={}", page.saturating_sub(1), limit);
 
         self.get(path).await
     }
@@ -182,8 +182,15 @@ impl Client {
     pub async fn get_deployments(
         &self,
         project: &ProjectName,
+        page: u32,
+        limit: u32,
     ) -> Result<Vec<deployment::Response>> {
-        let path = format!("/projects/{}/deployments", project.as_str());
+        let path = format!(
+            "/projects/{}/deployments?page={}&limit={}",
+            project.as_str(),
+            page.saturating_sub(1),
+            limit,
+        );
 
         self.get(path).await
     }
diff --git a/cargo-shuttle/src/lib.rs b/cargo-shuttle/src/lib.rs
index 2cd850b58f..84ea154b81 100644
--- a/cargo-shuttle/src/lib.rs
+++ b/cargo-shuttle/src/lib.rs
@@ -113,8 +113,8 @@ impl Shuttle {
             Command::Logs { id, latest, follow } => {
                 self.logs(&self.client()?, id, latest, follow).await
             }
-            Command::Deployment(DeploymentCommand::List) => {
-                self.deployments_list(&self.client()?).await
+            Command::Deployment(DeploymentCommand::List { page, limit }) => {
+                self.deployments_list(&self.client()?, page, limit).await
             }
             Command::Deployment(DeploymentCommand::Status { id }) => {
                 self.deployment_get(&self.client()?, id).await
@@ -132,7 +132,9 @@ impl Shuttle {
             Command::Project(ProjectCommand::Status { follow }) => {
                 self.project_status(&self.client()?, follow).await
             }
-            Command::Project(ProjectCommand::List) => self.projects_list(&self.client()?).await,
+            Command::Project(ProjectCommand::List { page, limit }) => {
+                self.projects_list(&self.client()?, page, limit).await
+            }
             Command::Project(ProjectCommand::Stop) => self.project_delete(&self.client()?).await,
         }
         .map(|_| CommandOutcome::Ok)
@@ -387,7 +389,7 @@ impl Shuttle {
 
             if latest {
                 // Find latest deployment (not always an active one)
-                let deployments = client.get_deployments(proj_name).await?;
+                let deployments = client.get_deployments(proj_name, 0, u32::MAX).await?;
                 let most_recent = deployments.last().context(format!(
                     "Could not find any deployments for '{proj_name}'. Try passing a deployment ID manually",
                 ))?;
@@ -424,10 +426,15 @@ impl Shuttle {
         Ok(())
     }
 
-    async fn deployments_list(&self, client: &Client) -> Result<()> {
+    async fn deployments_list(&self, client: &Client, page: u32, limit: u32) -> Result<()> {
+        if limit == 0 {
+            println!();
+            return Ok(());
+        }
+
         let proj_name = self.ctx.project_name();
-        let deployments = client.get_deployments(proj_name).await?;
-        let table = get_deployments_table(&deployments, proj_name.as_str());
+        let deployments = client.get_deployments(proj_name, page, limit).await?;
+        let table = get_deployments_table(&deployments, proj_name.as_str(), page);
 
         println!("{table}");
         println!("Run `cargo shuttle logs <id>` to get logs for a given deployment.");
@@ -1041,9 +1048,14 @@ impl Shuttle {
         Ok(())
     }
 
-    async fn projects_list(&self, client: &Client) -> Result<()> {
-        let projects = client.get_projects_list().await?;
-        let projects_table = project::get_table(&projects);
+    async fn projects_list(&self, client: &Client, page: u32, limit: u32) -> Result<()> {
+        if limit == 0 {
+            println!();
+            return Ok(());
+        }
+
+        let projects = client.get_projects_list(page, limit).await?;
+        let projects_table = project::get_table(&projects, page);
 
         println!("{projects_table}");
 
diff --git a/common/src/models/deployment.rs b/common/src/models/deployment.rs
index 3db9994981..fb6b9b9661 100644
--- a/common/src/models/deployment.rs
+++ b/common/src/models/deployment.rs
@@ -7,6 +7,7 @@ use comfy_table::{
 };
 use crossterm::style::Stylize;
 use serde::{Deserialize, Serialize};
+
 #[cfg(feature = "openapi")]
 use utoipa::ToSchema;
 use uuid::Uuid;
@@ -54,12 +55,19 @@ impl State {
     }
 }
 
-pub fn get_deployments_table(deployments: &Vec<Response>, service_name: &str) -> String {
+pub fn get_deployments_table(deployments: &Vec<Response>, service_name: &str, page: u32) -> String {
     if deployments.is_empty() {
-        format!(
-            "{}\n",
-            "No deployments are linked to this service".yellow().bold()
-        )
+        if page <= 1 {
+            format!(
+                "{}\n",
+                "No deployments are linked to this service".yellow().bold()
+            )
+        } else {
+            format!(
+                "{}\n",
+                "No more deployments linked to this service".yellow().bold()
+            )
+        }
     } else {
         let mut table = Table::new();
         table
@@ -92,11 +100,14 @@ pub fn get_deployments_table(deployments: &Vec<Response>, service_name: &str) ->
         format!(
             r#"
 Most recent {} for {}
+{}
+
 {}
 "#,
             "deployments".bold(),
             service_name,
             table,
+            "More projects might be available on the next page using --page.".bold()
         )
     }
 }
diff --git a/common/src/models/project.rs b/common/src/models/project.rs
index fd03c67eff..d45b13cc2c 100644
--- a/common/src/models/project.rs
+++ b/common/src/models/project.rs
@@ -6,6 +6,7 @@ use crossterm::style::Stylize;
 use serde::{Deserialize, Serialize};
 use std::fmt::{Display, Formatter};
 use strum::EnumString;
+
 #[cfg(feature = "openapi")]
 use utoipa::ToSchema;
 
@@ -184,12 +185,20 @@ pub struct AdminResponse {
     pub account_name: String,
 }
 
-pub fn get_table(projects: &Vec<Response>) -> String {
+pub fn get_table(projects: &Vec<Response>, page: u32) -> String {
     if projects.is_empty() {
-        format!(
-            "{}\n",
-            "No projects are linked to this account".yellow().bold()
-        )
+        // The page starts at 1 in the CLI.
+        if page <= 1 {
+            format!(
+                "{}\n",
+                "No projects are linked to this account".yellow().bold()
+            )
+        } else {
+            format!(
+                "{}\n",
+                "No more projects linked to this account".yellow().bold()
+            )
+        }
     } else {
         let mut table = Table::new();
         table
@@ -214,7 +223,10 @@ pub fn get_table(projects: &Vec<Response>) -> String {
             r#"
 These projects are linked to this account
 {table}
+
+{}
 "#,
+            "More projects might be available on the next page using --page.".bold()
         )
     }
 }
diff --git a/deployer/src/handlers/mod.rs b/deployer/src/handlers/mod.rs
index 15b6cc91be..1b2c82a152 100644
--- a/deployer/src/handlers/mod.rs
+++ b/deployer/src/handlers/mod.rs
@@ -12,6 +12,7 @@ use chrono::{TimeZone, Utc};
 use fqdn::FQDN;
 use futures::StreamExt;
 use hyper::Uri;
+use serde::Deserialize;
 use shuttle_common::backends::auth::{
     AdminSecretLayer, AuthPublicKey, JwtAuthenticationLayer, ScopedLayer,
 };
@@ -24,7 +25,7 @@ use shuttle_common::storage_manager::StorageManager;
 use shuttle_common::{request_span, LogItem};
 use shuttle_service::builder::clean_crate;
 use tracing::{debug, error, field, instrument, trace, warn};
-use utoipa::OpenApi;
+use utoipa::{IntoParams, OpenApi};
 
 use utoipa_swagger_ui::SwaggerUi;
 use uuid::Uuid;
@@ -73,6 +74,14 @@ mod project;
 )]
 pub struct ApiDoc;
 
+#[derive(Debug, Clone, Copy, Deserialize, IntoParams)]
+pub struct PaginationDetails {
+    /// Page to fetch, starting from 0.
+    pub page: Option<u32>,
+    /// Number of results per page.
+    pub limit: Option<u32>,
+}
+
 #[derive(Clone)]
 pub struct RouterBuilder {
     router: Router,
@@ -397,16 +406,20 @@ pub async fn stop_service(
         (status = 404, description = "Record could not be found.", body = String),
     ),
     params(
-        ("project_name" = String, Path, description = "Name of the project that owns the deployments.")
+        ("project_name" = String, Path, description = "Name of the project that owns the deployments."),
+        PaginationDetails
     )
 )]
 pub async fn get_deployments(
     Extension(persistence): Extension<Persistence>,
     Path(project_name): Path<String>,
+    Query(PaginationDetails { page, limit }): Query<PaginationDetails>,
 ) -> Result<Json<Vec<shuttle_common::models::deployment::Response>>> {
     if let Some(service) = persistence.get_service_by_name(&project_name).await? {
+        let limit = limit.unwrap_or(u32::MAX);
+        let page = page.unwrap_or(0);
         let deployments = persistence
-            .get_deployments(&service.id)
+            .get_deployments(&service.id, page * limit, limit)
             .await?
             .into_iter()
             .map(Into::into)
diff --git a/deployer/src/persistence/mod.rs b/deployer/src/persistence/mod.rs
index 51d5a5b81b..449fcd1065 100644
--- a/deployer/src/persistence/mod.rs
+++ b/deployer/src/persistence/mod.rs
@@ -11,6 +11,7 @@ use crate::deployment::deploy_layer::{self, LogRecorder, LogType};
 use crate::deployment::ActiveDeploymentsGetter;
 use crate::proxy::AddressGetter;
 use error::{Error, Result};
+use sqlx::QueryBuilder;
 
 use std::net::SocketAddr;
 use std::path::Path;
@@ -189,9 +190,25 @@ impl Persistence {
         get_deployment(&self.pool, id).await
     }
 
-    pub async fn get_deployments(&self, service_id: &Uuid) -> Result<Vec<Deployment>> {
-        sqlx::query_as("SELECT * FROM deployments WHERE service_id = ? ORDER BY last_update")
-            .bind(service_id)
+    pub async fn get_deployments(
+        &self,
+        service_id: &Uuid,
+        offset: u32,
+        limit: u32,
+    ) -> Result<Vec<Deployment>> {
+        let mut query = QueryBuilder::new("SELECT * FROM deployments WHERE service_id = ");
+
+        query
+            .push_bind(service_id)
+            .push(" ORDER BY last_update DESC LIMIT ")
+            .push_bind(limit);
+
+        if offset > 0 {
+            query.push(" OFFSET ").push_bind(offset);
+        }
+
+        query
+            .build_query_as()
             .fetch_all(&self.pool)
             .await
             .map_err(Error::from)
@@ -549,6 +566,39 @@ mod tests {
         );
     }
 
+    #[tokio::test(flavor = "multi_thread")]
+    async fn get_deployments() {
+        let (p, _) = Persistence::new_in_memory().await;
+        let service_id = add_service(&p.pool).await.unwrap();
+
+        let mut deployments: Vec<_> = (0..10)
+            .map(|_| Deployment {
+                id: Uuid::new_v4(),
+                service_id,
+                state: State::Running,
+                last_update: Utc::now(),
+                address: None,
+                is_next: false,
+            })
+            .collect();
+
+        for deployment in &deployments {
+            p.insert_deployment(deployment.clone()).await.unwrap();
+        }
+
+        // Reverse to match last_updated desc order
+        deployments.reverse();
+        assert_eq!(
+            p.get_deployments(&service_id, 0, 5).await.unwrap(),
+            deployments[0..5]
+        );
+        assert_eq!(
+            p.get_deployments(&service_id, 5, 5).await.unwrap(),
+            deployments[5..10]
+        );
+        assert_eq!(p.get_deployments(&service_id, 20, 5).await.unwrap(), vec![]);
+    }
+
     #[tokio::test(flavor = "multi_thread")]
     async fn deployment_active() {
         let (p, _) = Persistence::new_in_memory().await;
@@ -653,8 +703,8 @@ mod tests {
             p.insert_deployment(deployment.clone()).await.unwrap();
         }
 
-        let actual = p.get_deployments(&service_id).await.unwrap();
-        let expected = vec![deployment_stopped, deployment_crashed, deployment_running];
+        let actual = p.get_deployments(&service_id, 0, u32::MAX).await.unwrap();
+        let expected = vec![deployment_running, deployment_crashed, deployment_stopped];
 
         assert_eq!(actual, expected, "deployments should be sorted by time");
     }
@@ -743,20 +793,20 @@ mod tests {
         p.cleanup_invalid_states().await.unwrap();
 
         let actual: Vec<_> = p
-            .get_deployments(&service_id)
+            .get_deployments(&service_id, 0, u32::MAX)
             .await
             .unwrap()
             .into_iter()
             .map(|deployment| (deployment.id, deployment.state))
             .collect();
         let expected = vec![
-            (deployment_crashed.id, State::Crashed),
-            (deployment_stopped.id, State::Stopped),
-            (deployment_running.id, State::Running),
-            (deployment_queued.id, State::Stopped),
-            (deployment_building.id, State::Stopped),
-            (deployment_built.id, State::Stopped),
             (deployment_loading.id, State::Stopped),
+            (deployment_built.id, State::Stopped),
+            (deployment_building.id, State::Stopped),
+            (deployment_queued.id, State::Stopped),
+            (deployment_running.id, State::Running),
+            (deployment_stopped.id, State::Stopped),
+            (deployment_crashed.id, State::Crashed),
         ];
 
         assert_eq!(
diff --git a/gateway/migrations/0004_project-add-created-at.sql b/gateway/migrations/0004_project-add-created-at.sql
new file mode 100644
index 0000000000..cb7c274fd6
--- /dev/null
+++ b/gateway/migrations/0004_project-add-created-at.sql
@@ -0,0 +1,1 @@
+ALTER TABLE projects ADD COLUMN created_at INTEGER;
diff --git a/gateway/src/service.rs b/gateway/src/service.rs
index 28b22996b0..e6f47e7242 100644
--- a/gateway/src/service.rs
+++ b/gateway/src/service.rs
@@ -23,7 +23,7 @@ use sqlx::error::DatabaseError;
 use sqlx::migrate::Migrator;
 use sqlx::sqlite::SqlitePool;
 use sqlx::types::Json as SqlxJson;
-use sqlx::{query, Error as SqlxError, Row};
+use sqlx::{query, Error as SqlxError, QueryBuilder, Row};
 use tokio::sync::mpsc::Sender;
 use tracing::{debug, trace, warn, Span};
 use tracing_opentelemetry::OpenTelemetrySpanExt;
@@ -271,20 +271,34 @@ impl GatewayService {
 
     pub async fn iter_user_projects_detailed(
         &self,
-        account_name: AccountName,
+        account_name: &AccountName,
+        offset: u32,
+        limit: u32,
     ) -> Result<impl Iterator<Item = (ProjectName, Project)>, Error> {
-        let iter =
-            query("SELECT project_name, project_state FROM projects WHERE account_name = ?1")
-                .bind(account_name)
-                .fetch_all(&self.db)
-                .await?
-                .into_iter()
-                .map(|row| {
-                    (
-                        row.get("project_name"),
-                        row.get::<SqlxJson<Project>, _>("project_state").0,
-                    )
-                });
+        let mut query = QueryBuilder::new(
+            "SELECT project_name, project_state FROM projects WHERE account_name = ",
+        );
+
+        query
+            .push_bind(account_name)
+            .push(" ORDER BY created_at DESC NULLS LAST, project_name LIMIT ")
+            .push_bind(limit);
+
+        if offset > 0 {
+            query.push(" OFFSET ").push_bind(offset);
+        }
+
+        let iter = query
+            .build()
+            .fetch_all(&self.db)
+            .await?
+            .into_iter()
+            .map(|row| {
+                (
+                    row.get("project_name"),
+                    row.get::<SqlxJson<Project>, _>("project_state").0,
+                )
+            });
         Ok(iter)
     }
 
@@ -376,9 +390,9 @@ impl GatewayService {
     ) -> Result<Project, Error> {
         if let Some(row) = query(
             r#"
-        SELECT project_name, account_name, initial_key, project_state 
-        FROM projects 
-        WHERE (project_name = ?1) 
+        SELECT project_name, account_name, initial_key, project_state
+        FROM projects
+        WHERE (project_name = ?1)
         AND (account_name = ?2 OR ?3)
         "#,
         )
@@ -440,7 +454,7 @@ impl GatewayService {
             ProjectCreating::new_with_random_initial_key(project_name.clone(), idle_minutes),
         ));
 
-        query("INSERT INTO projects (project_name, account_name, initial_key, project_state) VALUES (?1, ?2, ?3, ?4)")
+        query("INSERT INTO projects (project_name, account_name, initial_key, project_state, created_at) VALUES (?1, ?2, ?3, ?4, CURRENT_TIMESTAMP)")
             .bind(&project_name)
             .bind(&account_name)
             .bind(project.initial_key().unwrap())
@@ -784,7 +798,7 @@ pub mod tests {
             }
         );
         assert_eq!(
-            svc.iter_user_projects_detailed(neo.clone())
+            svc.iter_user_projects_detailed(&neo, 0, u32::MAX)
                 .await
                 .unwrap()
                 .map(|item| item.0)
@@ -792,6 +806,49 @@ pub mod tests {
             vec![matrix.clone()]
         );
 
+        let mut all_projects: Vec<ProjectName> = (1..60)
+            .map(|p| ProjectName(format!("matrix-{p}")))
+            .collect();
+        for p in &all_projects {
+            svc.create_project(p.clone(), neo.clone(), false, 0)
+                .await
+                .unwrap();
+        }
+        all_projects.insert(0, matrix.clone());
+
+        assert_eq!(
+            svc.iter_user_projects_detailed(&neo, 0, u32::MAX)
+                .await
+                .unwrap()
+                .map(|item| item.0)
+                .collect::<Vec<_>>(),
+            all_projects
+        );
+        assert_eq!(
+            svc.iter_user_projects_detailed(&neo, 0, 20)
+                .await
+                .unwrap()
+                .map(|item| item.0)
+                .collect::<Vec<_>>(),
+            all_projects[..20]
+        );
+        assert_eq!(
+            svc.iter_user_projects_detailed(&neo, 20, 20)
+                .await
+                .unwrap()
+                .map(|item| item.0)
+                .collect::<Vec<_>>(),
+            all_projects[20..40]
+        );
+        assert_eq!(
+            svc.iter_user_projects_detailed(&neo, 200, 20)
+                .await
+                .unwrap()
+                .map(|item| item.0)
+                .collect::<Vec<_>>(),
+            vec![]
+        );
+
         // assert_eq!(
         //     svc.iter_user_projects_detailed_filtered(neo.clone(), "ready".to_string())
         //         .await