kernex-memory 0.8.0

Pluggable storage for Kernex: conversations, learning, and scheduled tasks.
Documentation
//! Typed observation log: input ([`SaveEntry`]), output ([`Observation`]),
//! and the closed taxonomy ([`ObservationType`]) that backs the
//! `kx mem save` write surface plus its `search` / `get` / `soft-delete`
//! companions on [`crate::MemoryStore`].
//!
//! Project scoping comes from the on-disk DB location
//! (`~/.kx/projects/<name>/memory.db` in the CLI). Intra-DB scoping is by
//! `sender_id`, matching the existing `facts` and `messages` discipline.
//! Observations carry no project column.

use std::time::SystemTime;

use serde::{Deserialize, Serialize};

/// Closed set of observation types the CLI and store accept.
///
/// New variants ride a `kernex-memory` minor bump PLUS a migration that
/// extends the SQL `CHECK` constraint. Adding a Rust variant without
/// updating the migration silently rejects the new value at write time
/// with a SQLite CHECK violation; the two layers must move together.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
#[non_exhaustive]
pub enum ObservationType {
    Bugfix,
    Decision,
    Pattern,
    Config,
    Discovery,
    Learning,
    Architecture,
}

impl ObservationType {
    /// Stable lowercase identifier persisted to the `observations.type`
    /// column. Matches the SQL `CHECK` constraint in migration 018.
    pub fn as_db_str(&self) -> &'static str {
        match self {
            Self::Bugfix => "bugfix",
            Self::Decision => "decision",
            Self::Pattern => "pattern",
            Self::Config => "config",
            Self::Discovery => "discovery",
            Self::Learning => "learning",
            Self::Architecture => "architecture",
        }
    }

    /// Inverse of [`Self::as_db_str`]. Returns `None` for unknown
    /// strings so callers can map an unrecognized value (drift between
    /// DB rows and the current Rust enum) to a domain error rather
    /// than panicking.
    pub fn from_db_str(s: &str) -> Option<Self> {
        match s {
            "bugfix" => Some(Self::Bugfix),
            "decision" => Some(Self::Decision),
            "pattern" => Some(Self::Pattern),
            "config" => Some(Self::Config),
            "discovery" => Some(Self::Discovery),
            "learning" => Some(Self::Learning),
            "architecture" => Some(Self::Architecture),
            _ => None,
        }
    }
}

/// Operator-supplied input for [`crate::MemoryStore::save_observation`].
///
/// Optional fields stay [`None`] when the caller omits them. The store
/// generates [`Observation::id`] and the timestamps; callers do not.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[non_exhaustive]
pub struct SaveEntry {
    pub sender_id: String,
    #[serde(rename = "type")]
    pub kind: ObservationType,
    pub title: String,
    pub what: Option<String>,
    pub why: Option<String>,
    #[serde(rename = "where")]
    pub where_field: Option<String>,
    pub learned: Option<String>,
}

impl SaveEntry {
    /// Construct a minimal entry (only the required fields).
    /// Optional fields default to [`None`].
    pub fn new(
        sender_id: impl Into<String>,
        kind: ObservationType,
        title: impl Into<String>,
    ) -> Self {
        Self {
            sender_id: sender_id.into(),
            kind,
            title: title.into(),
            what: None,
            why: None,
            where_field: None,
            learned: None,
        }
    }
}

/// Stored observation row returned by save / get / search paths.
///
/// `id` is a UUIDv4 string generated by the store at write time.
/// `created_at` and `updated_at` are wall-clock times in UTC; the DB
/// stores them as ISO 8601 strings, and the store projects them back
/// to [`SystemTime`] at the row-shape boundary so consumers never see
/// raw timestamp text.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[non_exhaustive]
pub struct Observation {
    pub id: String,
    pub sender_id: String,
    #[serde(rename = "type")]
    pub kind: ObservationType,
    pub title: String,
    pub what: Option<String>,
    pub why: Option<String>,
    #[serde(rename = "where")]
    pub where_field: Option<String>,
    pub learned: Option<String>,
    pub created_at: SystemTime,
    pub updated_at: SystemTime,
}

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

    #[test]
    fn observation_type_round_trips_via_db_str() {
        for kind in [
            ObservationType::Bugfix,
            ObservationType::Decision,
            ObservationType::Pattern,
            ObservationType::Config,
            ObservationType::Discovery,
            ObservationType::Learning,
            ObservationType::Architecture,
        ] {
            assert_eq!(
                ObservationType::from_db_str(kind.as_db_str()),
                Some(kind),
                "round-trip failed for {kind:?}"
            );
        }
    }

    #[test]
    fn unknown_db_str_returns_none() {
        assert_eq!(ObservationType::from_db_str("bogus"), None);
        assert_eq!(ObservationType::from_db_str(""), None);
        assert_eq!(ObservationType::from_db_str("BUGFIX"), None); // case sensitive
    }

    #[test]
    fn save_entry_serde_round_trip() {
        let entry = SaveEntry {
            sender_id: "user".into(),
            kind: ObservationType::Bugfix,
            title: "Fixed N+1 query".into(),
            what: Some("added eager loading".into()),
            why: Some("12s pages on 5k users".into()),
            where_field: Some("src/users/list.rs".into()),
            learned: Some("FTS5 rewriter cannot fix N+1".into()),
        };
        let json = serde_json::to_string(&entry).unwrap();
        // JSON uses the renamed field names (`type`, `where`) from serde attrs.
        assert!(json.contains("\"type\":\"bugfix\""), "json was {json}");
        assert!(
            json.contains("\"where\":\"src/users/list.rs\""),
            "json was {json}"
        );
        let parsed: SaveEntry = serde_json::from_str(&json).unwrap();
        assert_eq!(parsed.sender_id, entry.sender_id);
        assert_eq!(parsed.kind, entry.kind);
        assert_eq!(parsed.title, entry.title);
        assert_eq!(parsed.what, entry.what);
        assert_eq!(parsed.why, entry.why);
        assert_eq!(parsed.where_field, entry.where_field);
        assert_eq!(parsed.learned, entry.learned);
    }
}