use std::path::Path;
use std::str::FromStr;
use std::sync::Mutex;
use duckdb::{Connection, params};
use thiserror::Error;
use crate::domain::{
AnalysisProvenance, ClassificationRecord, DocumentClassification, ExtractionMethod,
IngestionRun, IngestionStage, SourceDigest, SourceFileName,
};
use crate::ports::{
ClassificationStore, ClassificationStoreError, IngestionStore, IngestionStoreError,
};
const INITIAL_SCHEMA: &str = include_str!("../../migrations/001_ingestion.sql");
const CLASSIFICATION_SCHEMA: &str = include_str!("../../migrations/002_classification.sql");
#[derive(Debug)]
pub struct DuckDbIngestionStore {
connection: Mutex<Connection>,
}
impl DuckDbIngestionStore {
pub fn open(path: &Path) -> Result<Self, StoreError> {
let connection = Connection::open(path).map_err(StoreError::Database)?;
connection
.execute_batch(INITIAL_SCHEMA)
.map_err(StoreError::Database)?;
connection
.execute_batch(CLASSIFICATION_SCHEMA)
.map_err(StoreError::Database)?;
Ok(Self {
connection: Mutex::new(connection),
})
}
pub fn save(&self, run: &IngestionRun) -> Result<(), StoreError> {
self.connection
.lock()
.map_err(|_| StoreError::LockPoisoned)?
.execute(
"INSERT INTO ingestion_runs (
source_digest, source_name, stage, note_path, archive_path, error
) VALUES (?, ?, ?, ?, ?, ?)
ON CONFLICT (source_digest) DO UPDATE SET
source_name = excluded.source_name,
stage = excluded.stage,
note_path = excluded.note_path,
archive_path = excluded.archive_path,
error = excluded.error",
params![
run.digest.as_str(),
run.source_name.as_str(),
run.stage.as_str(),
run.note_path.as_deref(),
run.archive_path.as_deref(),
run.error.as_deref(),
],
)
.map_err(StoreError::Database)?;
Ok(())
}
pub fn load(&self, digest: &SourceDigest) -> Result<Option<IngestionRun>, StoreError> {
let connection = self
.connection
.lock()
.map_err(|_| StoreError::LockPoisoned)?;
let mut statement = connection
.prepare(
"SELECT source_name, stage, note_path, archive_path, error
FROM ingestion_runs WHERE source_digest = ?",
)
.map_err(StoreError::Database)?;
let mut rows = statement
.query(params![digest.as_str()])
.map_err(StoreError::Database)?;
let Some(row) = rows.next().map_err(StoreError::Database)? else {
return Ok(None);
};
let source_name: String = row.get(0).map_err(StoreError::Database)?;
let stage: String = row.get(1).map_err(StoreError::Database)?;
Ok(Some(IngestionRun {
digest: digest.clone(),
source_name: SourceFileName::new(source_name).map_err(StoreError::invalid_record)?,
stage: IngestionStage::from_str(&stage).map_err(StoreError::invalid_record)?,
note_path: row.get(2).map_err(StoreError::Database)?,
archive_path: row.get(3).map_err(StoreError::Database)?,
error: row.get(4).map_err(StoreError::Database)?,
}))
}
fn save_classification(&self, record: &ClassificationRecord) -> Result<(), StoreError> {
record
.classification
.clone()
.validate()
.map_err(StoreError::invalid_record)?;
let authors = serde_json::to_string(&record.classification.authors)
.map_err(StoreError::Serialization)?;
let topics = serde_json::to_string(&record.classification.topics)
.map_err(StoreError::Serialization)?;
self.connection
.lock()
.map_err(|_| StoreError::LockPoisoned)?
.execute(
"INSERT INTO document_classifications (
source_digest, source_name, extraction_method, title, authors_json,
source_type, language, topics_json, analysis_function, analysis_client,
analysis_model, pipeline_version, processed_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT (source_digest) DO UPDATE SET
source_name = excluded.source_name,
extraction_method = excluded.extraction_method,
title = excluded.title,
authors_json = excluded.authors_json,
source_type = excluded.source_type,
language = excluded.language,
topics_json = excluded.topics_json,
analysis_function = excluded.analysis_function,
analysis_client = excluded.analysis_client,
analysis_model = excluded.analysis_model,
pipeline_version = excluded.pipeline_version,
processed_at = excluded.processed_at",
params![
record.source_digest.as_str(),
record.source_name.as_str(),
record.extraction_method.as_str(),
record.classification.title.as_str(),
authors,
record.classification.source_type.as_str(),
record.classification.language.as_str(),
topics,
record.analysis.function.as_str(),
record.analysis.client.as_str(),
record.analysis.model.as_str(),
record.analysis.pipeline_version.as_str(),
record.analysis.processed_at.as_str(),
],
)
.map_err(StoreError::Database)?;
Ok(())
}
fn load_classification(
&self,
digest: &SourceDigest,
) -> Result<Option<ClassificationRecord>, StoreError> {
let connection = self
.connection
.lock()
.map_err(|_| StoreError::LockPoisoned)?;
let mut statement = connection
.prepare(
"SELECT source_name, extraction_method, title, authors_json, source_type,
language, topics_json, analysis_function, analysis_client, analysis_model,
pipeline_version, processed_at
FROM document_classifications WHERE source_digest = ?",
)
.map_err(StoreError::Database)?;
let mut rows = statement
.query(params![digest.as_str()])
.map_err(StoreError::Database)?;
let Some(row) = rows.next().map_err(StoreError::Database)? else {
return Ok(None);
};
let source_name: String = row.get(0).map_err(StoreError::Database)?;
let extraction_method: String = row.get(1).map_err(StoreError::Database)?;
let authors: String = row.get(3).map_err(StoreError::Database)?;
let topics: String = row.get(6).map_err(StoreError::Database)?;
let classification = DocumentClassification {
title: row.get(2).map_err(StoreError::Database)?,
authors: serde_json::from_str(&authors).map_err(StoreError::Serialization)?,
source_type: row.get(4).map_err(StoreError::Database)?,
language: row.get(5).map_err(StoreError::Database)?,
topics: serde_json::from_str(&topics).map_err(StoreError::Serialization)?,
}
.validate()
.map_err(StoreError::invalid_record)?;
Ok(Some(ClassificationRecord {
source_digest: digest.clone(),
source_name: SourceFileName::new(source_name).map_err(StoreError::invalid_record)?,
extraction_method: ExtractionMethod::from_str(&extraction_method)
.map_err(StoreError::invalid_record)?,
classification,
analysis: AnalysisProvenance {
function: row.get(7).map_err(StoreError::Database)?,
client: row.get(8).map_err(StoreError::Database)?,
model: row.get(9).map_err(StoreError::Database)?,
pipeline_version: row.get(10).map_err(StoreError::Database)?,
processed_at: row.get(11).map_err(StoreError::Database)?,
},
}))
}
}
#[derive(Debug, Error)]
pub enum StoreError {
#[error("DuckDB operation failed: {0}")]
Database(#[source] duckdb::Error),
#[error("invalid persisted record: {0}")]
InvalidRecord(String),
#[error("classification serialization failed: {0}")]
Serialization(#[source] serde_json::Error),
#[error("DuckDB connection lock was poisoned")]
LockPoisoned,
}
impl IngestionStore for DuckDbIngestionStore {
fn load(&self, digest: &SourceDigest) -> Result<Option<IngestionRun>, IngestionStoreError> {
Self::load(self, digest).map_err(|error| IngestionStoreError(error.to_string()))
}
fn save(&self, run: &IngestionRun) -> Result<(), IngestionStoreError> {
Self::save(self, run).map_err(|error| IngestionStoreError(error.to_string()))
}
}
impl ClassificationStore for DuckDbIngestionStore {
fn load(
&self,
digest: &SourceDigest,
) -> Result<Option<ClassificationRecord>, ClassificationStoreError> {
self.load_classification(digest)
.map_err(|error| ClassificationStoreError(error.to_string()))
}
fn save(&self, record: &ClassificationRecord) -> Result<(), ClassificationStoreError> {
self.save_classification(record)
.map_err(|error| ClassificationStoreError(error.to_string()))
}
}
impl StoreError {
fn invalid_record(error: impl std::error::Error) -> Self {
Self::InvalidRecord(error.to_string())
}
}