//! CRUD over the `app_domains` table. //! //! Parsing + shape_key derivation live in `orchestrator-core`'s //! `routing::pattern::parse_app_domain` — this repo just stores what //! the API handler hands it. Same-shape collisions surface as a unique //! constraint violation on `shape_key`, mapped here to a clean error. use async_trait::async_trait; use picloud_shared::{AppDomain, AppId, DomainShape}; use sqlx::PgPool; use uuid::Uuid; use crate::repo::ScriptRepositoryError; #[derive(Debug, Clone)] pub struct NewAppDomain { pub app_id: AppId, pub pattern: String, pub shape: DomainShape, pub shape_key: String, } #[async_trait] pub trait AppDomainRepository: Send + Sync { /// All domain claims across all apps — used by the orchestrator's /// `AppDomainTable` to build its lookup cache at startup and after /// every write. async fn list_all(&self) -> Result, ScriptRepositoryError>; async fn list_for_app(&self, app_id: AppId) -> Result, ScriptRepositoryError>; async fn get(&self, domain_id: Uuid) -> Result, ScriptRepositoryError>; async fn create(&self, input: NewAppDomain) -> Result; async fn delete(&self, domain_id: Uuid) -> Result<(), ScriptRepositoryError>; } pub struct PostgresAppDomainRepository { pool: PgPool, } impl PostgresAppDomainRepository { #[must_use] pub fn new(pool: PgPool) -> Self { Self { pool } } } #[async_trait] impl AppDomainRepository for PostgresAppDomainRepository { async fn list_all(&self) -> Result, ScriptRepositoryError> { let rows = sqlx::query_as::<_, DomainRow>( "SELECT id, app_id, pattern, shape, shape_key, created_at \ FROM app_domains ORDER BY pattern", ) .fetch_all(&self.pool) .await?; Ok(rows.into_iter().map(Into::into).collect()) } async fn list_for_app(&self, app_id: AppId) -> Result, ScriptRepositoryError> { let rows = sqlx::query_as::<_, DomainRow>( "SELECT id, app_id, pattern, shape, shape_key, created_at \ FROM app_domains WHERE app_id = $1 ORDER BY pattern", ) .bind(app_id.into_inner()) .fetch_all(&self.pool) .await?; Ok(rows.into_iter().map(Into::into).collect()) } async fn get(&self, domain_id: Uuid) -> Result, ScriptRepositoryError> { let row = sqlx::query_as::<_, DomainRow>( "SELECT id, app_id, pattern, shape, shape_key, created_at \ FROM app_domains WHERE id = $1", ) .bind(domain_id) .fetch_optional(&self.pool) .await?; Ok(row.map(Into::into)) } async fn create(&self, input: NewAppDomain) -> Result { let res = sqlx::query_as::<_, DomainRow>( "INSERT INTO app_domains (app_id, pattern, shape, shape_key) \ VALUES ($1, $2, $3, $4) \ RETURNING id, app_id, pattern, shape, shape_key, created_at", ) .bind(input.app_id.into_inner()) .bind(&input.pattern) .bind(shape_str(input.shape)) .bind(&input.shape_key) .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!( "domain {:?} (or another claim of the same shape) is already claimed", input.pattern ))) } Err(e) => Err(e.into()), } } async fn delete(&self, domain_id: Uuid) -> Result<(), ScriptRepositoryError> { let res = sqlx::query("DELETE FROM app_domains WHERE id = $1") .bind(domain_id) .execute(&self.pool) .await?; if res.rows_affected() == 0 { return Err(ScriptRepositoryError::Conflict(format!( "domain {domain_id} not found" ))); } Ok(()) } } const fn shape_str(s: DomainShape) -> &'static str { match s { DomainShape::Exact => "exact", DomainShape::Wildcard => "wildcard", DomainShape::Parameterized => "parameterized", } } #[derive(sqlx::FromRow)] struct DomainRow { id: Uuid, app_id: Uuid, pattern: String, shape: String, shape_key: String, created_at: chrono::DateTime, } impl From for AppDomain { fn from(r: DomainRow) -> Self { Self { id: r.id, app_id: r.app_id.into(), pattern: r.pattern, shape: match r.shape.as_str() { "wildcard" => DomainShape::Wildcard, "parameterized" => DomainShape::Parameterized, _ => DomainShape::Exact, }, shape_key: r.shape_key, created_at: r.created_at, } } }