//! Low-level Postgres CRUD over `vars` — the write/admin side of the //! Phase-3 config layer (the read/resolution side lives in //! `config_resolver`). Storage-only: it upserts and lists an owner's OWN //! rows. Authorization, env-scope validation, and value encoding live one //! layer up in `vars_api`. //! //! A var is owned by exactly one group OR one app (the migration's //! `vars_owner_exactly_one` CHECK). Because the owner is split across two //! nullable columns (`group_id`, `app_id`), the upsert writes //! owner-kind-specific SQL with the matching partial-unique conflict //! target. use async_trait::async_trait; use chrono::{DateTime, Utc}; use picloud_shared::{AppId, GroupId}; use serde_json::Value as JsonValue; use sqlx::PgPool; #[derive(Debug, thiserror::Error)] pub enum VarsRepoError { #[error("database error: {0}")] Db(#[from] sqlx::Error), } /// Which side of the polymorphic owner a var hangs off. The repo chooses /// the conflict target (group vs app partial-unique index) from this. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum VarOwner { Group(GroupId), App(AppId), } /// One of an owner's OWN var rows (NOT a resolved/inherited value). Backs /// the admin list surface. #[derive(Debug, Clone)] pub struct VarRow { pub environment_scope: String, pub key: String, pub value: JsonValue, pub is_tombstone: bool, pub updated_at: DateTime, } /// Repo surface. A trait so service/handler tests can substitute an /// in-memory backing without Postgres. #[async_trait] pub trait VarsRepo: Send + Sync { /// Upsert one (owner, env_scope, key) row. async fn set( &self, owner: VarOwner, env_scope: &str, key: &str, value: &JsonValue, is_tombstone: bool, ) -> Result<(), VarsRepoError>; /// Delete one (owner, env_scope, key) row; returns whether a row was /// present. async fn delete( &self, owner: VarOwner, env_scope: &str, key: &str, ) -> Result; /// The owner's OWN rows only (NOT resolved/inherited), ordered by /// (key, environment_scope). async fn list_for_owner(&self, owner: VarOwner) -> Result, VarsRepoError>; } pub struct PostgresVarsRepo { pool: PgPool, } impl PostgresVarsRepo { #[must_use] pub fn new(pool: PgPool) -> Self { Self { pool } } } #[async_trait] impl VarsRepo for PostgresVarsRepo { async fn set( &self, owner: VarOwner, env_scope: &str, key: &str, value: &JsonValue, is_tombstone: bool, ) -> Result<(), VarsRepoError> { // Owner-kind-specific SQL: only one of the two nullable owner // columns is written, and the conflict target is the matching // partial-unique index. match owner { VarOwner::Group(g) => { sqlx::query( // The conflict target is a PARTIAL unique index, so the // index predicate (`WHERE group_id IS NOT NULL`) must be // restated for Postgres to infer the arbiter. "INSERT INTO vars (group_id, environment_scope, key, value, is_tombstone) \ VALUES ($1, $2, $3, $4, $5) \ ON CONFLICT (group_id, environment_scope, key) \ WHERE group_id IS NOT NULL DO UPDATE \ SET value = EXCLUDED.value, \ is_tombstone = EXCLUDED.is_tombstone, \ updated_at = NOW()", ) .bind(g.into_inner()) .bind(env_scope) .bind(key) .bind(value) .bind(is_tombstone) .execute(&self.pool) .await?; } VarOwner::App(a) => { sqlx::query( // Partial-index conflict target — restate the predicate. "INSERT INTO vars (app_id, environment_scope, key, value, is_tombstone) \ VALUES ($1, $2, $3, $4, $5) \ ON CONFLICT (app_id, environment_scope, key) \ WHERE app_id IS NOT NULL DO UPDATE \ SET value = EXCLUDED.value, \ is_tombstone = EXCLUDED.is_tombstone, \ updated_at = NOW()", ) .bind(a.into_inner()) .bind(env_scope) .bind(key) .bind(value) .bind(is_tombstone) .execute(&self.pool) .await?; } } Ok(()) } async fn delete( &self, owner: VarOwner, env_scope: &str, key: &str, ) -> Result { let res = match owner { VarOwner::Group(g) => { sqlx::query( "DELETE FROM vars \ WHERE group_id = $1 AND environment_scope = $2 AND key = $3", ) .bind(g.into_inner()) .bind(env_scope) .bind(key) .execute(&self.pool) .await? } VarOwner::App(a) => { sqlx::query( "DELETE FROM vars \ WHERE app_id = $1 AND environment_scope = $2 AND key = $3", ) .bind(a.into_inner()) .bind(env_scope) .bind(key) .execute(&self.pool) .await? } }; Ok(res.rows_affected() > 0) } async fn list_for_owner(&self, owner: VarOwner) -> Result, VarsRepoError> { let rows: Vec<(String, String, JsonValue, bool, DateTime)> = match owner { VarOwner::Group(g) => { sqlx::query_as( "SELECT environment_scope, key, value, is_tombstone, updated_at \ FROM vars WHERE group_id = $1 \ ORDER BY key ASC, environment_scope ASC", ) .bind(g.into_inner()) .fetch_all(&self.pool) .await? } VarOwner::App(a) => { sqlx::query_as( "SELECT environment_scope, key, value, is_tombstone, updated_at \ FROM vars WHERE app_id = $1 \ ORDER BY key ASC, environment_scope ASC", ) .bind(a.into_inner()) .fetch_all(&self.pool) .await? } }; Ok(rows .into_iter() .map( |(environment_scope, key, value, is_tombstone, updated_at)| VarRow { environment_scope, key, value, is_tombstone, updated_at, }, ) .collect()) } }