feat(vars): admin CRUD API + pic vars CLI
Completes the vars half of Phase 3 end-to-end:
- vars_repo: VarOwner{Group|App} upsert/delete/list (owner-kind-specific
SQL; ON CONFLICT restates the partial-index predicate).
- vars_api: GET/PUT/DELETE under /apps/{id}/vars and /groups/{id}/vars,
resolve-then-require gated on App/GroupVars{Read,Write}, secrets-style
error mapping + key/env-scope validation.
- pic vars ls/set/rm (--group|--app, --env, --json, --tombstone).
- journey test: a group var is inherited by a descendant app's
vars::get(), and an app-level value overrides it (proximity) — green.
386 manager-core lib tests + the vars journey pass; clippy clean.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -85,6 +85,8 @@ pub mod trigger_repo;
|
||||
pub mod triggers_api;
|
||||
pub mod users_admin_api;
|
||||
pub mod users_service;
|
||||
pub mod vars_api;
|
||||
pub mod vars_repo;
|
||||
pub mod vars_service;
|
||||
|
||||
pub use abandoned_repo::{
|
||||
@@ -221,4 +223,6 @@ pub use trigger_repo::{
|
||||
pub use triggers_api::{triggers_router, TriggersApiError, TriggersState};
|
||||
pub use users_admin_api::{app_users_router, AppUsersApiError, AppUsersState};
|
||||
pub use users_service::{UsersServiceConfig, UsersServiceImpl};
|
||||
pub use vars_api::{vars_router, VarsApiError, VarsApiState};
|
||||
pub use vars_repo::{PostgresVarsRepo, VarOwner, VarRow, VarsRepo, VarsRepoError};
|
||||
pub use vars_service::VarsServiceImpl;
|
||||
|
||||
410
crates/manager-core/src/vars_api.rs
Normal file
410
crates/manager-core/src/vars_api.rs
Normal file
@@ -0,0 +1,410 @@
|
||||
//! `/api/v1/admin/{apps,groups}/{id_or_slug}/vars*` — the Phase-3 config
|
||||
//! `vars` admin surface (write/list side; resolution lives in
|
||||
//! `config_resolver` + the `vars::` SDK).
|
||||
//!
|
||||
//! * `GET /apps/{id}/vars` — list the app's OWN vars.
|
||||
//! * `PUT /apps/{id}/vars` — set/overwrite one app var.
|
||||
//! * `DELETE /apps/{id}/vars/{key}` — delete one app var.
|
||||
//! * `GET/PUT/DELETE /groups/{id}/vars[...]` — same, group-owned.
|
||||
//!
|
||||
//! App routes gate on `App{Vars}Read/Write`; group routes on
|
||||
//! `Group{Vars}Read/Write`. The owner is resolved FIRST (slug-or-uuid),
|
||||
//! THEN `authz::require` binds the capability to the resolved owner id —
|
||||
//! never to a caller-controlled path param. Listing returns the owner's
|
||||
//! OWN rows only (not the resolved/inherited view).
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use axum::extract::{Path, Query, State};
|
||||
use axum::http::StatusCode;
|
||||
use axum::response::{IntoResponse, Json, Response};
|
||||
use axum::routing::{get, put};
|
||||
use axum::{Extension, Router};
|
||||
use picloud_shared::{AppId, GroupId, Principal};
|
||||
use serde::Deserialize;
|
||||
use serde_json::json;
|
||||
|
||||
use crate::app_repo::AppRepository;
|
||||
use crate::authz::{require, AuthzDenied, AuthzError, AuthzRepo, Capability};
|
||||
use crate::group_repo::GroupRepository;
|
||||
use crate::vars_repo::{VarOwner, VarsRepo, VarsRepoError};
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct VarsApiState {
|
||||
pub vars: Arc<dyn VarsRepo>,
|
||||
pub apps: Arc<dyn AppRepository>,
|
||||
pub groups: Arc<dyn GroupRepository>,
|
||||
pub authz: Arc<dyn AuthzRepo>,
|
||||
}
|
||||
|
||||
pub fn vars_router(state: VarsApiState) -> Router {
|
||||
Router::new()
|
||||
.route(
|
||||
"/apps/{id_or_slug}/vars",
|
||||
get(list_app_vars).put(set_app_var),
|
||||
)
|
||||
.route(
|
||||
"/apps/{id_or_slug}/vars/{key}",
|
||||
axum::routing::delete(delete_app_var),
|
||||
)
|
||||
.route(
|
||||
"/groups/{id_or_slug}/vars",
|
||||
put(set_group_var).get(list_group_vars),
|
||||
)
|
||||
.route(
|
||||
"/groups/{id_or_slug}/vars/{key}",
|
||||
axum::routing::delete(delete_group_var),
|
||||
)
|
||||
.with_state(state)
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// DTOs
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct SetVarRequest {
|
||||
pub key: String,
|
||||
pub value: serde_json::Value,
|
||||
/// Environment scope — `*` (env-agnostic, default) or a concrete env
|
||||
/// name matched against `apps.environment` at resolution time.
|
||||
#[serde(default)]
|
||||
pub env: Option<String>,
|
||||
/// Write a tombstone (suppresses an inherited key) instead of a real
|
||||
/// value. The body's `value` is ignored for a tombstone.
|
||||
#[serde(default)]
|
||||
pub tombstone: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct EnvQuery {
|
||||
#[serde(default)]
|
||||
pub env: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, serde::Serialize)]
|
||||
struct VarItem {
|
||||
key: String,
|
||||
env: String,
|
||||
value: serde_json::Value,
|
||||
is_tombstone: bool,
|
||||
updated_at: chrono::DateTime<chrono::Utc>,
|
||||
}
|
||||
|
||||
#[derive(Debug, serde::Serialize)]
|
||||
struct ListVarsResponse {
|
||||
vars: Vec<VarItem>,
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// App handlers
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
async fn list_app_vars(
|
||||
State(s): State<VarsApiState>,
|
||||
Extension(principal): Extension<Principal>,
|
||||
Path(id_or_slug): Path<String>,
|
||||
) -> Result<Json<ListVarsResponse>, VarsApiError> {
|
||||
let app_id = resolve_app(&*s.apps, &id_or_slug).await?;
|
||||
require(
|
||||
s.authz.as_ref(),
|
||||
&principal,
|
||||
Capability::AppVarsRead(app_id),
|
||||
)
|
||||
.await?;
|
||||
list(&*s.vars, VarOwner::App(app_id)).await
|
||||
}
|
||||
|
||||
async fn set_app_var(
|
||||
State(s): State<VarsApiState>,
|
||||
Extension(principal): Extension<Principal>,
|
||||
Path(id_or_slug): Path<String>,
|
||||
Json(input): Json<SetVarRequest>,
|
||||
) -> Result<StatusCode, VarsApiError> {
|
||||
let app_id = resolve_app(&*s.apps, &id_or_slug).await?;
|
||||
require(
|
||||
s.authz.as_ref(),
|
||||
&principal,
|
||||
Capability::AppVarsWrite(app_id),
|
||||
)
|
||||
.await?;
|
||||
set(&*s.vars, VarOwner::App(app_id), input).await
|
||||
}
|
||||
|
||||
async fn delete_app_var(
|
||||
State(s): State<VarsApiState>,
|
||||
Extension(principal): Extension<Principal>,
|
||||
Path((id_or_slug, key)): Path<(String, String)>,
|
||||
Query(q): Query<EnvQuery>,
|
||||
) -> Result<StatusCode, VarsApiError> {
|
||||
let app_id = resolve_app(&*s.apps, &id_or_slug).await?;
|
||||
require(
|
||||
s.authz.as_ref(),
|
||||
&principal,
|
||||
Capability::AppVarsWrite(app_id),
|
||||
)
|
||||
.await?;
|
||||
delete(&*s.vars, VarOwner::App(app_id), &key, q.env.as_deref()).await
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// Group handlers
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
async fn list_group_vars(
|
||||
State(s): State<VarsApiState>,
|
||||
Extension(principal): Extension<Principal>,
|
||||
Path(id_or_slug): Path<String>,
|
||||
) -> Result<Json<ListVarsResponse>, VarsApiError> {
|
||||
let group_id = resolve_group(&*s.groups, &id_or_slug).await?;
|
||||
require(
|
||||
s.authz.as_ref(),
|
||||
&principal,
|
||||
Capability::GroupVarsRead(group_id),
|
||||
)
|
||||
.await?;
|
||||
list(&*s.vars, VarOwner::Group(group_id)).await
|
||||
}
|
||||
|
||||
async fn set_group_var(
|
||||
State(s): State<VarsApiState>,
|
||||
Extension(principal): Extension<Principal>,
|
||||
Path(id_or_slug): Path<String>,
|
||||
Json(input): Json<SetVarRequest>,
|
||||
) -> Result<StatusCode, VarsApiError> {
|
||||
let group_id = resolve_group(&*s.groups, &id_or_slug).await?;
|
||||
require(
|
||||
s.authz.as_ref(),
|
||||
&principal,
|
||||
Capability::GroupVarsWrite(group_id),
|
||||
)
|
||||
.await?;
|
||||
set(&*s.vars, VarOwner::Group(group_id), input).await
|
||||
}
|
||||
|
||||
async fn delete_group_var(
|
||||
State(s): State<VarsApiState>,
|
||||
Extension(principal): Extension<Principal>,
|
||||
Path((id_or_slug, key)): Path<(String, String)>,
|
||||
Query(q): Query<EnvQuery>,
|
||||
) -> Result<StatusCode, VarsApiError> {
|
||||
let group_id = resolve_group(&*s.groups, &id_or_slug).await?;
|
||||
require(
|
||||
s.authz.as_ref(),
|
||||
&principal,
|
||||
Capability::GroupVarsWrite(group_id),
|
||||
)
|
||||
.await?;
|
||||
delete(&*s.vars, VarOwner::Group(group_id), &key, q.env.as_deref()).await
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// Shared owner-generic bodies
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
async fn list(
|
||||
vars: &dyn VarsRepo,
|
||||
owner: VarOwner,
|
||||
) -> Result<Json<ListVarsResponse>, VarsApiError> {
|
||||
let rows = vars.list_for_owner(owner).await?;
|
||||
Ok(Json(ListVarsResponse {
|
||||
vars: rows
|
||||
.into_iter()
|
||||
.map(|r| VarItem {
|
||||
key: r.key,
|
||||
env: r.environment_scope,
|
||||
value: r.value,
|
||||
is_tombstone: r.is_tombstone,
|
||||
updated_at: r.updated_at,
|
||||
})
|
||||
.collect(),
|
||||
}))
|
||||
}
|
||||
|
||||
async fn set(
|
||||
vars: &dyn VarsRepo,
|
||||
owner: VarOwner,
|
||||
input: SetVarRequest,
|
||||
) -> Result<StatusCode, VarsApiError> {
|
||||
validate_key(&input.key)?;
|
||||
let env = input.env.as_deref().unwrap_or("*");
|
||||
validate_env_scope(env)?;
|
||||
// A tombstone carries no meaningful value (the resolver suppresses the
|
||||
// key regardless); store JSON null so the NOT NULL column is satisfied.
|
||||
let value = if input.tombstone {
|
||||
serde_json::Value::Null
|
||||
} else {
|
||||
input.value
|
||||
};
|
||||
vars.set(owner, env, &input.key, &value, input.tombstone)
|
||||
.await?;
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
|
||||
async fn delete(
|
||||
vars: &dyn VarsRepo,
|
||||
owner: VarOwner,
|
||||
key: &str,
|
||||
env: Option<&str>,
|
||||
) -> Result<StatusCode, VarsApiError> {
|
||||
let env = env.unwrap_or("*");
|
||||
validate_env_scope(env)?;
|
||||
if !vars.delete(owner, env, key).await? {
|
||||
return Err(VarsApiError::NotFound);
|
||||
}
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// Resolution + validation
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
async fn resolve_app(apps: &dyn AppRepository, ident: &str) -> Result<AppId, VarsApiError> {
|
||||
crate::app_repo::resolve_app(apps, ident)
|
||||
.await
|
||||
.map_err(|e| VarsApiError::Backend(e.to_string()))?
|
||||
.map(|l| l.app.id)
|
||||
.ok_or(VarsApiError::AppNotFound)
|
||||
}
|
||||
|
||||
async fn resolve_group(groups: &dyn GroupRepository, ident: &str) -> Result<GroupId, VarsApiError> {
|
||||
let found = if let Ok(uuid) = ident.parse::<uuid::Uuid>() {
|
||||
groups
|
||||
.get_by_id(uuid.into())
|
||||
.await
|
||||
.map_err(|e| VarsApiError::Backend(e.to_string()))?
|
||||
} else {
|
||||
groups
|
||||
.get_by_slug(ident)
|
||||
.await
|
||||
.map_err(|e| VarsApiError::Backend(e.to_string()))?
|
||||
};
|
||||
found.map(|g| g.id).ok_or(VarsApiError::GroupNotFound)
|
||||
}
|
||||
|
||||
/// Keys are kebab identifiers (`^[a-z0-9][a-z0-9-]*$`) — same shape as the
|
||||
/// manifest's var names (docs/design §4.3).
|
||||
fn validate_key(key: &str) -> Result<(), VarsApiError> {
|
||||
if key.is_empty() || key.len() > 128 {
|
||||
return Err(VarsApiError::Invalid("key must be 1–128 characters".into()));
|
||||
}
|
||||
let mut chars = key.chars();
|
||||
let first = chars.next().unwrap();
|
||||
if !(first.is_ascii_lowercase() || first.is_ascii_digit()) {
|
||||
return Err(VarsApiError::Invalid(
|
||||
"key must start with a lowercase letter or digit".into(),
|
||||
));
|
||||
}
|
||||
if !key
|
||||
.chars()
|
||||
.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
|
||||
{
|
||||
return Err(VarsApiError::Invalid(
|
||||
"key may contain only lowercase letters, digits, and hyphens".into(),
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Env scope is `*` (env-agnostic) or a kebab env name.
|
||||
fn validate_env_scope(env: &str) -> Result<(), VarsApiError> {
|
||||
if env == "*" {
|
||||
return Ok(());
|
||||
}
|
||||
if env.is_empty() || env.len() > 63 {
|
||||
return Err(VarsApiError::Invalid(
|
||||
"env must be '*' or 1–63 characters".into(),
|
||||
));
|
||||
}
|
||||
let mut chars = env.chars();
|
||||
let first = chars.next().unwrap();
|
||||
if !(first.is_ascii_lowercase() || first.is_ascii_digit()) {
|
||||
return Err(VarsApiError::Invalid(
|
||||
"env must start with a lowercase letter or digit".into(),
|
||||
));
|
||||
}
|
||||
if !env
|
||||
.chars()
|
||||
.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
|
||||
{
|
||||
return Err(VarsApiError::Invalid(
|
||||
"env may contain only lowercase letters, digits, and hyphens".into(),
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// Errors
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum VarsApiError {
|
||||
#[error("app not found")]
|
||||
AppNotFound,
|
||||
#[error("group not found")]
|
||||
GroupNotFound,
|
||||
#[error("var not found")]
|
||||
NotFound,
|
||||
#[error("invalid request: {0}")]
|
||||
Invalid(String),
|
||||
#[error("forbidden")]
|
||||
Forbidden,
|
||||
#[error("authorization repo error: {0}")]
|
||||
AuthzRepo(String),
|
||||
#[error("vars backend: {0}")]
|
||||
Backend(String),
|
||||
}
|
||||
|
||||
impl From<AuthzDenied> for VarsApiError {
|
||||
fn from(d: AuthzDenied) -> Self {
|
||||
match d {
|
||||
AuthzDenied::Denied => Self::Forbidden,
|
||||
AuthzDenied::Repo(e) => Self::AuthzRepo(e.to_string()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<AuthzError> for VarsApiError {
|
||||
fn from(e: AuthzError) -> Self {
|
||||
Self::AuthzRepo(e.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
impl From<VarsRepoError> for VarsApiError {
|
||||
fn from(e: VarsRepoError) -> Self {
|
||||
match e {
|
||||
VarsRepoError::Db(e) => Self::Backend(e.to_string()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl IntoResponse for VarsApiError {
|
||||
fn into_response(self) -> Response {
|
||||
let (status, body) = match &self {
|
||||
Self::AppNotFound | Self::GroupNotFound | Self::NotFound => {
|
||||
(StatusCode::NOT_FOUND, json!({ "error": self.to_string() }))
|
||||
}
|
||||
Self::Invalid(_) => (
|
||||
StatusCode::UNPROCESSABLE_ENTITY,
|
||||
json!({ "error": self.to_string() }),
|
||||
),
|
||||
Self::Forbidden => (StatusCode::FORBIDDEN, json!({ "error": self.to_string() })),
|
||||
Self::AuthzRepo(e) => {
|
||||
tracing::error!(error = %e, "vars admin authz repo error");
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
json!({ "error": "internal error" }),
|
||||
)
|
||||
}
|
||||
Self::Backend(e) => {
|
||||
tracing::error!(error = %e, "vars admin backend error");
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
json!({ "error": "internal error" }),
|
||||
)
|
||||
}
|
||||
};
|
||||
(status, Json(body)).into_response()
|
||||
}
|
||||
}
|
||||
210
crates/manager-core/src/vars_repo.rs
Normal file
210
crates/manager-core/src/vars_repo.rs
Normal file
@@ -0,0 +1,210 @@
|
||||
//! 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<Utc>,
|
||||
}
|
||||
|
||||
/// 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<bool, VarsRepoError>;
|
||||
|
||||
/// The owner's OWN rows only (NOT resolved/inherited), ordered by
|
||||
/// (key, environment_scope).
|
||||
async fn list_for_owner(&self, owner: VarOwner) -> Result<Vec<VarRow>, 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<bool, VarsRepoError> {
|
||||
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<Vec<VarRow>, VarsRepoError> {
|
||||
let rows: Vec<(String, String, JsonValue, bool, DateTime<Utc>)> = 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())
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user