Skip to main content

runifold_store_postgres/
conversation.rs

1//! `PostgreSQL` conversation, summary, and semantic-memory adapter.
2
3mod memory;
4mod schema;
5mod store;
6mod support;
7mod transcript;
8
9use support::validate_identifier;
10
11use std::{fmt, sync::Arc};
12
13use runifold_retrieval::EmbeddingModel;
14use thiserror::Error;
15use tokio_postgres::{Client, NoTls};
16
17const MAX_CONTENT_BYTES: usize = 262_144;
18
19/// `PostgreSQL` conversation-store configuration or schema failure.
20#[derive(Debug, Error)]
21#[non_exhaustive]
22pub enum PostgresConversationStoreError {
23    /// The configured table name is unsafe for SQL interpolation.
24    #[error("conversation table must be a portable PostgreSQL identifier of at most 48 bytes")]
25    InvalidTable,
26    /// `PostgreSQL` connection or explicit schema setup failed.
27    #[error("PostgreSQL conversation store operation failed: {0}")]
28    Database(#[from] tokio_postgres::Error),
29}
30
31/// PostgreSQL-backed append-only conversation and semantic-memory store.
32#[derive(Clone)]
33pub struct PostgresConversationStore {
34    client: Arc<Client>,
35    table: String,
36    semantic_embedder: Option<Arc<dyn EmbeddingModel>>,
37}
38
39impl fmt::Debug for PostgresConversationStore {
40    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
41        formatter
42            .debug_struct("PostgresConversationStore")
43            .field("table", &self.table)
44            .field(
45                "semantic_embedding",
46                &self.semantic_embedder.as_ref().map(|_| "configured"),
47            )
48            .finish_non_exhaustive()
49    }
50}
51
52impl PostgresConversationStore {
53    /// Connects without creating or changing schema.
54    ///
55    /// # Errors
56    ///
57    /// Rejects unsafe table identifiers and propagates connection failures.
58    pub async fn connect(
59        connection: &str,
60        table: &str,
61    ) -> Result<Self, PostgresConversationStoreError> {
62        validate_identifier(table)?;
63        let (client, connection) = tokio_postgres::connect(connection, NoTls).await?;
64        tokio::spawn(async move {
65            let _ = connection.await;
66        });
67        Ok(Self {
68            client: Arc::new(client),
69            table: table.to_owned(),
70            semantic_embedder: None,
71        })
72    }
73
74    /// Enables vector semantic-memory writes and searches for scoped operations.
75    #[must_use]
76    pub fn with_semantic_memory_embedder(mut self, embedder: Arc<dyn EmbeddingModel>) -> Self {
77        self.semantic_embedder = Some(embedder);
78        self
79    }
80}
81
82#[cfg(test)]
83mod tests {
84    use super::*;
85
86    #[test]
87    fn table_identifiers_are_restricted_before_sql_construction() {
88        assert!(validate_identifier("runifold_conversations").is_ok());
89        assert!(matches!(
90            validate_identifier("bad-name"),
91            Err(PostgresConversationStoreError::InvalidTable)
92        ));
93        assert!(matches!(
94            validate_identifier("1bad"),
95            Err(PostgresConversationStoreError::InvalidTable)
96        ));
97    }
98
99    #[test]
100    fn schema_preserves_append_only_transcript_and_search_indexes() {
101        let schema = PostgresConversationStore::schema_sql("runifold_conversations");
102
103        assert!(schema.contains("PRIMARY KEY (conversation_id, sequence)"));
104        assert!(schema.contains("to_tsvector('simple', content)"));
105        assert!(!schema.contains("CREATE TRIGGER"));
106    }
107}