runifold_store_postgres/
conversation.rs1mod durable;
4mod memory;
5mod schema;
6mod store;
7mod support;
8mod transcript;
9
10use support::validate_identifier;
11
12use std::{fmt, sync::Arc};
13
14use runifold_retrieval::EmbeddingModel;
15use thiserror::Error;
16use tokio_postgres::{Client, NoTls};
17
18use crate::blocking::PostgresBlockingClient;
19
20const MAX_CONTENT_BYTES: usize = 262_144;
21
22#[derive(Debug, Error)]
24#[non_exhaustive]
25pub enum PostgresConversationStoreError {
26 #[error("conversation table must be a portable PostgreSQL identifier of at most 48 bytes")]
28 InvalidTable,
29 #[error("PostgreSQL conversation store operation failed: {0}")]
31 Database(#[from] tokio_postgres::Error),
32 #[error("PostgreSQL blocking connection task failed: {0}")]
34 ConnectionTask(String),
35}
36
37#[derive(Clone)]
39pub struct PostgresConversationStore {
40 client: Arc<Client>,
41 transaction_client: Arc<tokio::sync::Mutex<Client>>,
42 blocking: PostgresBlockingClient,
43 table: String,
44 semantic_embedder: Option<Arc<dyn EmbeddingModel>>,
45}
46
47impl fmt::Debug for PostgresConversationStore {
48 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
49 formatter
50 .debug_struct("PostgresConversationStore")
51 .field("table", &self.table)
52 .field(
53 "semantic_embedding",
54 &self.semantic_embedder.as_ref().map(|_| "configured"),
55 )
56 .finish_non_exhaustive()
57 }
58}
59
60impl PostgresConversationStore {
61 pub async fn connect(
67 connection: &str,
68 table: &str,
69 ) -> Result<Self, PostgresConversationStoreError> {
70 validate_identifier(table)?;
71 let sync_connection = connection.to_owned();
72 let blocking =
73 tokio::task::spawn_blocking(move || PostgresBlockingClient::connect(&sync_connection))
74 .await
75 .map_err(|error| PostgresConversationStoreError::ConnectionTask(error.to_string()))?
76 .map_err(PostgresConversationStoreError::ConnectionTask)?;
77 let (client, primary_connection) = tokio_postgres::connect(connection, NoTls).await?;
78 tokio::spawn(async move {
79 let _ = primary_connection.await;
80 });
81 let (transaction_client, transaction_connection) =
82 tokio_postgres::connect(connection, NoTls).await?;
83 tokio::spawn(async move {
84 let _ = transaction_connection.await;
85 });
86 Ok(Self {
87 client: Arc::new(client),
88 transaction_client: Arc::new(tokio::sync::Mutex::new(transaction_client)),
89 blocking,
90 table: table.to_owned(),
91 semantic_embedder: None,
92 })
93 }
94
95 #[must_use]
97 pub fn with_semantic_memory_embedder(mut self, embedder: Arc<dyn EmbeddingModel>) -> Self {
98 self.semantic_embedder = Some(embedder);
99 self
100 }
101
102 pub(crate) const fn blocking(&self) -> &PostgresBlockingClient {
103 &self.blocking
104 }
105
106 pub(crate) fn table(&self) -> &str {
107 &self.table
108 }
109}
110
111#[cfg(test)]
112mod tests {
113 use super::*;
114
115 #[test]
116 fn table_identifiers_are_restricted_before_sql_construction() {
117 assert!(validate_identifier("runifold_conversations").is_ok());
118 assert!(matches!(
119 validate_identifier("bad-name"),
120 Err(PostgresConversationStoreError::InvalidTable)
121 ));
122 assert!(matches!(
123 validate_identifier("1bad"),
124 Err(PostgresConversationStoreError::InvalidTable)
125 ));
126 }
127
128 #[test]
129 fn schema_preserves_append_only_transcript_and_search_indexes() {
130 let schema = PostgresConversationStore::schema_sql("runifold_conversations");
131
132 assert!(schema.contains("PRIMARY KEY (conversation_id, sequence)"));
133 assert!(schema.contains("to_tsvector('simple', content)"));
134 assert!(!schema.contains("CREATE TRIGGER"));
135 }
136}