grit-core 0.2.2

Embedded, bi-temporal property graph for agent memory: one SQLite file, in-process, deterministic
Documentation
//! Read-side row types.

use rusqlite::Row;
use serde::{Deserialize, Serialize};
use serde_json::Value as Json;
use uuid::Uuid;

use crate::clock::TimestampMs;
use crate::error::Result;

/// An entity node as stored.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Node {
    /// UUIDv7 identity.
    pub id: Uuid,
    /// Entity kind.
    pub kind: String,
    /// Display name.
    pub name: String,
    /// Summary text.
    pub summary: String,
    /// Arbitrary JSON attributes.
    pub attrs: Json,
    /// Namespace.
    pub group_id: String,
    /// System time: first believed.
    pub created_at: TimestampMs,
    /// System time: belief retracted (set by merge), `None` = current.
    pub expired_at: Option<TimestampMs>,
    /// If merged away, the absorbing node.
    pub merged_into: Option<Uuid>,
}

/// A fact edge as stored (the "fat edge").
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Edge {
    /// UUIDv7 identity.
    pub id: Uuid,
    /// Source node.
    pub src: Uuid,
    /// Destination node.
    pub dst: Uuid,
    /// Relation label.
    pub rel: String,
    /// Natural-language fact sentence.
    pub fact: String,
    /// Arbitrary JSON attributes.
    pub attrs: Json,
    /// Namespace.
    pub group_id: String,
    /// Event time: fact became true.
    pub valid_at: Option<TimestampMs>,
    /// Event time: fact stopped being true.
    pub invalid_at: Option<TimestampMs>,
    /// System time: first believed.
    pub created_at: TimestampMs,
    /// System time: belief retracted.
    pub expired_at: Option<TimestampMs>,
}

/// A provenance episode as stored.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Episode {
    /// UUIDv7 identity.
    pub id: Uuid,
    /// Origin of the content.
    pub source: String,
    /// Free-form source-kind tag (e.g. `"message"`, `"text"`, `"json"`).
    /// Opaque to grit — callers filter on it. Empty for episodes written
    /// before schema v3.
    #[serde(default)]
    pub kind: String,
    /// Raw text.
    pub content: String,
    /// Event time.
    pub occurred_at: TimestampMs,
    /// Namespace.
    pub group_id: String,
    /// System time: recorded.
    pub created_at: TimestampMs,
}

/// A connected fragment returned by traversal: nodes plus the edges that
/// connect them, all valid at the requested instant.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct Subgraph {
    /// Nodes reached (including the seeds).
    pub nodes: Vec<Node>,
    /// Edges walked.
    pub edges: Vec<Edge>,
}

pub(crate) fn uuid_col(row: &Row<'_>, idx: usize) -> rusqlite::Result<Uuid> {
    let s: String = row.get(idx)?;
    Uuid::parse_str(&s).map_err(|e| {
        rusqlite::Error::FromSqlConversionFailure(idx, rusqlite::types::Type::Text, Box::new(e))
    })
}

fn uuid_col_opt(row: &Row<'_>, idx: usize) -> rusqlite::Result<Option<Uuid>> {
    let s: Option<String> = row.get(idx)?;
    s.map(|s| {
        Uuid::parse_str(&s).map_err(|e| {
            rusqlite::Error::FromSqlConversionFailure(idx, rusqlite::types::Type::Text, Box::new(e))
        })
    })
    .transpose()
}

fn json_col(row: &Row<'_>, idx: usize) -> rusqlite::Result<Json> {
    let s: String = row.get(idx)?;
    serde_json::from_str(&s).map_err(|e| {
        rusqlite::Error::FromSqlConversionFailure(idx, rusqlite::types::Type::Text, Box::new(e))
    })
}

/// Column list matching [`Node::from_row`]; keep the two in sync.
pub(crate) const NODE_COLS: &str =
    "id, kind, name, summary, attrs, group_id, created_at, expired_at, merged_into";

impl Node {
    pub(crate) fn from_row(row: &Row<'_>) -> rusqlite::Result<Self> {
        Ok(Self {
            id: uuid_col(row, 0)?,
            kind: row.get(1)?,
            name: row.get(2)?,
            summary: row.get(3)?,
            attrs: json_col(row, 4)?,
            group_id: row.get(5)?,
            created_at: row.get(6)?,
            expired_at: row.get(7)?,
            merged_into: uuid_col_opt(row, 8)?,
        })
    }
}

/// Column list matching [`Edge::from_row`]; keep the two in sync.
pub(crate) const EDGE_COLS: &str =
    "id, src, dst, rel, fact, attrs, group_id, valid_at, invalid_at, created_at, expired_at";

impl Edge {
    pub(crate) fn from_row(row: &Row<'_>) -> rusqlite::Result<Self> {
        Ok(Self {
            id: uuid_col(row, 0)?,
            src: uuid_col(row, 1)?,
            dst: uuid_col(row, 2)?,
            rel: row.get(3)?,
            fact: row.get(4)?,
            attrs: json_col(row, 5)?,
            group_id: row.get(6)?,
            valid_at: row.get(7)?,
            invalid_at: row.get(8)?,
            created_at: row.get(9)?,
            expired_at: row.get(10)?,
        })
    }
}

/// Column list matching [`Episode::from_row`]; keep the two in sync.
pub(crate) const EPISODE_COLS: &str =
    "id, source, kind, content, occurred_at, group_id, created_at";

impl Episode {
    pub(crate) fn from_row(row: &Row<'_>) -> rusqlite::Result<Self> {
        Ok(Self {
            id: uuid_col(row, 0)?,
            source: row.get(1)?,
            kind: row.get(2)?,
            content: row.get(3)?,
            occurred_at: row.get(4)?,
            group_id: row.get(5)?,
            created_at: row.get(6)?,
        })
    }
}

pub(crate) fn collect<T>(
    rows: rusqlite::MappedRows<'_, impl FnMut(&Row<'_>) -> rusqlite::Result<T>>,
) -> Result<Vec<T>> {
    let mut out = Vec::new();
    for row in rows {
        out.push(row?);
    }
    Ok(out)
}