octra-sqlite 0.6.3

Real SQLite inside an Octra Circle, with a Rust CLI and client library
Documentation
use super::error::{Error, ErrorKind, Result};
use serde_json::Value;

/// Result of read SQL.
#[derive(Debug, Clone, PartialEq)]
pub struct QueryResult {
    /// Column names returned by SQLite.
    pub columns: Vec<String>,
    /// Rows as JSON values in column order.
    pub rows: Vec<Vec<Value>>,
    /// Number of returned rows.
    pub row_count: usize,
    raw: Value,
}

impl QueryResult {
    /// Decode a raw Circle query response into validated typed rows.
    pub fn from_value(value: Value) -> Result<Self> {
        let columns = value
            .get("columns")
            .and_then(Value::as_array)
            .ok_or_else(|| Error::with_kind(ErrorKind::Decode, "query result missing columns"))?
            .iter()
            .map(|column| {
                column.as_str().map(str::to_string).ok_or_else(|| {
                    Error::with_kind(ErrorKind::Decode, "query result column must be a string")
                })
            })
            .collect::<Result<Vec<_>>>()?;
        let rows = value
            .get("rows")
            .and_then(Value::as_array)
            .ok_or_else(|| Error::with_kind(ErrorKind::Decode, "query result missing rows"))?
            .iter()
            .map(|row| {
                row.as_array().cloned().ok_or_else(|| {
                    Error::with_kind(ErrorKind::Decode, "query result row must be an array")
                })
            })
            .collect::<Result<Vec<_>>>()?;
        let row_count = match value.get("row_count").and_then(Value::as_u64) {
            Some(count) => usize::try_from(count).map_err(|_| {
                Error::with_kind(ErrorKind::Decode, "query result row_count exceeds usize")
            })?,
            None => rows.len(),
        };
        if row_count != rows.len() {
            return Err(Error::with_kind(
                ErrorKind::Decode,
                format!(
                    "query result row_count {row_count} does not match {} rows",
                    rows.len()
                ),
            ));
        }
        for row in &rows {
            if row.len() != columns.len() {
                return Err(Error::with_kind(
                    ErrorKind::Decode,
                    format!(
                        "query result row has {} cells but {} columns",
                        row.len(),
                        columns.len()
                    ),
                ));
            }
        }
        Ok(Self {
            columns,
            rows,
            row_count,
            raw: value,
        })
    }

    /// Return the original query response.
    pub fn raw(&self) -> &Value {
        &self.raw
    }
}

/// Submitted Octra transaction returned by no-wait write paths.
#[derive(Debug, Clone, PartialEq)]
pub struct SubmittedTransaction {
    /// Target Circle ID when known.
    pub circle: Option<String>,
    /// Submitting wallet address when known.
    pub wallet: Option<String>,
    /// Transaction hash when the RPC returned one.
    pub tx_hash: Option<String>,
    /// Raw submit result.
    pub result: Value,
}

impl SubmittedTransaction {
    /// Decode a raw transaction-submission response.
    pub fn from_value(value: Value) -> Result<Self> {
        Ok(Self {
            circle: string_field(&value, "circle"),
            wallet: string_field(&value, "wallet"),
            tx_hash: string_field(&value, "tx_hash"),
            result: value.get("result").cloned().ok_or_else(|| {
                Error::with_kind(ErrorKind::Rpc, "submitted transaction missing result")
            })?,
        })
    }
}

/// Result of a write that has been submitted and confirmed.
#[derive(Debug, Clone, PartialEq)]
pub struct ExecuteResult {
    /// Submitted transaction metadata.
    pub submitted: SubmittedTransaction,
    /// Confirmed transaction receipt.
    pub receipt: Value,
}

impl ExecuteResult {
    /// Decode a confirmed execution response and fail if its receipt failed.
    pub fn from_value(value: Value) -> Result<Self> {
        let submitted = SubmittedTransaction::from_value(value.clone())?;
        let receipt = value
            .get("receipt")
            .cloned()
            .ok_or_else(|| Error::with_kind(ErrorKind::Receipt, "exec result missing receipt"))?;
        ensure_receipt_success(&receipt)?;
        Ok(Self { submitted, receipt })
    }
}

/// Deployed Circle program metadata.
#[derive(Debug, Clone, PartialEq)]
pub struct ProgramInfo {
    /// Circle program version when reported by Octra.
    pub version: Option<String>,
    /// Deployed personalized WASM SHA-256 when reported by Octra.
    pub code_hash: Option<String>,
    /// Deployed WASM byte length when reported by Octra.
    pub code_bytes: Option<u64>,
    raw: Value,
}

impl ProgramInfo {
    /// Decode a raw Circle program-info response.
    pub fn from_value(value: Value) -> Result<Self> {
        Ok(Self {
            version: string_field(&value, "version"),
            code_hash: string_field(&value, "code_hash"),
            code_bytes: value
                .get("code_bytes")
                .and_then(|value| value.as_u64().or_else(|| value.as_str()?.parse().ok())),
            raw: value,
        })
    }

    /// Return the original program-info response.
    pub fn raw(&self) -> &Value {
        &self.raw
    }
}

/// Owner-write authorization metadata exposed by the Circle program.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AuthInfo {
    /// Whether owner-write authorization is configured.
    pub configured: bool,
    /// Database identity bound into OSW1 owner-write intents.
    pub db_id: String,
    /// Owner public key accepted by the Circle program.
    pub owner_pubkey: Option<String>,
    /// Next owner-write sequence when the Circle reports it.
    pub owner_sequence: Option<u64>,
}

pub(super) fn ensure_receipt_success(receipt: &Value) -> Result<()> {
    let sql_error = event_values(receipt, "octra.sqlite.error");
    let failed = receipt.get("success").and_then(Value::as_bool) != Some(true)
        || receipt.get("error").is_some_and(|error| !error.is_null())
        || sql_error.is_some();
    if failed {
        let detail = sql_error
            .as_deref()
            .map(format_sql_error_event)
            .unwrap_or_else(|| receipt_error_text(receipt));
        let message = format!("SQL execution failed: {detail}");
        return match sql_error.as_deref().and_then(sql_error_code) {
            Some(code) => Err(Error::with_code(ErrorKind::Receipt, code, message)),
            None => Err(Error::with_kind(ErrorKind::Receipt, message)),
        };
    }
    Ok(())
}

fn event_values(receipt: &Value, topic: &str) -> Option<String> {
    receipt
        .get("events")?
        .as_array()?
        .iter()
        .find(|event| event.get("event").and_then(Value::as_str) == Some(topic))
        .and_then(|event| event.get("values"))
        .and_then(Value::as_array)
        .map(|values| {
            values
                .iter()
                .map(value_to_event_text)
                .collect::<Vec<_>>()
                .join(", ")
        })
}

fn receipt_error_text(receipt: &Value) -> String {
    receipt
        .get("error")
        .filter(|error| !error.is_null())
        .map(value_to_compact_text)
        .unwrap_or_else(|| value_to_compact_text(receipt))
}

fn value_to_compact_text(value: &Value) -> String {
    serde_json::to_string(value).unwrap_or_else(|_| value.to_string())
}

fn value_to_event_text(value: &Value) -> String {
    value
        .as_str()
        .map(str::to_string)
        .unwrap_or_else(|| value_to_compact_text(value))
}

fn format_sql_error_event(error: &str) -> String {
    match error.split_once(':') {
        Some((code, detail)) if !detail.is_empty() => {
            format!("database error ({code}): {detail}")
        }
        _ => error.to_string(),
    }
}

fn sql_error_code(error: &str) -> Option<&str> {
    let code = error.split_once(':').map(|(code, _)| code).unwrap_or(error);
    (!code.is_empty()).then_some(code)
}

fn string_field(value: &Value, key: &str) -> Option<String> {
    value.get(key).and_then(Value::as_str).map(str::to_string)
}

#[cfg(test)]
mod tests {
    use super::*;
    use serde_json::json;

    #[test]
    fn query_result_validates_rectangular_rows() {
        let error = QueryResult::from_value(json!({
            "columns": ["a", "b"],
            "rows": [[1]],
            "row_count": 1,
        }))
        .unwrap_err();
        assert_eq!(error.kind(), ErrorKind::Decode);
    }

    #[test]
    fn receipt_success_with_sql_error_event_is_failed_execution() {
        let receipt = json!({
            "success": true,
            "error": null,
            "events": [{
                "event": "octra.sqlite.error",
                "values": ["sqlite_exec_failed:no such table: correction"]
            }]
        });
        let error = ensure_receipt_success(&receipt).unwrap_err();
        assert_eq!(error.kind(), ErrorKind::Receipt);
        assert!(
            error
                .to_string()
                .contains("database error (sqlite_exec_failed): no such table: correction")
        );
    }

    #[test]
    fn receipt_without_explicit_success_fails_closed() {
        let error = ensure_receipt_success(&json!({"events": []})).unwrap_err();
        assert_eq!(error.kind(), ErrorKind::Receipt);
    }
}