feltdb-client 0.4.2

Native async Rust client for a FeltDB server: records, atomic and idempotent transactions, compare-and-set, bounded queries, read-basis fences, events, and the canonical application API
Documentation
//! The control plane: server health, tenants, applications, the draft → revision → promotion
//! lifecycle of an application's contract, and online backup.

use feltdb::application::{
    ApplicationDraft, ApplicationManifest, ApplicationRevision, ValidationReport,
};
use serde::Deserialize;
use serde_json::{json, Value};

use crate::{encode, Client, Method, Result};

/// The server's own health report (`GET /health`): liveness, build identity and subsystem status.
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
pub struct Health {
    /// `healthy`, or the reason it is not.
    pub status: String,
    /// The server's package version.
    pub version: String,
    #[serde(default)]
    pub git_commit: Option<String>,
    #[serde(default)]
    pub release_id: Option<String>,
    #[serde(default)]
    pub public_contract: Option<String>,
    #[serde(default)]
    pub capability_fingerprint: Option<String>,
    #[serde(default)]
    pub storage: Option<String>,
    #[serde(default)]
    pub membership: Option<String>,
}

/// A tenant on the server.
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
pub struct Tenant {
    pub id: String,
    pub name: String,
    #[serde(default)]
    pub slug: Option<String>,
}

/// An application as the control plane records it. Which revision an environment runs is
/// [`crate::Application::discover`]'s to answer.
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
pub struct ApplicationRecord {
    pub id: String,
    pub tenant_id: String,
    pub name: String,
    #[serde(default)]
    pub slug: Option<String>,
    #[serde(default)]
    pub status: Option<String>,
}

/// Moves an environment to a revision: [`Control::promote`].
#[derive(Debug, Clone)]
pub struct Promotion {
    environment: String,
    reason: String,
    expected_current_revision: Option<String>,
    approve_destructive_changes: bool,
}

impl Promotion {
    pub fn new(environment: impl Into<String>) -> Self {
        Self {
            environment: environment.into(),
            reason: String::new(),
            expected_current_revision: None,
            approve_destructive_changes: false,
        }
    }

    /// Recorded in the environment's history.
    pub fn reason(mut self, reason: impl Into<String>) -> Self {
        self.reason = reason.into();
        self
    }

    /// Promote only if the environment still runs `revision_id` (`None`: still runs nothing), so
    /// two promotions racing from the same base cannot both win.
    pub fn expect_current(mut self, revision_id: Option<String>) -> Self {
        self.expected_current_revision = revision_id;
        self
    }

    /// Allow a promotion whose schema change would drop data.
    pub fn approve_destructive_changes(mut self) -> Self {
        self.approve_destructive_changes = true;
        self
    }
}

/// The control-plane routes, from [`Client::control`]. Cheap to clone.
#[derive(Debug, Clone)]
pub struct Control {
    client: Client,
}

impl Control {
    fn application_path(application_id: &str, rest: &str) -> String {
        format!("/api/applications/{}{rest}", encode(application_id))
    }

    fn draft_path(application_id: &str, draft_id: &str, rest: &str) -> String {
        Self::application_path(
            application_id,
            &format!("/drafts/{}{rest}", encode(draft_id)),
        )
    }

    /// The tenants this key can see.
    pub async fn tenants(&self) -> Result<Vec<Tenant>> {
        self.client
            .send::<_, ()>(Method::GET, "/api/tenants", None)
            .await
    }

    pub async fn create_tenant(&self, name: &str) -> Result<Tenant> {
        self.client
            .send(Method::POST, "/api/tenants", Some(&json!({ "name": name })))
            .await
    }

    /// A tenant's applications that this key can see.
    pub async fn applications(&self, tenant_id: &str) -> Result<Vec<ApplicationRecord>> {
        let path = format!("/api/tenants/{}/applications", encode(tenant_id));
        self.client.send::<_, ()>(Method::GET, &path, None).await
    }

    pub async fn create_application(
        &self,
        tenant_id: &str,
        name: &str,
    ) -> Result<ApplicationRecord> {
        let path = format!("/api/tenants/{}/applications", encode(tenant_id));
        self.client
            .send(Method::POST, &path, Some(&json!({ "name": name })))
            .await
    }

    pub async fn application(&self, application_id: &str) -> Result<ApplicationRecord> {
        self.client
            .send::<_, ()>(
                Method::GET,
                &Self::application_path(application_id, ""),
                None,
            )
            .await
    }

    /// Start a draft of the application's contract from `base_revision_id`, or from an empty
    /// manifest.
    pub async fn create_draft(
        &self,
        application_id: &str,
        base_revision_id: Option<&str>,
    ) -> Result<ApplicationDraft> {
        self.client
            .send(
                Method::POST,
                &Self::application_path(application_id, "/drafts"),
                Some(&json!({ "base_revision_id": base_revision_id })),
            )
            .await
    }

    pub async fn draft(&self, application_id: &str, draft_id: &str) -> Result<ApplicationDraft> {
        self.client
            .send::<_, ()>(
                Method::GET,
                &Self::draft_path(application_id, draft_id, ""),
                None,
            )
            .await
    }

    /// Replace the draft's manifest, only if the draft is still at `version` (the
    /// [`ApplicationDraft::version`] last read); a concurrent edit is refused, not overwritten.
    pub async fn update_draft(
        &self,
        application_id: &str,
        draft_id: &str,
        version: u64,
        manifest: &ApplicationManifest,
    ) -> Result<ApplicationDraft> {
        self.client
            .send_with_headers(
                Method::PUT,
                &Self::draft_path(application_id, draft_id, ""),
                &[("if-version", &version.to_string())],
                Some(&json!({ "manifest": manifest })),
            )
            .await
    }

    /// Validate the draft without committing it. An invalid manifest is a report with
    /// `valid: false` and its issues, not an error.
    pub async fn validate_draft(
        &self,
        application_id: &str,
        draft_id: &str,
    ) -> Result<ValidationReport> {
        self.client
            .send(
                Method::POST,
                &Self::draft_path(application_id, draft_id, "/validate"),
                Some(&json!({})),
            )
            .await
    }

    /// Commit the draft as an immutable revision. No environment runs it until it is promoted.
    pub async fn commit_draft(
        &self,
        application_id: &str,
        draft_id: &str,
    ) -> Result<ApplicationRevision> {
        self.client
            .send(
                Method::POST,
                &Self::draft_path(application_id, draft_id, "/commit"),
                Some(&json!({})),
            )
            .await
    }

    pub async fn revision(
        &self,
        application_id: &str,
        revision_id: &str,
    ) -> Result<ApplicationRevision> {
        let path = Self::application_path(
            application_id,
            &format!("/revisions/{}", encode(revision_id)),
        );
        self.client.send::<_, ()>(Method::GET, &path, None).await
    }

    /// Make `revision_id` the revision an environment runs. Returns the server's promotion
    /// record and readiness.
    pub async fn promote(
        &self,
        application_id: &str,
        revision_id: &str,
        promotion: &Promotion,
    ) -> Result<Value> {
        let path = Self::application_path(
            application_id,
            &format!("/revisions/{}/promote", encode(revision_id)),
        );
        self.client
            .send(
                Method::POST,
                &path,
                Some(&json!({
                    "environment": promotion.environment,
                    "reason": promotion.reason,
                    "expected_current_revision": promotion.expected_current_revision,
                    "approve_destructive_changes": promotion.approve_destructive_changes,
                })),
            )
            .await
    }

    /// An online backup: a consistent snapshot of the whole database written to `output`, a path
    /// on the server's host. Returns the bundle's manifest. Requires `cluster:write`; verify the
    /// bundle with `feltdb-server backup verify`.
    pub async fn backup(&self, output: &str) -> Result<Value> {
        self.client
            .send(
                Method::POST,
                "/admin/backups",
                Some(&json!({ "output": output })),
            )
            .await
    }
}

impl Client {
    /// The server's health report. Unauthenticated on the server.
    pub async fn health(&self) -> Result<Health> {
        self.send::<_, ()>(Method::GET, "/health", None).await
    }

    /// The control plane: tenants, applications, contract drafts, revisions, promotion, backup.
    pub fn control(&self) -> Control {
        Control {
            client: self.clone(),
        }
    }
}