1mod request;
12mod stats;
13
14pub use request::InsertFileRequest;
15pub use stats::FileStats;
16
17use std::sync::Arc;
18
19use chrono::Utc;
20use sqlx::PgPool;
21use systemprompt_database::DbPool;
22use systemprompt_identifiers::{ContextId, FileId, SessionId, TraceId, UserId};
23
24use crate::error::{FilesError, FilesResult};
25use crate::models::{File, FileMetadata};
26
27#[derive(Debug, Clone)]
28pub struct FileRepository {
29 pub(crate) pool: Arc<PgPool>,
30 write_pool: Arc<PgPool>,
31}
32
33impl FileRepository {
34 pub fn new(db: &DbPool) -> FilesResult<Self> {
35 let pool = db.pool_arc()?;
36 let write_pool = db.write_pool_arc()?;
37 Ok(Self { pool, write_pool })
38 }
39
40 pub async fn insert(&self, request: InsertFileRequest) -> FilesResult<FileId> {
41 let id_uuid = uuid::Uuid::parse_str(request.id.as_str()).map_err(|e| {
42 FilesError::Validation(format!(
43 "Invalid UUID for file id {}: {e}",
44 request.id.as_str()
45 ))
46 })?;
47 let now = Utc::now();
48
49 let user_id_str = request.user_id.as_ref().map(UserId::as_str);
50 let session_id_str = request.session_id.as_ref().map(SessionId::as_str);
51 let trace_id_str = request.trace_id.as_ref().map(TraceId::as_str);
52 let context_id_str = request.context_id.as_ref().map(ContextId::as_str);
53
54 sqlx::query_as!(
55 File,
56 r#"
57 INSERT INTO files (id, path, public_url, mime_type, size_bytes, ai_content, metadata, user_id, session_id, trace_id, context_id, created_at, updated_at)
58 VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $12)
59 ON CONFLICT (path) DO UPDATE SET
60 public_url = EXCLUDED.public_url,
61 mime_type = EXCLUDED.mime_type,
62 size_bytes = EXCLUDED.size_bytes,
63 ai_content = EXCLUDED.ai_content,
64 metadata = EXCLUDED.metadata,
65 updated_at = EXCLUDED.updated_at
66 RETURNING id, path, public_url, mime_type, size_bytes, ai_content, metadata as "metadata: sqlx::types::Json<FileMetadata>", user_id as "user_id: UserId", session_id as "session_id: SessionId", trace_id as "trace_id: TraceId", context_id as "context_id: ContextId", created_at, updated_at, deleted_at
67 "#,
68 id_uuid,
69 request.path,
70 request.public_url,
71 request.mime_type,
72 request.size_bytes,
73 request.ai_content,
74 sqlx::types::Json(&request.metadata) as _,
75 user_id_str,
76 session_id_str,
77 trace_id_str,
78 context_id_str,
79 now
80 )
81 .fetch_one(&*self.write_pool)
82 .await?;
83
84 Ok(request.id)
85 }
86
87 pub async fn insert_file(&self, file: &File) -> FilesResult<FileId> {
88 let file_id = FileId::new(file.id.to_string());
89
90 let mut request = InsertFileRequest::new(
91 file_id.clone(),
92 file.path.clone(),
93 file.public_url.clone(),
94 file.mime_type.clone(),
95 )
96 .with_ai_content(file.ai_content)
97 .with_metadata(file.metadata.0.clone());
98
99 if let Some(size) = file.size_bytes {
100 request = request.with_size(size);
101 }
102
103 if let Some(ref user_id) = file.user_id {
104 request = request.with_user_id(user_id.clone());
105 }
106
107 if let Some(ref session_id) = file.session_id {
108 request = request.with_session_id(session_id.clone());
109 }
110
111 if let Some(ref trace_id) = file.trace_id {
112 request = request.with_trace_id(trace_id.clone());
113 }
114
115 if let Some(ref context_id) = file.context_id {
116 request = request.with_context_id(context_id.clone());
117 }
118
119 self.insert(request).await
120 }
121
122 pub async fn find_by_id(&self, id: &FileId) -> FilesResult<Option<File>> {
123 let id_uuid = uuid::Uuid::parse_str(id.as_str())
124 .map_err(|e| FilesError::Validation(format!("Invalid UUID for file id: {e}")))?;
125
126 let result = sqlx::query_as!(
127 File,
128 r#"
129 SELECT id, path, public_url, mime_type, size_bytes, ai_content, metadata as "metadata: sqlx::types::Json<FileMetadata>", user_id as "user_id: UserId", session_id as "session_id: SessionId", trace_id as "trace_id: TraceId", context_id as "context_id: ContextId", created_at, updated_at, deleted_at
130 FROM files
131 WHERE id = $1 AND deleted_at IS NULL
132 "#,
133 id_uuid
134 )
135 .fetch_optional(&*self.pool)
136 .await?;
137
138 Ok(result)
139 }
140
141 pub async fn find_by_path(&self, path: &str) -> FilesResult<Option<File>> {
142 let result = sqlx::query_as!(
143 File,
144 r#"
145 SELECT id, path, public_url, mime_type, size_bytes, ai_content, metadata as "metadata: sqlx::types::Json<FileMetadata>", user_id as "user_id: UserId", session_id as "session_id: SessionId", trace_id as "trace_id: TraceId", context_id as "context_id: ContextId", created_at, updated_at, deleted_at
146 FROM files
147 WHERE path = $1 AND deleted_at IS NULL
148 "#,
149 path
150 )
151 .fetch_optional(&*self.pool)
152 .await?;
153
154 Ok(result)
155 }
156
157 pub async fn list_by_user(
158 &self,
159 user_id: &UserId,
160 limit: i64,
161 offset: i64,
162 ) -> FilesResult<Vec<File>> {
163 let user_id_str = user_id.as_str();
164 let result = sqlx::query_as!(
165 File,
166 r#"
167 SELECT id, path, public_url, mime_type, size_bytes, ai_content, metadata as "metadata: sqlx::types::Json<FileMetadata>", user_id as "user_id: UserId", session_id as "session_id: SessionId", trace_id as "trace_id: TraceId", context_id as "context_id: ContextId", created_at, updated_at, deleted_at
168 FROM files
169 WHERE user_id = $1 AND deleted_at IS NULL
170 ORDER BY created_at DESC
171 LIMIT $2 OFFSET $3
172 "#,
173 user_id_str,
174 limit,
175 offset
176 )
177 .fetch_all(&*self.pool)
178 .await?;
179
180 Ok(result)
181 }
182
183 pub async fn list_all(&self, limit: i64, offset: i64) -> FilesResult<Vec<File>> {
184 let result = sqlx::query_as!(
185 File,
186 r#"
187 SELECT id, path, public_url, mime_type, size_bytes, ai_content, metadata as "metadata: sqlx::types::Json<FileMetadata>", user_id as "user_id: UserId", session_id as "session_id: SessionId", trace_id as "trace_id: TraceId", context_id as "context_id: ContextId", created_at, updated_at, deleted_at
188 FROM files
189 WHERE deleted_at IS NULL
190 ORDER BY created_at DESC
191 LIMIT $1 OFFSET $2
192 "#,
193 limit,
194 offset
195 )
196 .fetch_all(&*self.pool)
197 .await?;
198
199 Ok(result)
200 }
201
202 pub async fn delete(&self, id: &FileId) -> FilesResult<()> {
203 let id_uuid = uuid::Uuid::parse_str(id.as_str())
204 .map_err(|e| FilesError::Validation(format!("Invalid UUID for file id: {e}")))?;
205
206 sqlx::query!(
207 r#"
208 DELETE FROM files
209 WHERE id = $1
210 "#,
211 id_uuid
212 )
213 .execute(&*self.write_pool)
214 .await?;
215
216 Ok(())
217 }
218
219 pub async fn update_metadata(&self, id: &FileId, metadata: &FileMetadata) -> FilesResult<()> {
220 let id_uuid = uuid::Uuid::parse_str(id.as_str())
221 .map_err(|e| FilesError::Validation(format!("Invalid UUID for file id: {e}")))?;
222 let now = Utc::now();
223
224 sqlx::query!(
225 r#"
226 UPDATE files
227 SET metadata = $1, updated_at = $2
228 WHERE id = $3
229 "#,
230 sqlx::types::Json(metadata) as _,
231 now,
232 id_uuid
233 )
234 .execute(&*self.write_pool)
235 .await?;
236
237 Ok(())
238 }
239
240 pub async fn search_by_path(&self, query: &str, limit: i64) -> FilesResult<Vec<File>> {
241 let pattern = format!("%{query}%");
242 let result = sqlx::query_as!(
243 File,
244 r#"
245 SELECT id, path, public_url, mime_type, size_bytes, ai_content, metadata as "metadata: sqlx::types::Json<FileMetadata>",
246 user_id as "user_id: UserId", session_id as "session_id: SessionId",
247 trace_id as "trace_id: TraceId", context_id as "context_id: ContextId",
248 created_at, updated_at, deleted_at
249 FROM files
250 WHERE path ILIKE $1 AND deleted_at IS NULL
251 ORDER BY created_at DESC
252 LIMIT $2
253 "#,
254 pattern,
255 limit
256 )
257 .fetch_all(&*self.pool)
258 .await?;
259
260 Ok(result)
261 }
262}