runifold_store_postgres/
conversation.rs1mod 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#[derive(Debug, Error)]
21#[non_exhaustive]
22pub enum PostgresConversationStoreError {
23 #[error("conversation table must be a portable PostgreSQL identifier of at most 48 bytes")]
25 InvalidTable,
26 #[error("PostgreSQL conversation store operation failed: {0}")]
28 Database(#[from] tokio_postgres::Error),
29}
30
31#[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 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 #[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}