use anyhow::{Context, Result, bail};
use chrono::{DateTime, Utc};
use serde::Deserialize;
use std::collections::HashSet;
use std::path::Path;
use crate::engine::crawl_graph::{CrawlGraphStore, DiscoverySource, UrlNode};
use crate::engine::util::url_id;
use crate::schema::content::PageContentRecord;
use crate::storage::turso_store::TursoStore;
const EXPORT_VERSION: u32 = 1;
#[derive(Debug, Clone)]
pub struct MigrationReport {
pub url_nodes_imported: usize,
pub link_edges_imported: usize,
pub page_content_imported: usize,
pub crawl_jobs_imported: usize,
pub embedding_dimension: Option<usize>,
}
#[derive(Debug, Deserialize)]
pub struct MigrationExport {
#[serde(default)]
pub version: u32,
#[serde(default)]
pub exported_at: Option<String>,
#[serde(default)]
pub url_nodes: Vec<ExportUrlNode>,
#[serde(default)]
pub link_edges: Vec<ExportLinkEdge>,
#[serde(default)]
pub page_content: Vec<ExportPageContent>,
#[serde(default)]
pub crawl_jobs: Vec<ExportCrawlJob>,
}
#[derive(Debug, Deserialize)]
pub struct ExportUrlNode {
pub url: String,
#[serde(default)]
pub domain: Option<String>,
#[serde(default)]
pub source: Option<String>,
#[serde(default)]
pub depth: Option<u32>,
#[serde(default)]
pub priority: Option<f32>,
#[serde(default)]
pub lastmod: Option<String>,
#[serde(default)]
pub changefreq: Option<String>,
#[serde(default)]
pub discovered_at: Option<String>,
#[serde(default)]
pub crawled: Option<bool>,
#[serde(default)]
pub excerpt: Option<String>,
#[serde(default)]
pub embedding: Option<Vec<f32>>,
}
#[derive(Debug, Deserialize)]
pub struct ExportLinkEdge {
pub from: String,
pub to: String,
#[serde(default)]
pub anchor_text: Option<String>,
}
#[derive(Debug, Deserialize)]
pub struct ExportPageContent {
pub url: String,
#[serde(default)]
pub content_text: Option<String>,
#[serde(default)]
pub content_markdown: Option<String>,
#[serde(default)]
pub content_html: Option<String>,
#[serde(default)]
pub excerpt: Option<String>,
#[serde(default)]
pub content_hash: Option<String>,
#[serde(default)]
pub word_count: Option<u32>,
#[serde(default)]
pub reading_time_seconds: Option<u32>,
#[serde(default)]
pub fetched_at: Option<String>,
#[serde(default)]
pub created_at: Option<String>,
}
#[derive(Debug, Deserialize)]
pub struct ExportCrawlJob {
pub url: String,
#[serde(default)]
pub status: Option<String>,
#[serde(default)]
pub attempts: Option<i32>,
#[serde(default)]
pub error: Option<String>,
#[serde(default)]
pub created_at: Option<String>,
#[serde(default)]
pub updated_at: Option<String>,
}
pub async fn migrate_graph(
from_path: impl AsRef<Path>,
to_path: impl AsRef<Path>,
) -> Result<MigrationReport> {
let from_path = from_path.as_ref();
let to_path = to_path.as_ref();
let export = load_export(from_path).await?;
if export.version != EXPORT_VERSION {
bail!(
"unsupported export version {found}; expected {expected}",
found = export.version,
expected = EXPORT_VERSION
);
}
let dim = validate_embedding_dimensions(&export.url_nodes)?;
let store = TursoStore::new(to_path.to_str().unwrap_or("webfind.db"))
.await
.with_context(|| {
format!(
"create destination Turso database at `{}`",
to_path.display()
)
})?;
let mut url_set: HashSet<String> = HashSet::with_capacity(export.url_nodes.len());
let mut node_batch: Vec<(UrlNode, Option<String>, Option<Vec<f32>>)> =
Vec::with_capacity(export.url_nodes.len());
for node in &export.url_nodes {
let domain = node
.domain
.clone()
.or_else(|| crate::engine::util::extract_domain(&node.url))
.unwrap_or_default();
node_batch.push((
UrlNode {
url: node.url.clone(),
domain,
source: parse_source(node.source.as_deref()),
depth: node.depth.unwrap_or(0),
priority: node.priority.unwrap_or(1.0),
lastmod: node.lastmod.as_deref().map(|s| parse_ts(Some(s))),
changefreq: node.changefreq.clone(),
discovered_at: parse_ts(node.discovered_at.as_deref()),
crawled: node.crawled.unwrap_or(false),
},
node.excerpt.clone(),
node.embedding.clone(),
));
url_set.insert(node.url.clone());
}
let mut edge_endpoints: HashSet<String> = HashSet::new();
for edge in &export.link_edges {
edge_endpoints.insert(edge.from.clone());
edge_endpoints.insert(edge.to.clone());
}
for url in edge_endpoints.difference(&url_set) {
let domain = crate::engine::util::extract_domain(url).unwrap_or_default();
node_batch.push((
UrlNode {
url: url.clone(),
domain,
source: DiscoverySource::LinkCrawl,
depth: 0,
priority: 1.0,
lastmod: None,
changefreq: None,
discovered_at: Utc::now(),
crawled: false,
},
None,
None,
));
}
let mut edge_batch: Vec<(String, String, Option<String>)> =
Vec::with_capacity(export.link_edges.len());
for edge in &export.link_edges {
edge_batch.push((edge.from.clone(), edge.to.clone(), edge.anchor_text.clone()));
}
let (imported_nodes, imported_edges) = store
.migrate_batch(&node_batch, &edge_batch)
.await
.context("batch-import url_nodes + link_edges")?;
assert_eq!(imported_edges, edge_batch.len());
let mut imported_content = 0usize;
for content in &export.page_content {
import_page_content(&store, content)
.await
.with_context(|| format!("import page content for `{}`", content.url))?;
imported_content += 1;
}
let mut imported_jobs = 0usize;
for job in &export.crawl_jobs {
import_crawl_job(&store, job)
.await
.with_context(|| format!("import crawl job `{}`", job.url))?;
imported_jobs += 1;
}
store
.rebuild_fts()
.await
.context("rebuild FTS index after migration")?;
store
.compute_pagerank(20, 0.85)
.await
.context("compute PageRank after migration")?;
verify_row_counts(
&store,
imported_nodes,
imported_edges,
imported_content,
imported_jobs,
)
.await?;
let report = MigrationReport {
url_nodes_imported: imported_nodes,
link_edges_imported: imported_edges,
page_content_imported: imported_content,
crawl_jobs_imported: imported_jobs,
embedding_dimension: dim,
};
tracing::info!(
url_nodes = report.url_nodes_imported,
link_edges = report.link_edges_imported,
page_content = report.page_content_imported,
crawl_jobs = report.crawl_jobs_imported,
embedding_dim = ?report.embedding_dimension,
"migration complete"
);
Ok(report)
}
async fn load_export(path: &Path) -> Result<MigrationExport> {
let text = tokio::fs::read_to_string(path)
.await
.with_context(|| format!("read export file `{}`", path.display()))?;
serde_json::from_str::<MigrationExport>(&text)
.with_context(|| format!("parse export JSON from `{}`", path.display()))
}
fn validate_embedding_dimensions(nodes: &[ExportUrlNode]) -> Result<Option<usize>> {
let mut expected: Option<usize> = None;
for node in nodes {
let Some(vec) = &node.embedding else { continue };
if vec.is_empty() {
continue;
}
match expected {
None => expected = Some(vec.len()),
Some(dim) if dim != vec.len() => bail!(
"inconsistent embedding dimensions in export: \
expected {dim} but `{}` has {}",
node.url,
vec.len()
),
_ => {}
}
}
Ok(expected)
}
fn parse_ts(value: Option<&str>) -> DateTime<Utc> {
match value {
Some(s) => DateTime::parse_from_rfc3339(s)
.map(|dt| dt.with_timezone(&Utc))
.unwrap_or_else(|_| Utc::now()),
None => Utc::now(),
}
}
fn parse_source(value: Option<&str>) -> DiscoverySource {
match value.unwrap_or("seed") {
"sitemap" => DiscoverySource::Sitemap,
"link_crawl" => DiscoverySource::LinkCrawl,
"external_link" => DiscoverySource::ExternalLink,
_ => DiscoverySource::Seed,
}
}
async fn import_page_content(store: &TursoStore, content: &ExportPageContent) -> Result<()> {
let record = PageContentRecord {
url_node: format!("url_node:{}", url_id(&content.url)),
content_text: content.content_text.clone().unwrap_or_default(),
content_markdown: content.content_markdown.clone(),
content_html: content.content_html.clone(),
excerpt: content.excerpt.clone(),
content_hash: content
.content_hash
.clone()
.unwrap_or_else(|| format!("hash-{}", url_id(&content.url))),
word_count: content.word_count,
reading_time_seconds: content.reading_time_seconds,
fetched_at: parse_ts(content.fetched_at.as_deref()),
created_at: parse_ts(content.created_at.as_deref()),
};
store.record_page_content(record).await?;
Ok(())
}
async fn import_crawl_job(store: &TursoStore, job: &ExportCrawlJob) -> Result<()> {
let id = store.enqueue_crawl_job(&job.url).await?;
if let Some(status) = job.status.as_deref() {
store
.mark_crawl_job_status(&id, status, job.error.as_deref())
.await?;
}
Ok(())
}
async fn verify_row_counts(
store: &TursoStore,
expected_nodes: usize,
expected_edges: usize,
expected_content: usize,
expected_jobs: usize,
) -> Result<()> {
let actual_nodes = store.count_rows("url_nodes").await?;
let actual_edges = store.count_rows("link_edges").await?;
let actual_content = store.count_rows("page_content").await?;
let actual_jobs = store.count_rows("crawl_jobs").await?;
if actual_nodes != expected_nodes {
bail!("url_nodes row-count mismatch: expected {expected_nodes}, found {actual_nodes}");
}
if actual_edges != expected_edges {
bail!("link_edges row-count mismatch: expected {expected_edges}, found {actual_edges}");
}
if actual_content != expected_content {
bail!(
"page_content row-count mismatch: expected {expected_content}, found {actual_content}"
);
}
if actual_jobs != expected_jobs {
bail!("crawl_jobs row-count mismatch: expected {expected_jobs}, found {actual_jobs}");
}
tracing::info!(
actual_nodes,
actual_edges,
actual_content,
actual_jobs,
"row-count verification passed"
);
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn validate_embedding_dimensions_allows_uniform() {
let nodes = vec![
ExportUrlNode {
url: "https://a".into(),
domain: None,
source: None,
depth: None,
priority: None,
lastmod: None,
changefreq: None,
discovered_at: None,
crawled: None,
excerpt: None,
embedding: Some(vec![1.0, 2.0, 3.0]),
},
ExportUrlNode {
url: "https://b".into(),
domain: None,
source: None,
depth: None,
priority: None,
lastmod: None,
changefreq: None,
discovered_at: None,
crawled: None,
excerpt: None,
embedding: Some(vec![4.0, 5.0, 6.0]),
},
];
assert_eq!(validate_embedding_dimensions(&nodes).unwrap(), Some(3));
}
#[test]
fn validate_embedding_dimensions_rejects_mixed() {
let nodes = vec![
ExportUrlNode {
url: "https://a".into(),
domain: None,
source: None,
depth: None,
priority: None,
lastmod: None,
changefreq: None,
discovered_at: None,
crawled: None,
excerpt: None,
embedding: Some(vec![1.0, 2.0]),
},
ExportUrlNode {
url: "https://b".into(),
domain: None,
source: None,
depth: None,
priority: None,
lastmod: None,
changefreq: None,
discovered_at: None,
crawled: None,
excerpt: None,
embedding: Some(vec![1.0, 2.0, 3.0]),
},
];
assert!(validate_embedding_dimensions(&nodes).is_err());
}
#[test]
fn validate_embedding_dimensions_handles_empty_and_null() {
let nodes = vec![
ExportUrlNode {
url: "https://a".into(),
domain: None,
source: None,
depth: None,
priority: None,
lastmod: None,
changefreq: None,
discovered_at: None,
crawled: None,
excerpt: None,
embedding: None,
},
ExportUrlNode {
url: "https://b".into(),
domain: None,
source: None,
depth: None,
priority: None,
lastmod: None,
changefreq: None,
discovered_at: None,
crawled: None,
excerpt: None,
embedding: Some(vec![]),
},
];
assert_eq!(validate_embedding_dimensions(&nodes).unwrap(), None);
}
}