use zeph_db::{DbPool, query, query_as, query_scalar, sql};
use crate::MemoryError;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum BatchIdResolution {
Resolved(String),
Ambiguous(Vec<String>),
NotFound,
}
#[derive(Debug, Clone, sqlx::FromRow)]
pub struct LedgerEntry {
pub source_uri: String,
pub content_hash: String,
pub import_batch_id: String,
pub ingested_at: String,
pub entities: i64,
pub edges: i64,
}
#[derive(Clone)]
pub struct IngestLedger {
pool: DbPool,
}
impl IngestLedger {
#[must_use]
pub fn new(pool: DbPool) -> Self {
Self { pool }
}
#[must_use]
pub fn content_hash(bytes: &[u8]) -> String {
blake3::Hasher::new()
.update(bytes)
.finalize()
.to_hex()
.to_string()
}
pub async fn is_ingested(
&self,
source_uri: &str,
content_hash: &str,
) -> Result<bool, MemoryError> {
let exists: Option<i64> = query_scalar(sql!(
"SELECT 1 FROM knowledge_ingest_ledger \
WHERE source_uri = ? AND content_hash = ?"
))
.bind(source_uri)
.bind(content_hash)
.fetch_optional(&self.pool)
.await?;
Ok(exists.is_some())
}
pub async fn mark_ingested(
&self,
source_uri: &str,
content_hash: &str,
batch_id: &str,
entities: i64,
edges: i64,
) -> Result<(), MemoryError> {
let now = chrono::Utc::now().to_rfc3339();
query(sql!(
"INSERT INTO knowledge_ingest_ledger \
(source_uri, content_hash, import_batch_id, ingested_at, entities, edges) \
VALUES (?, ?, ?, ?, ?, ?) \
ON CONFLICT(source_uri, content_hash) DO UPDATE SET \
import_batch_id = excluded.import_batch_id, \
ingested_at = excluded.ingested_at, \
entities = excluded.entities, \
edges = excluded.edges"
))
.bind(source_uri)
.bind(content_hash)
.bind(batch_id)
.bind(&now)
.bind(entities)
.bind(edges)
.execute(&self.pool)
.await?;
Ok(())
}
pub async fn batch_exists(&self, batch_id: &str) -> Result<bool, MemoryError> {
let exists: Option<i64> = query_scalar(sql!(
"SELECT 1 FROM knowledge_ingest_ledger WHERE import_batch_id = ?"
))
.bind(batch_id)
.fetch_optional(&self.pool)
.await?;
Ok(exists.is_some())
}
pub async fn resolve_batch_id(&self, prefix: &str) -> Result<BatchIdResolution, MemoryError> {
if prefix.trim().is_empty() {
return Ok(BatchIdResolution::NotFound);
}
let ids: Vec<String> = query_scalar(sql!(
"SELECT DISTINCT import_batch_id FROM knowledge_ingest_ledger"
))
.fetch_all(&self.pool)
.await?;
if ids.iter().any(|id| id == prefix) {
return Ok(BatchIdResolution::Resolved(prefix.to_owned()));
}
let mut matches: Vec<String> = ids
.into_iter()
.filter(|id| id.starts_with(prefix))
.collect();
match matches.len() {
0 => Ok(BatchIdResolution::NotFound),
1 => Ok(BatchIdResolution::Resolved(matches.remove(0))),
_ => Ok(BatchIdResolution::Ambiguous(matches)),
}
}
pub async fn delete_batch(&self, batch_id: &str) -> Result<u64, MemoryError> {
let result = query(sql!(
"DELETE FROM knowledge_ingest_ledger WHERE import_batch_id = ?"
))
.bind(batch_id)
.execute(&self.pool)
.await?;
Ok(result.rows_affected())
}
pub async fn delete_batch_in_tx(
&self,
batch_id: &str,
tx: &mut zeph_db::DbTransaction<'_>,
) -> Result<u64, MemoryError> {
let result = query(sql!(
"DELETE FROM knowledge_ingest_ledger WHERE import_batch_id = ?"
))
.bind(batch_id)
.execute(&mut **tx)
.await?;
Ok(result.rows_affected())
}
pub async fn summary(&self) -> Result<Vec<LedgerEntry>, MemoryError> {
let rows: Vec<LedgerEntry> = query_as(sql!(
"SELECT source_uri, content_hash, import_batch_id, ingested_at, entities, edges \
FROM knowledge_ingest_ledger \
ORDER BY ingested_at DESC"
))
.fetch_all(&self.pool)
.await?;
Ok(rows)
}
}
#[cfg(all(test, feature = "sqlite"))]
mod tests {
use super::*;
async fn test_ledger() -> IngestLedger {
let pool = sqlx::SqlitePool::connect(":memory:")
.await
.expect("in-memory sqlite");
sqlx::query(
"CREATE TABLE knowledge_ingest_ledger (\
source_uri TEXT NOT NULL, \
content_hash TEXT NOT NULL, \
import_batch_id TEXT NOT NULL, \
ingested_at TEXT NOT NULL DEFAULT (datetime('now')), \
entities INTEGER NOT NULL DEFAULT 0, \
edges INTEGER NOT NULL DEFAULT 0, \
PRIMARY KEY (source_uri, content_hash)\
)",
)
.execute(&pool)
.await
.expect("create table");
IngestLedger::new(pool)
}
#[test]
fn content_hash_is_hex64() {
let h = IngestLedger::content_hash(b"hello");
assert_eq!(h.len(), 64);
assert!(h.chars().all(|c| c.is_ascii_hexdigit()));
}
#[test]
fn content_hash_deterministic() {
assert_eq!(
IngestLedger::content_hash(b"zeph"),
IngestLedger::content_hash(b"zeph"),
);
}
#[test]
fn content_hash_differs_for_different_inputs() {
assert_ne!(
IngestLedger::content_hash(b"foo"),
IngestLedger::content_hash(b"bar"),
);
}
#[tokio::test]
async fn is_ingested_false_when_absent() {
let ledger = test_ledger().await;
assert!(!ledger.is_ingested("uri", "hash").await.unwrap());
}
#[tokio::test]
async fn mark_then_is_ingested_round_trip() {
let ledger = test_ledger().await;
let uri = "specs/README.md@abc123";
let hash = IngestLedger::content_hash(b"content");
ledger
.mark_ingested(uri, &hash, "batch-1", 0, 0)
.await
.unwrap();
assert!(ledger.is_ingested(uri, &hash).await.unwrap());
}
#[tokio::test]
async fn different_hash_not_ingested() {
let ledger = test_ledger().await;
let uri = "specs/README.md@abc123";
let hash1 = IngestLedger::content_hash(b"v1");
let hash2 = IngestLedger::content_hash(b"v2");
ledger
.mark_ingested(uri, &hash1, "batch-1", 0, 0)
.await
.unwrap();
assert!(!ledger.is_ingested(uri, &hash2).await.unwrap());
}
#[tokio::test]
async fn mark_ingested_idempotent() {
let ledger = test_ledger().await;
let uri = "specs/README.md@abc123";
let hash = IngestLedger::content_hash(b"content");
ledger
.mark_ingested(uri, &hash, "batch-1", 0, 0)
.await
.unwrap();
ledger
.mark_ingested(uri, &hash, "batch-2", 5, 3)
.await
.unwrap();
let rows = ledger.summary().await.unwrap();
assert_eq!(rows.len(), 1);
assert_eq!(rows[0].import_batch_id, "batch-2");
assert_eq!(rows[0].entities, 5);
assert_eq!(rows[0].edges, 3);
}
#[tokio::test]
async fn summary_returns_all_rows() {
let ledger = test_ledger().await;
ledger.mark_ingested("a", "h1", "b1", 0, 0).await.unwrap();
ledger.mark_ingested("b", "h2", "b1", 0, 0).await.unwrap();
let rows = ledger.summary().await.unwrap();
assert_eq!(rows.len(), 2);
}
#[tokio::test]
async fn summary_empty_when_no_rows() {
let ledger = test_ledger().await;
assert!(ledger.summary().await.unwrap().is_empty());
}
#[tokio::test]
async fn batch_exists_false_when_no_rows() {
let ledger = test_ledger().await;
assert!(!ledger.batch_exists("nonexistent-batch").await.unwrap());
}
#[tokio::test]
async fn batch_exists_true_after_mark_ingested() {
let ledger = test_ledger().await;
ledger
.mark_ingested("uri", "hash", "batch-42", 0, 0)
.await
.unwrap();
assert!(ledger.batch_exists("batch-42").await.unwrap());
assert!(!ledger.batch_exists("batch-99").await.unwrap());
}
#[tokio::test]
async fn delete_batch_removes_matching_rows_only() {
let ledger = test_ledger().await;
ledger
.mark_ingested("a", "h1", "batch-A", 0, 0)
.await
.unwrap();
ledger
.mark_ingested("b", "h2", "batch-A", 0, 0)
.await
.unwrap();
ledger
.mark_ingested("c", "h3", "batch-B", 0, 0)
.await
.unwrap();
let removed = ledger.delete_batch("batch-A").await.unwrap();
assert_eq!(removed, 2);
assert!(!ledger.batch_exists("batch-A").await.unwrap());
assert!(ledger.batch_exists("batch-B").await.unwrap());
}
#[tokio::test]
async fn delete_batch_returns_zero_for_missing_batch() {
let ledger = test_ledger().await;
let removed = ledger.delete_batch("ghost-batch").await.unwrap();
assert_eq!(removed, 0);
}
#[tokio::test]
async fn resolve_batch_id_not_found_when_no_match() {
let ledger = test_ledger().await;
ledger
.mark_ingested("a", "h1", "01234567-aaaa", 0, 0)
.await
.unwrap();
assert_eq!(
ledger.resolve_batch_id("ghost").await.unwrap(),
BatchIdResolution::NotFound
);
}
#[tokio::test]
async fn resolve_batch_id_empty_prefix_is_not_found_even_with_a_single_batch() {
let ledger = test_ledger().await;
ledger
.mark_ingested("a", "h1", "01234567-aaaa", 0, 0)
.await
.unwrap();
assert_eq!(
ledger.resolve_batch_id("").await.unwrap(),
BatchIdResolution::NotFound
);
assert_eq!(
ledger.resolve_batch_id(" ").await.unwrap(),
BatchIdResolution::NotFound
);
}
#[tokio::test]
async fn resolve_batch_id_exact_match_wins_even_if_also_a_prefix() {
let ledger = test_ledger().await;
ledger.mark_ingested("a", "h1", "0123", 0, 0).await.unwrap();
ledger
.mark_ingested("b", "h2", "01234567", 0, 0)
.await
.unwrap();
assert_eq!(
ledger.resolve_batch_id("0123").await.unwrap(),
BatchIdResolution::Resolved("0123".to_owned())
);
}
#[tokio::test]
async fn resolve_batch_id_unique_prefix_resolves_to_full_id() {
let ledger = test_ledger().await;
ledger
.mark_ingested("a", "h1", "01234567-aaaa-bbbb-cccc", 0, 0)
.await
.unwrap();
ledger
.mark_ingested("b", "h2", "89abcdef-aaaa-bbbb-cccc", 0, 0)
.await
.unwrap();
assert_eq!(
ledger.resolve_batch_id("012345").await.unwrap(),
BatchIdResolution::Resolved("01234567-aaaa-bbbb-cccc".to_owned())
);
}
#[tokio::test]
async fn resolve_batch_id_ambiguous_prefix_lists_candidates() {
let ledger = test_ledger().await;
ledger
.mark_ingested("a", "h1", "01234567-aaaa", 0, 0)
.await
.unwrap();
ledger
.mark_ingested("b", "h2", "01234567-bbbb", 0, 0)
.await
.unwrap();
let resolution = ledger.resolve_batch_id("01234567").await.unwrap();
match resolution {
BatchIdResolution::Ambiguous(mut candidates) => {
candidates.sort();
assert_eq!(candidates, ["01234567-aaaa", "01234567-bbbb"]);
}
other => panic!("expected Ambiguous, got {other:?}"),
}
}
}