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