//! CRUD over the `apps` and `app_slug_history` tables. //! //! Slug validation (regex, reserved-word check) lives in the API //! handler; this repo enforces only what Postgres enforces (uniqueness, //! FK). The slug-rename flow is exposed as a single `rename_slug` call //! that writes the history row in the same transaction. use async_trait::async_trait; use picloud_shared::{App, AppId}; use sqlx::PgPool; use crate::repo::ScriptRepositoryError; /// Result of looking up an app by slug or via the redirect history. #[derive(Debug, Clone)] pub struct AppLookup { pub app: App, /// `true` when the slug was found in `app_slug_history` rather than /// directly on `apps`. Dashboards should issue a redirect. pub redirected: bool, } #[async_trait] pub trait AppRepository: Send + Sync { async fn list(&self) -> Result, ScriptRepositoryError>; async fn get_by_id(&self, id: AppId) -> Result, ScriptRepositoryError>; async fn get_by_slug(&self, slug: &str) -> Result, ScriptRepositoryError>; async fn get_by_slug_or_history( &self, slug: &str, ) -> Result, ScriptRepositoryError>; async fn slug_in_history(&self, slug: &str) -> Result, ScriptRepositoryError>; async fn create( &self, slug: &str, name: &str, description: Option<&str>, ) -> Result; /// Create that also consumes a matching `app_slug_history` row, if /// any. Used after the operator has confirmed they want to break old /// redirects. async fn create_with_takeover( &self, slug: &str, name: &str, description: Option<&str>, ) -> Result; async fn update( &self, id: AppId, name: Option<&str>, description: Option>, ) -> Result; /// Rename and record the old slug in `app_slug_history` (so /// retired URLs keep redirecting). If `take_over_history` is true, /// any existing history row for `new_slug` is consumed. async fn rename_slug( &self, id: AppId, new_slug: &str, take_over_history: bool, ) -> Result; async fn delete(&self, id: AppId) -> Result<(), ScriptRepositoryError>; /// Delete the app along with all its scripts (which in turn cascades /// routes and execution logs via their `script_id` FK). Domains and /// app-slug-history rows cascade off the app row itself. Runs in a /// single transaction so a partial delete cannot be observed. async fn delete_cascade(&self, id: AppId) -> Result<(), ScriptRepositoryError>; async fn count_scripts_in_app(&self, id: AppId) -> Result; } pub struct PostgresAppRepository { pool: PgPool, } impl PostgresAppRepository { #[must_use] pub fn new(pool: PgPool) -> Self { Self { pool } } } #[async_trait] impl AppRepository for PostgresAppRepository { async fn list(&self) -> Result, ScriptRepositoryError> { let rows = sqlx::query_as::<_, AppRow>( "SELECT id, slug, name, description, created_at, updated_at \ FROM apps ORDER BY name", ) .fetch_all(&self.pool) .await?; Ok(rows.into_iter().map(Into::into).collect()) } async fn get_by_id(&self, id: AppId) -> Result, ScriptRepositoryError> { let row = sqlx::query_as::<_, AppRow>( "SELECT id, slug, name, description, created_at, updated_at \ FROM apps WHERE id = $1", ) .bind(id.into_inner()) .fetch_optional(&self.pool) .await?; Ok(row.map(Into::into)) } async fn get_by_slug(&self, slug: &str) -> Result, ScriptRepositoryError> { let row = sqlx::query_as::<_, AppRow>( "SELECT id, slug, name, description, created_at, updated_at \ FROM apps WHERE slug = $1", ) .bind(slug) .fetch_optional(&self.pool) .await?; Ok(row.map(Into::into)) } async fn get_by_slug_or_history( &self, slug: &str, ) -> Result, ScriptRepositoryError> { if let Some(app) = self.get_by_slug(slug).await? { return Ok(Some(AppLookup { app, redirected: false, })); } if let Some(app) = self.slug_in_history(slug).await? { return Ok(Some(AppLookup { app, redirected: true, })); } Ok(None) } async fn slug_in_history(&self, slug: &str) -> Result, ScriptRepositoryError> { let row = sqlx::query_as::<_, AppRow>( "SELECT a.id, a.slug, a.name, a.description, a.created_at, a.updated_at \ FROM app_slug_history h \ JOIN apps a ON a.id = h.current_app_id \ WHERE h.slug = $1", ) .bind(slug) .fetch_optional(&self.pool) .await?; Ok(row.map(Into::into)) } async fn create( &self, slug: &str, name: &str, description: Option<&str>, ) -> Result { let res = sqlx::query_as::<_, AppRow>( "INSERT INTO apps (slug, name, description) \ VALUES ($1, $2, $3) \ RETURNING id, slug, name, description, created_at, updated_at", ) .bind(slug) .bind(name) .bind(description) .fetch_one(&self.pool) .await; match res { Ok(row) => Ok(row.into()), Err(sqlx::Error::Database(e)) if e.is_unique_violation() => Err( ScriptRepositoryError::Conflict(format!("slug {slug:?} is already in use")), ), Err(e) => Err(e.into()), } } async fn create_with_takeover( &self, slug: &str, name: &str, description: Option<&str>, ) -> Result { let mut tx = self.pool.begin().await?; sqlx::query("DELETE FROM app_slug_history WHERE slug = $1") .bind(slug) .execute(&mut *tx) .await?; let row = sqlx::query_as::<_, AppRow>( "INSERT INTO apps (slug, name, description) \ VALUES ($1, $2, $3) \ RETURNING id, slug, name, description, created_at, updated_at", ) .bind(slug) .bind(name) .bind(description) .fetch_one(&mut *tx) .await; let row = match row { Ok(r) => r, Err(sqlx::Error::Database(e)) if e.is_unique_violation() => { return Err(ScriptRepositoryError::Conflict(format!( "slug {slug:?} is already in use" ))); } Err(e) => return Err(e.into()), }; tx.commit().await?; Ok(row.into()) } async fn update( &self, id: AppId, name: Option<&str>, description: Option>, ) -> Result { let row = sqlx::query_as::<_, AppRow>( "UPDATE apps SET \ name = COALESCE($2, name), \ description = CASE WHEN $3::bool THEN $4 ELSE description END, \ updated_at = NOW() \ WHERE id = $1 \ RETURNING id, slug, name, description, created_at, updated_at", ) .bind(id.into_inner()) .bind(name) .bind(description.is_some()) .bind(description.and_then(|d| d)) .fetch_optional(&self.pool) .await?; row.map(Into::into) .ok_or_else(|| ScriptRepositoryError::Conflict(format!("app {id} not found"))) } async fn rename_slug( &self, id: AppId, new_slug: &str, take_over_history: bool, ) -> Result { let mut tx = self.pool.begin().await?; // 1. Read the current slug (so we can record it in history). let current: Option<(String,)> = sqlx::query_as("SELECT slug FROM apps WHERE id = $1") .bind(id.into_inner()) .fetch_optional(&mut *tx) .await?; let Some((current_slug,)) = current else { return Err(ScriptRepositoryError::Conflict(format!( "app {id} not found" ))); }; if current_slug == new_slug { // No-op rename; just return the row. let row = sqlx::query_as::<_, AppRow>( "SELECT id, slug, name, description, created_at, updated_at \ FROM apps WHERE id = $1", ) .bind(id.into_inner()) .fetch_one(&mut *tx) .await?; tx.commit().await?; return Ok(row.into()); } // 2. If renaming back to this app's own retired slug, just // consume the history row silently (no warning, no takeover // flag required). let owns_history: Option<(uuid::Uuid,)> = sqlx::query_as("SELECT current_app_id FROM app_slug_history WHERE slug = $1") .bind(new_slug) .fetch_optional(&mut *tx) .await?; match owns_history { Some((owner,)) if owner == id.into_inner() => { sqlx::query("DELETE FROM app_slug_history WHERE slug = $1") .bind(new_slug) .execute(&mut *tx) .await?; } Some(_) if take_over_history => { sqlx::query("DELETE FROM app_slug_history WHERE slug = $1") .bind(new_slug) .execute(&mut *tx) .await?; } Some(_) => { return Err(ScriptRepositoryError::Conflict(format!( "slug {new_slug:?} is in history; rename with takeover to claim it" ))); } None => {} } // 3. Record the current slug in history (replacing any older // entry — the same slug can pass through history multiple // times across many renames). sqlx::query( "INSERT INTO app_slug_history (slug, current_app_id) \ VALUES ($1, $2) \ ON CONFLICT (slug) DO UPDATE SET current_app_id = EXCLUDED.current_app_id, \ retired_at = NOW()", ) .bind(¤t_slug) .bind(id.into_inner()) .execute(&mut *tx) .await?; // 4. Apply the rename. Unique violation = another live app // already holds this slug. let row = sqlx::query_as::<_, AppRow>( "UPDATE apps SET slug = $2, updated_at = NOW() \ WHERE id = $1 \ RETURNING id, slug, name, description, created_at, updated_at", ) .bind(id.into_inner()) .bind(new_slug) .fetch_one(&mut *tx) .await; let row = match row { Ok(r) => r, Err(sqlx::Error::Database(e)) if e.is_unique_violation() => { return Err(ScriptRepositoryError::Conflict(format!( "slug {new_slug:?} is already in use by another app" ))); } Err(e) => return Err(e.into()), }; tx.commit().await?; Ok(row.into()) } async fn delete(&self, id: AppId) -> Result<(), ScriptRepositoryError> { let res = sqlx::query("DELETE FROM apps WHERE id = $1") .bind(id.into_inner()) .execute(&self.pool) .await; match res { Ok(r) if r.rows_affected() == 0 => Err(ScriptRepositoryError::Conflict(format!( "app {id} not found" ))), Ok(_) => Ok(()), Err(sqlx::Error::Database(e)) if e.is_foreign_key_violation() => { // ON DELETE RESTRICT on scripts.app_id — surface a clean // "has dependents" error rather than a raw SQL message. Err(ScriptRepositoryError::Conflict( "app still contains scripts; delete or move them first".into(), )) } Err(e) => Err(e.into()), } } async fn delete_cascade(&self, id: AppId) -> Result<(), ScriptRepositoryError> { let mut tx = self.pool.begin().await?; sqlx::query("DELETE FROM scripts WHERE app_id = $1") .bind(id.into_inner()) .execute(&mut *tx) .await?; let res = sqlx::query("DELETE FROM apps WHERE id = $1") .bind(id.into_inner()) .execute(&mut *tx) .await?; if res.rows_affected() == 0 { return Err(ScriptRepositoryError::Conflict(format!( "app {id} not found" ))); } tx.commit().await?; Ok(()) } async fn count_scripts_in_app(&self, id: AppId) -> Result { let count: (i64,) = sqlx::query_as("SELECT COUNT(*) FROM scripts WHERE app_id = $1") .bind(id.into_inner()) .fetch_one(&self.pool) .await?; Ok(count.0) } } #[derive(sqlx::FromRow)] struct AppRow { id: uuid::Uuid, slug: String, name: String, description: Option, created_at: chrono::DateTime, updated_at: chrono::DateTime, } impl From for App { fn from(r: AppRow) -> Self { Self { id: r.id.into(), slug: r.slug, name: r.name, description: r.description, created_at: r.created_at, updated_at: r.updated_at, } } }