1use zeph_db::{DbPool, query, query_as, query_scalar, sql};
46
47use crate::MemoryError;
48
49#[derive(Debug, Clone, PartialEq, Eq)]
55pub enum BatchIdResolution {
56 Resolved(String),
58 Ambiguous(Vec<String>),
60 NotFound,
62}
63
64#[derive(Debug, Clone, sqlx::FromRow)]
68pub struct LedgerEntry {
69 pub source_uri: String,
71 pub content_hash: String,
73 pub import_batch_id: String,
75 pub ingested_at: String,
77 pub entities: i64,
79 pub edges: i64,
81}
82
83#[derive(Clone)]
93pub struct IngestLedger {
94 pool: DbPool,
95}
96
97impl IngestLedger {
98 #[must_use]
103 pub fn new(pool: DbPool) -> Self {
104 Self { pool }
105 }
106
107 #[must_use]
122 pub fn content_hash(bytes: &[u8]) -> String {
123 blake3::Hasher::new()
124 .update(bytes)
125 .finalize()
126 .to_hex()
127 .to_string()
128 }
129
130 pub async fn is_ingested(
139 &self,
140 source_uri: &str,
141 content_hash: &str,
142 ) -> Result<bool, MemoryError> {
143 let exists: Option<i64> = query_scalar(sql!(
144 "SELECT 1 FROM knowledge_ingest_ledger \
145 WHERE source_uri = ? AND content_hash = ?"
146 ))
147 .bind(source_uri)
148 .bind(content_hash)
149 .fetch_optional(&self.pool)
150 .await?;
151 Ok(exists.is_some())
152 }
153
154 pub async fn mark_ingested(
167 &self,
168 source_uri: &str,
169 content_hash: &str,
170 batch_id: &str,
171 entities: i64,
172 edges: i64,
173 ) -> Result<(), MemoryError> {
174 let now = chrono::Utc::now().to_rfc3339();
175 query(sql!(
176 "INSERT INTO knowledge_ingest_ledger \
177 (source_uri, content_hash, import_batch_id, ingested_at, entities, edges) \
178 VALUES (?, ?, ?, ?, ?, ?) \
179 ON CONFLICT(source_uri, content_hash) DO UPDATE SET \
180 import_batch_id = excluded.import_batch_id, \
181 ingested_at = excluded.ingested_at, \
182 entities = excluded.entities, \
183 edges = excluded.edges"
184 ))
185 .bind(source_uri)
186 .bind(content_hash)
187 .bind(batch_id)
188 .bind(&now)
189 .bind(entities)
190 .bind(edges)
191 .execute(&self.pool)
192 .await?;
193 Ok(())
194 }
195
196 pub async fn batch_exists(&self, batch_id: &str) -> Result<bool, MemoryError> {
202 let exists: Option<i64> = query_scalar(sql!(
203 "SELECT 1 FROM knowledge_ingest_ledger WHERE import_batch_id = ?"
204 ))
205 .bind(batch_id)
206 .fetch_optional(&self.pool)
207 .await?;
208 Ok(exists.is_some())
209 }
210
211 pub async fn resolve_batch_id(&self, prefix: &str) -> Result<BatchIdResolution, MemoryError> {
229 if prefix.trim().is_empty() {
230 return Ok(BatchIdResolution::NotFound);
231 }
232
233 let ids: Vec<String> = query_scalar(sql!(
234 "SELECT DISTINCT import_batch_id FROM knowledge_ingest_ledger"
235 ))
236 .fetch_all(&self.pool)
237 .await?;
238
239 if ids.iter().any(|id| id == prefix) {
240 return Ok(BatchIdResolution::Resolved(prefix.to_owned()));
241 }
242
243 let mut matches: Vec<String> = ids
244 .into_iter()
245 .filter(|id| id.starts_with(prefix))
246 .collect();
247 match matches.len() {
248 0 => Ok(BatchIdResolution::NotFound),
249 1 => Ok(BatchIdResolution::Resolved(matches.remove(0))),
250 _ => Ok(BatchIdResolution::Ambiguous(matches)),
251 }
252 }
253
254 pub async fn delete_batch(&self, batch_id: &str) -> Result<u64, MemoryError> {
262 let result = query(sql!(
263 "DELETE FROM knowledge_ingest_ledger WHERE import_batch_id = ?"
264 ))
265 .bind(batch_id)
266 .execute(&self.pool)
267 .await?;
268 Ok(result.rows_affected())
269 }
270
271 pub async fn delete_batch_in_tx(
281 &self,
282 batch_id: &str,
283 tx: &mut zeph_db::DbTransaction<'_>,
284 ) -> Result<u64, MemoryError> {
285 let result = query(sql!(
286 "DELETE FROM knowledge_ingest_ledger WHERE import_batch_id = ?"
287 ))
288 .bind(batch_id)
289 .execute(&mut **tx)
290 .await?;
291 Ok(result.rows_affected())
292 }
293
294 pub async fn summary(&self) -> Result<Vec<LedgerEntry>, MemoryError> {
302 let rows: Vec<LedgerEntry> = query_as(sql!(
303 "SELECT source_uri, content_hash, import_batch_id, ingested_at, entities, edges \
304 FROM knowledge_ingest_ledger \
305 ORDER BY ingested_at DESC"
306 ))
307 .fetch_all(&self.pool)
308 .await?;
309 Ok(rows)
310 }
311}
312
313#[cfg(all(test, feature = "sqlite"))]
316mod tests {
317 use super::*;
318
319 async fn test_ledger() -> IngestLedger {
320 let pool = sqlx::SqlitePool::connect(":memory:")
321 .await
322 .expect("in-memory sqlite");
323 sqlx::query(
324 "CREATE TABLE knowledge_ingest_ledger (\
325 source_uri TEXT NOT NULL, \
326 content_hash TEXT NOT NULL, \
327 import_batch_id TEXT NOT NULL, \
328 ingested_at TEXT NOT NULL DEFAULT (datetime('now')), \
329 entities INTEGER NOT NULL DEFAULT 0, \
330 edges INTEGER NOT NULL DEFAULT 0, \
331 PRIMARY KEY (source_uri, content_hash)\
332 )",
333 )
334 .execute(&pool)
335 .await
336 .expect("create table");
337 IngestLedger::new(pool)
338 }
339
340 #[test]
341 fn content_hash_is_hex64() {
342 let h = IngestLedger::content_hash(b"hello");
343 assert_eq!(h.len(), 64);
344 assert!(h.chars().all(|c| c.is_ascii_hexdigit()));
345 }
346
347 #[test]
348 fn content_hash_deterministic() {
349 assert_eq!(
350 IngestLedger::content_hash(b"zeph"),
351 IngestLedger::content_hash(b"zeph"),
352 );
353 }
354
355 #[test]
356 fn content_hash_differs_for_different_inputs() {
357 assert_ne!(
358 IngestLedger::content_hash(b"foo"),
359 IngestLedger::content_hash(b"bar"),
360 );
361 }
362
363 #[tokio::test]
364 async fn is_ingested_false_when_absent() {
365 let ledger = test_ledger().await;
366 assert!(!ledger.is_ingested("uri", "hash").await.unwrap());
367 }
368
369 #[tokio::test]
370 async fn mark_then_is_ingested_round_trip() {
371 let ledger = test_ledger().await;
372 let uri = "specs/README.md@abc123";
373 let hash = IngestLedger::content_hash(b"content");
374 ledger
375 .mark_ingested(uri, &hash, "batch-1", 0, 0)
376 .await
377 .unwrap();
378 assert!(ledger.is_ingested(uri, &hash).await.unwrap());
379 }
380
381 #[tokio::test]
382 async fn different_hash_not_ingested() {
383 let ledger = test_ledger().await;
384 let uri = "specs/README.md@abc123";
385 let hash1 = IngestLedger::content_hash(b"v1");
386 let hash2 = IngestLedger::content_hash(b"v2");
387 ledger
388 .mark_ingested(uri, &hash1, "batch-1", 0, 0)
389 .await
390 .unwrap();
391 assert!(!ledger.is_ingested(uri, &hash2).await.unwrap());
392 }
393
394 #[tokio::test]
395 async fn mark_ingested_idempotent() {
396 let ledger = test_ledger().await;
397 let uri = "specs/README.md@abc123";
398 let hash = IngestLedger::content_hash(b"content");
399 ledger
400 .mark_ingested(uri, &hash, "batch-1", 0, 0)
401 .await
402 .unwrap();
403 ledger
404 .mark_ingested(uri, &hash, "batch-2", 5, 3)
405 .await
406 .unwrap();
407 let rows = ledger.summary().await.unwrap();
408 assert_eq!(rows.len(), 1);
409 assert_eq!(rows[0].import_batch_id, "batch-2");
410 assert_eq!(rows[0].entities, 5);
411 assert_eq!(rows[0].edges, 3);
412 }
413
414 #[tokio::test]
415 async fn summary_returns_all_rows() {
416 let ledger = test_ledger().await;
417 ledger.mark_ingested("a", "h1", "b1", 0, 0).await.unwrap();
418 ledger.mark_ingested("b", "h2", "b1", 0, 0).await.unwrap();
419 let rows = ledger.summary().await.unwrap();
420 assert_eq!(rows.len(), 2);
421 }
422
423 #[tokio::test]
424 async fn summary_empty_when_no_rows() {
425 let ledger = test_ledger().await;
426 assert!(ledger.summary().await.unwrap().is_empty());
427 }
428
429 #[tokio::test]
430 async fn batch_exists_false_when_no_rows() {
431 let ledger = test_ledger().await;
432 assert!(!ledger.batch_exists("nonexistent-batch").await.unwrap());
433 }
434
435 #[tokio::test]
436 async fn batch_exists_true_after_mark_ingested() {
437 let ledger = test_ledger().await;
438 ledger
439 .mark_ingested("uri", "hash", "batch-42", 0, 0)
440 .await
441 .unwrap();
442 assert!(ledger.batch_exists("batch-42").await.unwrap());
443 assert!(!ledger.batch_exists("batch-99").await.unwrap());
444 }
445
446 #[tokio::test]
447 async fn delete_batch_removes_matching_rows_only() {
448 let ledger = test_ledger().await;
449 ledger
450 .mark_ingested("a", "h1", "batch-A", 0, 0)
451 .await
452 .unwrap();
453 ledger
454 .mark_ingested("b", "h2", "batch-A", 0, 0)
455 .await
456 .unwrap();
457 ledger
458 .mark_ingested("c", "h3", "batch-B", 0, 0)
459 .await
460 .unwrap();
461
462 let removed = ledger.delete_batch("batch-A").await.unwrap();
463 assert_eq!(removed, 2);
464
465 assert!(!ledger.batch_exists("batch-A").await.unwrap());
466 assert!(ledger.batch_exists("batch-B").await.unwrap());
467 }
468
469 #[tokio::test]
470 async fn delete_batch_returns_zero_for_missing_batch() {
471 let ledger = test_ledger().await;
472 let removed = ledger.delete_batch("ghost-batch").await.unwrap();
473 assert_eq!(removed, 0);
474 }
475
476 #[tokio::test]
477 async fn resolve_batch_id_not_found_when_no_match() {
478 let ledger = test_ledger().await;
479 ledger
480 .mark_ingested("a", "h1", "01234567-aaaa", 0, 0)
481 .await
482 .unwrap();
483 assert_eq!(
484 ledger.resolve_batch_id("ghost").await.unwrap(),
485 BatchIdResolution::NotFound
486 );
487 }
488
489 #[tokio::test]
490 async fn resolve_batch_id_empty_prefix_is_not_found_even_with_a_single_batch() {
491 let ledger = test_ledger().await;
492 ledger
493 .mark_ingested("a", "h1", "01234567-aaaa", 0, 0)
494 .await
495 .unwrap();
496 assert_eq!(
497 ledger.resolve_batch_id("").await.unwrap(),
498 BatchIdResolution::NotFound
499 );
500 assert_eq!(
501 ledger.resolve_batch_id(" ").await.unwrap(),
502 BatchIdResolution::NotFound
503 );
504 }
505
506 #[tokio::test]
507 async fn resolve_batch_id_exact_match_wins_even_if_also_a_prefix() {
508 let ledger = test_ledger().await;
509 ledger.mark_ingested("a", "h1", "0123", 0, 0).await.unwrap();
510 ledger
511 .mark_ingested("b", "h2", "01234567", 0, 0)
512 .await
513 .unwrap();
514 assert_eq!(
515 ledger.resolve_batch_id("0123").await.unwrap(),
516 BatchIdResolution::Resolved("0123".to_owned())
517 );
518 }
519
520 #[tokio::test]
521 async fn resolve_batch_id_unique_prefix_resolves_to_full_id() {
522 let ledger = test_ledger().await;
523 ledger
524 .mark_ingested("a", "h1", "01234567-aaaa-bbbb-cccc", 0, 0)
525 .await
526 .unwrap();
527 ledger
528 .mark_ingested("b", "h2", "89abcdef-aaaa-bbbb-cccc", 0, 0)
529 .await
530 .unwrap();
531 assert_eq!(
532 ledger.resolve_batch_id("012345").await.unwrap(),
533 BatchIdResolution::Resolved("01234567-aaaa-bbbb-cccc".to_owned())
534 );
535 }
536
537 #[tokio::test]
538 async fn resolve_batch_id_ambiguous_prefix_lists_candidates() {
539 let ledger = test_ledger().await;
540 ledger
541 .mark_ingested("a", "h1", "01234567-aaaa", 0, 0)
542 .await
543 .unwrap();
544 ledger
545 .mark_ingested("b", "h2", "01234567-bbbb", 0, 0)
546 .await
547 .unwrap();
548 let resolution = ledger.resolve_batch_id("01234567").await.unwrap();
549 match resolution {
550 BatchIdResolution::Ambiguous(mut candidates) => {
551 candidates.sort();
552 assert_eq!(candidates, ["01234567-aaaa", "01234567-bbbb"]);
553 }
554 other => panic!("expected Ambiguous, got {other:?}"),
555 }
556 }
557}