1use anyhow::Result;
9use async_trait::async_trait;
10use serde::Serialize;
11use sha2::{Digest, Sha256};
12use sqlx::{Row, SqlitePool};
13use uuid::Uuid;
14
15use context_harness_core::models::{Chunk, Document};
16use context_harness_core::store::{ChunkCandidate, DocumentMetadata, DocumentResponse, Store};
17
18use crate::config::Config;
19use crate::db;
20use crate::migrate;
21use crate::models::SourceItem;
22use crate::sqlite_store::SqliteStore;
23use crate::vector_index::{self, VectorRecord};
24
25#[derive(Debug, Clone, PartialEq, Eq)]
27pub struct PendingChunk {
28 pub chunk_id: String,
29 pub document_id: String,
30 pub text: String,
31 pub text_hash: String,
32}
33
34#[derive(Debug, Clone, PartialEq, Eq)]
36pub struct SourceStats {
37 pub source: String,
38 pub doc_count: i64,
39 pub chunk_count: i64,
40 pub embedded_count: i64,
41 pub last_sync_ts: Option<i64>,
42}
43
44#[derive(Debug, Clone, PartialEq, Eq)]
46pub struct StoreStats {
47 pub total_docs: i64,
48 pub total_chunks: i64,
49 pub total_embedded: i64,
50 pub db_size_bytes: u64,
51 pub sources: Vec<SourceStats>,
52}
53
54#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
56pub struct ExportData {
57 pub documents: Vec<ExportDocument>,
58 pub chunks: Vec<ExportChunk>,
59}
60
61#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
63pub struct ExportDocument {
64 pub id: String,
65 pub source: String,
66 pub source_id: String,
67 pub source_url: Option<String>,
68 pub title: Option<String>,
69 pub updated_at: i64,
70 pub body: String,
71}
72
73#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
75pub struct ExportChunk {
76 pub id: String,
77 pub document_id: String,
78 pub chunk_index: i64,
79 pub text: String,
80}
81
82#[async_trait]
84pub trait AppStore: Store {
85 #[allow(dead_code)]
86 async fn initialize(&self) -> Result<()>;
87 async fn get_checkpoint(&self, source: &str) -> Result<Option<i64>>;
88 async fn set_checkpoint(&self, source: &str, cursor: i64) -> Result<()>;
89 async fn upsert_source_item(&self, item: &SourceItem) -> Result<String>;
90 async fn find_pending_chunks(
91 &self,
92 model: &str,
93 limit: Option<usize>,
94 ) -> Result<Vec<PendingChunk>>;
95 async fn get_embedding_hash(&self, chunk_id: &str, model: &str) -> Result<Option<String>>;
96 async fn clear_embeddings(&self) -> Result<()>;
97 async fn stats(&self) -> Result<StoreStats>;
98 async fn export_index(&self) -> Result<ExportData>;
99}
100
101pub struct SqliteAppStore {
103 config: Config,
104 pool: SqlitePool,
105}
106
107impl SqliteAppStore {
108 pub async fn connect(config: &Config) -> Result<Self> {
109 let pool = db::connect(config).await?;
110 Ok(Self {
111 config: config.clone(),
112 pool,
113 })
114 }
115
116 pub async fn initialize_config(config: &Config) -> Result<()> {
117 migrate::run_migrations(config).await
118 }
119
120 #[allow(dead_code)]
121 pub fn pool(&self) -> &SqlitePool {
122 &self.pool
123 }
124
125 #[allow(dead_code)]
127 pub fn agent_runs(&self, workspace_id: &str) -> Result<crate::agent_store::AgentRunStore> {
128 crate::agent_store::AgentRunStore::new(self.pool.clone(), workspace_id)
129 }
130
131 pub fn agent_tasks(
134 &self,
135 workspace_id: &str,
136 ) -> Result<crate::agent_task_store::AgentTaskStore> {
137 crate::agent_task_store::AgentTaskStore::new(self.pool.clone(), workspace_id)
138 }
139
140 pub async fn close(&self) {
141 self.pool.close().await;
142 }
143
144 fn core_store(&self) -> SqliteStore {
145 SqliteStore::new(self.pool.clone())
146 }
147
148 #[allow(dead_code)] pub(crate) async fn replace_source_item_atomic(
151 &self,
152 item: &SourceItem,
153 max_tokens: usize,
154 ) -> Result<CanonicalDocumentWrite> {
155 let mut tx = self.pool.begin().await?;
156 let existing_id: Option<String> =
157 sqlx::query_scalar("SELECT id FROM documents WHERE source = ? AND source_id = ?")
158 .bind(&item.source)
159 .bind(&item.source_id)
160 .fetch_optional(&mut *tx)
161 .await?;
162 let document = document_from_source_item(
163 existing_id.unwrap_or_else(|| Uuid::new_v4().to_string()),
164 item,
165 );
166 let chunks = crate::chunk::chunk_text(&document.id, &document.body, max_tokens);
167
168 sqlx::query(
169 r#"
170 INSERT INTO documents (id, source, source_id, source_url, title, author,
171 created_at, updated_at, content_type, body,
172 metadata_json, raw_json, dedup_hash)
173 VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
174 ON CONFLICT(source, source_id) DO UPDATE SET
175 source_url = excluded.source_url,
176 title = excluded.title,
177 author = excluded.author,
178 updated_at = excluded.updated_at,
179 content_type = excluded.content_type,
180 body = excluded.body,
181 metadata_json = excluded.metadata_json,
182 raw_json = excluded.raw_json,
183 dedup_hash = excluded.dedup_hash
184 "#,
185 )
186 .bind(&document.id)
187 .bind(&document.source)
188 .bind(&document.source_id)
189 .bind(&document.source_url)
190 .bind(&document.title)
191 .bind(&document.author)
192 .bind(document.created_at)
193 .bind(document.updated_at)
194 .bind(&document.content_type)
195 .bind(&document.body)
196 .bind(&document.metadata_json)
197 .bind(&document.raw_json)
198 .bind(&document.dedup_hash)
199 .execute(&mut *tx)
200 .await?;
201
202 sqlx::query(
203 "DELETE FROM chunk_vectors WHERE chunk_id IN (SELECT id FROM chunks WHERE document_id = ?)",
204 )
205 .bind(&document.id)
206 .execute(&mut *tx)
207 .await?;
208 sqlx::query(
209 "DELETE FROM embeddings WHERE chunk_id IN (SELECT id FROM chunks WHERE document_id = ?)",
210 )
211 .bind(&document.id)
212 .execute(&mut *tx)
213 .await?;
214 sqlx::query("DELETE FROM chunks_fts WHERE document_id = ?")
215 .bind(&document.id)
216 .execute(&mut *tx)
217 .await?;
218 sqlx::query("DELETE FROM chunks WHERE document_id = ?")
219 .bind(&document.id)
220 .execute(&mut *tx)
221 .await?;
222 for chunk in &chunks {
223 sqlx::query(
224 "INSERT INTO chunks (id, document_id, chunk_index, text, hash) VALUES (?, ?, ?, ?, ?)",
225 )
226 .bind(&chunk.id)
227 .bind(&chunk.document_id)
228 .bind(chunk.chunk_index)
229 .bind(&chunk.text)
230 .bind(&chunk.hash)
231 .execute(&mut *tx)
232 .await?;
233 sqlx::query("INSERT INTO chunks_fts (chunk_id, document_id, text) VALUES (?, ?, ?)")
234 .bind(&chunk.id)
235 .bind(&chunk.document_id)
236 .bind(&chunk.text)
237 .execute(&mut *tx)
238 .await?;
239 }
240 tx.commit().await?;
241
242 let sidecar_current = vector_index::remove_configured_sidecar(&self.config).is_ok();
243 Ok(CanonicalDocumentWrite {
244 document_id: document.id,
245 chunks,
246 sidecar_current,
247 })
248 }
249}
250
251#[allow(dead_code)] pub(crate) struct CanonicalDocumentWrite {
253 pub(crate) document_id: String,
254 pub(crate) chunks: Vec<Chunk>,
255 pub(crate) sidecar_current: bool,
256}
257
258#[async_trait]
259impl Store for SqliteAppStore {
260 async fn upsert_document(&self, doc: &Document) -> Result<String> {
261 self.core_store().upsert_document(doc).await
262 }
263
264 async fn replace_chunks(
265 &self,
266 doc_id: &str,
267 chunks: &[Chunk],
268 vectors: Option<&[Vec<f32>]>,
269 ) -> Result<()> {
270 self.core_store()
271 .replace_chunks(doc_id, chunks, vectors)
272 .await?;
273 vector_index::remove_configured_sidecar(&self.config)?;
274 Ok(())
275 }
276
277 async fn upsert_embedding(
278 &self,
279 chunk_id: &str,
280 doc_id: &str,
281 vector: &[f32],
282 model: &str,
283 dims: usize,
284 content_hash: &str,
285 ) -> Result<()> {
286 self.core_store()
287 .upsert_embedding(chunk_id, doc_id, vector, model, dims, content_hash)
288 .await?;
289 let record = VectorRecord {
290 chunk_id: chunk_id.to_string(),
291 document_id: doc_id.to_string(),
292 vector: vector.to_vec(),
293 model: model.to_string(),
294 dims,
295 content_hash: content_hash.to_string(),
296 };
297 vector_index::sync_vector_record_after_sqlite(&self.config, &self.pool, &record).await?;
298 Ok(())
299 }
300
301 async fn get_document(&self, id: &str) -> Result<Option<DocumentResponse>> {
302 self.core_store().get_document(id).await
303 }
304
305 async fn get_document_metadata(&self, id: &str) -> Result<Option<DocumentMetadata>> {
306 self.core_store().get_document_metadata(id).await
307 }
308
309 async fn keyword_search(
310 &self,
311 query: &str,
312 limit: i64,
313 source: Option<&str>,
314 since: Option<&str>,
315 ) -> Result<Vec<ChunkCandidate>> {
316 self.core_store()
317 .keyword_search(query, limit, source, since)
318 .await
319 }
320
321 async fn vector_search(
322 &self,
323 query_vec: &[f32],
324 limit: i64,
325 source: Option<&str>,
326 since: Option<&str>,
327 ) -> Result<Vec<ChunkCandidate>> {
328 self.core_store()
329 .vector_search(query_vec, limit, source, since)
330 .await
331 }
332}
333
334#[async_trait]
335impl AppStore for SqliteAppStore {
336 async fn initialize(&self) -> Result<()> {
337 migrate::run_migrations(&self.config).await
338 }
339
340 async fn get_checkpoint(&self, source: &str) -> Result<Option<i64>> {
341 let result: Option<String> =
342 sqlx::query_scalar("SELECT cursor FROM checkpoints WHERE source = ?")
343 .bind(source)
344 .fetch_optional(&self.pool)
345 .await?;
346
347 Ok(result.and_then(|s| s.parse::<i64>().ok()))
348 }
349
350 async fn set_checkpoint(&self, source: &str, cursor: i64) -> Result<()> {
351 let now = chrono::Utc::now().timestamp();
352 sqlx::query(
353 r#"
354 INSERT INTO checkpoints (source, cursor, updated_at) VALUES (?, ?, ?)
355 ON CONFLICT(source) DO UPDATE SET cursor = excluded.cursor, updated_at = excluded.updated_at
356 "#,
357 )
358 .bind(source)
359 .bind(cursor.to_string())
360 .bind(now)
361 .execute(&self.pool)
362 .await?;
363
364 Ok(())
365 }
366
367 async fn upsert_source_item(&self, item: &SourceItem) -> Result<String> {
368 let doc = source_item_to_document(&self.pool, item).await?;
369 self.upsert_document(&doc).await
370 }
371
372 async fn find_pending_chunks(
373 &self,
374 model: &str,
375 limit: Option<usize>,
376 ) -> Result<Vec<PendingChunk>> {
377 let limit_val = limit.unwrap_or(usize::MAX) as i64;
378
379 let rows = sqlx::query(
380 r#"
381 SELECT c.id AS chunk_id, c.document_id, c.text, c.hash AS chunk_hash
382 FROM chunks c
383 LEFT JOIN embeddings e ON e.chunk_id = c.id AND e.model = ?
384 WHERE e.chunk_id IS NULL OR e.hash != c.hash
385 ORDER BY c.document_id, c.chunk_index
386 LIMIT ?
387 "#,
388 )
389 .bind(model)
390 .bind(limit_val)
391 .fetch_all(&self.pool)
392 .await?;
393
394 Ok(rows
395 .iter()
396 .map(|row| {
397 let text: String = row.get("text");
398 PendingChunk {
399 chunk_id: row.get("chunk_id"),
400 document_id: row.get("document_id"),
401 text_hash: row.get("chunk_hash"),
402 text,
403 }
404 })
405 .collect())
406 }
407
408 async fn get_embedding_hash(&self, chunk_id: &str, model: &str) -> Result<Option<String>> {
409 let hash =
410 sqlx::query_scalar("SELECT hash FROM embeddings WHERE chunk_id = ? AND model = ?")
411 .bind(chunk_id)
412 .bind(model)
413 .fetch_optional(&self.pool)
414 .await?;
415 Ok(hash)
416 }
417
418 async fn clear_embeddings(&self) -> Result<()> {
419 sqlx::query("DELETE FROM chunk_vectors")
420 .execute(&self.pool)
421 .await?;
422 sqlx::query("DELETE FROM embeddings")
423 .execute(&self.pool)
424 .await?;
425 vector_index::remove_configured_sidecar(&self.config)?;
426 Ok(())
427 }
428
429 async fn stats(&self) -> Result<StoreStats> {
430 let total_docs: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM documents")
431 .fetch_one(&self.pool)
432 .await?;
433 let total_chunks: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM chunks")
434 .fetch_one(&self.pool)
435 .await?;
436 let total_embedded: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM chunk_vectors")
437 .fetch_one(&self.pool)
438 .await?;
439 let db_size_bytes = std::fs::metadata(&self.config.db.path)
440 .map(|m| m.len())
441 .unwrap_or(0);
442
443 let source_rows = sqlx::query(
444 r#"
445 SELECT
446 d.source,
447 COUNT(DISTINCT d.id) AS doc_count,
448 COUNT(DISTINCT c.id) AS chunk_count,
449 COUNT(DISTINCT cv.chunk_id) AS embedded_count
450 FROM documents d
451 LEFT JOIN chunks c ON c.document_id = d.id
452 LEFT JOIN chunk_vectors cv ON cv.chunk_id = c.id
453 GROUP BY d.source
454 ORDER BY doc_count DESC
455 "#,
456 )
457 .fetch_all(&self.pool)
458 .await?;
459
460 let checkpoint_rows = sqlx::query("SELECT source, updated_at FROM checkpoints")
461 .fetch_all(&self.pool)
462 .await?;
463
464 let mut sources = Vec::new();
465 for row in &source_rows {
466 let source: String = row.get("source");
467 let last_sync_ts = checkpoint_rows
468 .iter()
469 .find(|cp| {
470 let cp_source: String = cp.get("source");
471 cp_source == source
472 })
473 .map(|cp| cp.get::<i64, _>("updated_at"));
474
475 sources.push(SourceStats {
476 source,
477 doc_count: row.get("doc_count"),
478 chunk_count: row.get("chunk_count"),
479 embedded_count: row.get("embedded_count"),
480 last_sync_ts,
481 });
482 }
483
484 Ok(StoreStats {
485 total_docs,
486 total_chunks,
487 total_embedded,
488 db_size_bytes,
489 sources,
490 })
491 }
492
493 async fn export_index(&self) -> Result<ExportData> {
494 let doc_rows = sqlx::query(
495 "SELECT id, source, source_id, source_url, title, updated_at, body \
496 FROM documents ORDER BY source_id",
497 )
498 .fetch_all(&self.pool)
499 .await?;
500
501 let chunk_rows = sqlx::query(
502 "SELECT id, document_id, chunk_index, text \
503 FROM chunks ORDER BY document_id, chunk_index",
504 )
505 .fetch_all(&self.pool)
506 .await?;
507
508 let documents = doc_rows
509 .iter()
510 .map(|row| ExportDocument {
511 id: row.get("id"),
512 source: row.get("source"),
513 source_id: row.get("source_id"),
514 source_url: row.get("source_url"),
515 title: row.get("title"),
516 updated_at: row.get("updated_at"),
517 body: row.get("body"),
518 })
519 .collect();
520
521 let chunks = chunk_rows
522 .iter()
523 .map(|row| ExportChunk {
524 id: row.get("id"),
525 document_id: row.get("document_id"),
526 chunk_index: row.get("chunk_index"),
527 text: row.get("text"),
528 })
529 .collect();
530
531 Ok(ExportData { documents, chunks })
532 }
533}
534
535async fn source_item_to_document(pool: &SqlitePool, item: &SourceItem) -> Result<Document> {
536 let existing_id: Option<String> =
537 sqlx::query_scalar("SELECT id FROM documents WHERE source = ? AND source_id = ?")
538 .bind(&item.source)
539 .bind(&item.source_id)
540 .fetch_optional(pool)
541 .await?;
542
543 Ok(document_from_source_item(
544 existing_id.unwrap_or_else(|| Uuid::new_v4().to_string()),
545 item,
546 ))
547}
548
549fn document_from_source_item(id: String, item: &SourceItem) -> Document {
550 Document {
551 id,
552 source: item.source.clone(),
553 source_id: item.source_id.clone(),
554 source_url: item.source_url.clone(),
555 title: item.title.clone(),
556 author: item.author.clone(),
557 created_at: item.created_at.timestamp(),
558 updated_at: item.updated_at.timestamp(),
559 content_type: item.content_type.clone(),
560 body: item.body.clone(),
561 metadata_json: item.metadata_json.clone(),
562 raw_json: item.raw_json.clone(),
563 dedup_hash: dedup_hash(item),
564 }
565}
566
567fn dedup_hash(item: &SourceItem) -> String {
568 let mut hasher = Sha256::new();
569 hasher.update(item.source.as_bytes());
570 hasher.update(item.source_id.as_bytes());
571 hasher.update(item.updated_at.timestamp().to_le_bytes());
572 hasher.update(item.body.as_bytes());
573 format!("{:x}", hasher.finalize())
574}
575
576pub(crate) fn hash_text(text: &str) -> String {
577 let mut hasher = Sha256::new();
578 hasher.update(text.as_bytes());
579 format!("{:x}", hasher.finalize())
580}