use std::path::{Path, PathBuf};
use anyhow::{anyhow, Context, Result};
use khive_runtime::{KhiveRuntime, RuntimeConfig};
use khive_storage::types::{Edge, TextDocument};
use khive_storage::{LinkId, SubstrateKind};
use khive_types::EdgeRelation;
use serde::Deserialize;
use uuid::Uuid;
#[derive(Debug, Deserialize)]
struct NdjsonEntity {
id: Uuid,
kind: String,
name: String,
#[serde(default)]
description: Option<String>,
#[serde(default)]
properties: Option<serde_json::Value>,
#[serde(default)]
tags: Vec<String>,
#[serde(default)]
created_at: Option<String>,
#[serde(default)]
updated_at: Option<String>,
}
#[derive(Debug, Deserialize)]
struct NdjsonEdge {
edge_id: Uuid,
source: Uuid,
target: Uuid,
relation: String,
#[serde(default = "default_weight")]
weight: f64,
#[serde(default)]
#[allow(dead_code)]
properties: Option<serde_json::Value>,
#[serde(default)]
created_at: Option<String>,
#[serde(default)]
#[allow(dead_code)]
updated_at: Option<String>,
}
fn default_weight() -> f64 {
1.0
}
fn parse_ts_micros(s: Option<&str>) -> i64 {
s.and_then(|t| chrono::DateTime::parse_from_rfc3339(t).ok())
.map(|dt| dt.timestamp_micros())
.unwrap_or_else(|| chrono::Utc::now().timestamp_micros())
}
#[derive(Debug, serde::Serialize)]
pub struct SyncReport {
pub entities: usize,
pub edges: usize,
pub db_path: String,
}
pub async fn run_sync(repo_root: &Path, db_path: &Path, namespace: &str) -> Result<SyncReport> {
let entities_path = repo_root.join(".khive/kg/entities.ndjson");
let edges_path = repo_root.join(".khive/kg/edges.ndjson");
let entity_records = read_entities(&entities_path)
.with_context(|| format!("reading {}", entities_path.display()))?;
let edge_records =
read_edges(&edges_path).with_context(|| format!("reading {}", edges_path.display()))?;
let tmp_path = with_extension_suffix(db_path, ".tmp");
let _ = std::fs::remove_file(&tmp_path);
let ns = khive_types::Namespace::parse(namespace)
.map_err(|e| anyhow!("invalid namespace {namespace:?}: {e}"))?;
let config = RuntimeConfig {
db_path: Some(tmp_path.clone()),
default_namespace: ns,
embedding_model: None,
..RuntimeConfig::default()
};
let runtime = KhiveRuntime::new(config)
.with_context(|| format!("building runtime for {}", tmp_path.display()))?;
let entity_count = upsert_entities(&runtime, namespace, entity_records).await?;
let edge_count = upsert_edges(&runtime, namespace, edge_records).await?;
checkpoint_wal(&runtime)
.await
.context("checkpoint WAL before rename")?;
drop(runtime);
if let Some(parent) = db_path.parent() {
std::fs::create_dir_all(parent)
.with_context(|| format!("creating {}", parent.display()))?;
}
std::fs::rename(&tmp_path, db_path)
.with_context(|| format!("renaming {} -> {}", tmp_path.display(), db_path.display()))?;
Ok(SyncReport {
entities: entity_count,
edges: edge_count,
db_path: db_path.to_string_lossy().into_owned(),
})
}
fn with_extension_suffix(p: &Path, suffix: &str) -> PathBuf {
let mut s = p.as_os_str().to_owned();
s.push(suffix);
PathBuf::from(s)
}
fn read_entities(path: &Path) -> Result<Vec<NdjsonEntity>> {
if !path.exists() {
return Ok(Vec::new());
}
let text = std::fs::read_to_string(path)?;
let mut out = Vec::new();
for (i, line) in text.lines().enumerate() {
let trimmed = line.trim();
if trimmed.is_empty() {
continue;
}
let e: NdjsonEntity = serde_json::from_str(trimmed)
.with_context(|| format!("parsing entity at line {}", i + 1))?;
out.push(e);
}
Ok(out)
}
fn read_edges(path: &Path) -> Result<Vec<NdjsonEdge>> {
if !path.exists() {
return Ok(Vec::new());
}
let text = std::fs::read_to_string(path)?;
let mut out = Vec::new();
for (i, line) in text.lines().enumerate() {
let trimmed = line.trim();
if trimmed.is_empty() {
continue;
}
let e: NdjsonEdge = serde_json::from_str(trimmed)
.with_context(|| format!("parsing edge at line {}", i + 1))?;
out.push(e);
}
Ok(out)
}
async fn checkpoint_wal(runtime: &KhiveRuntime) -> Result<()> {
let mut writer = runtime.backend().sql().writer().await?;
writer
.execute_script("PRAGMA wal_checkpoint(TRUNCATE);".to_string())
.await?;
Ok(())
}
async fn upsert_entities(
runtime: &KhiveRuntime,
namespace: &str,
records: Vec<NdjsonEntity>,
) -> Result<usize> {
let ns = khive_types::Namespace::parse(namespace)
.map_err(|e| anyhow!("invalid namespace {namespace:?}: {e}"))?;
let token = runtime.authorize(ns);
let store = runtime.entities(&token).context("opening entity store")?;
let text = runtime.text(&token).context("opening text store")?;
let mut count = 0;
for r in records {
let created_at = parse_ts_micros(r.created_at.as_deref());
let updated_at = parse_ts_micros(r.updated_at.as_deref());
let body = match &r.description {
Some(d) if !d.is_empty() => format!("{} {}", r.name, d),
_ => r.name.clone(),
};
let entity = khive_storage::entity::Entity {
id: r.id,
namespace: namespace.to_string(),
kind: r.kind.clone(),
entity_type: None,
name: r.name.clone(),
description: r.description.clone(),
properties: r.properties.clone(),
tags: r.tags.clone(),
created_at,
updated_at,
deleted_at: None,
merge_event_id: None,
merged_into: None,
};
store
.upsert_entity(entity)
.await
.with_context(|| format!("upsert entity {}", r.id))?;
text.upsert_document(TextDocument {
subject_id: r.id,
kind: SubstrateKind::Entity,
title: Some(r.name.clone()),
body,
tags: r.tags.clone(),
namespace: namespace.to_string(),
metadata: r.properties.clone(),
updated_at: chrono::DateTime::from_timestamp_micros(updated_at)
.unwrap_or_else(chrono::Utc::now),
})
.await
.with_context(|| format!("fts index entity {}", r.id))?;
count += 1;
}
Ok(count)
}
async fn upsert_edges(
runtime: &KhiveRuntime,
namespace: &str,
records: Vec<NdjsonEdge>,
) -> Result<usize> {
let ns = khive_types::Namespace::parse(namespace)
.map_err(|e| anyhow!("invalid namespace {namespace:?}: {e}"))?;
let token = runtime.authorize(ns);
let graph = runtime.graph(&token).context("opening graph store")?;
let mut count = 0;
for r in records {
let relation: EdgeRelation = r
.relation
.parse()
.map_err(|e| anyhow!("invalid relation {:?}: {}", r.relation, e))?;
let created_at =
chrono::DateTime::from_timestamp_micros(parse_ts_micros(r.created_at.as_deref()))
.unwrap_or_else(chrono::Utc::now);
let edge = Edge {
id: LinkId::from(r.edge_id),
namespace: namespace.to_string(),
source_id: r.source,
target_id: r.target,
relation,
weight: r.weight,
created_at,
updated_at: created_at,
deleted_at: None,
metadata: None,
target_backend: None,
};
graph
.upsert_edge(edge)
.await
.with_context(|| format!("upsert edge {}", r.edge_id))?;
count += 1;
}
Ok(count)
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
fn write_repo(dir: &Path, entities_ndjson: &str, edges_ndjson: &str) {
let kg_dir = dir.join(".khive/kg");
std::fs::create_dir_all(&kg_dir).unwrap();
std::fs::write(kg_dir.join("entities.ndjson"), entities_ndjson).unwrap();
std::fs::write(kg_dir.join("edges.ndjson"), edges_ndjson).unwrap();
}
#[tokio::test]
async fn sync_empty_ndjson_produces_real_sqlite_file() {
let tmp = TempDir::new().unwrap();
let repo = tmp.path();
let db_path = repo.join(".khive/state/working.db");
write_repo(repo, "", "");
let report = run_sync(repo, &db_path, "test-ns").await.unwrap();
assert_eq!(report.entities, 0);
assert_eq!(report.edges, 0);
let bytes = std::fs::read(&db_path).unwrap();
assert!(!bytes.is_empty(), "DB file must be non-empty after sync");
assert!(
bytes.starts_with(b"SQLite format 3\0"),
"DB file must start with SQLite magic header, got {:?}",
&bytes[..bytes.len().min(20)]
);
}
#[tokio::test]
async fn sync_imports_entities_and_edges_into_real_db() {
let tmp = TempDir::new().unwrap();
let repo = tmp.path();
let db_path = repo.join(".khive/state/working.db");
let id_a = "11111111-1111-1111-1111-111111111111";
let id_b = "22222222-2222-2222-2222-222222222222";
let edge_id = "33333333-3333-3333-3333-333333333333";
let line_a = format!(
r#"{{"id":"{id_a}","kind":"concept","name":"Alpha","properties":{{}},"tags":[]}}"#
);
let line_b = format!(
r#"{{"id":"{id_b}","kind":"concept","name":"Beta","properties":{{}},"tags":[]}}"#
);
let entities = format!("{line_a}\n{line_b}\n");
let edges = format!(
r#"{{"edge_id":"{edge_id}","source":"{id_a}","target":"{id_b}","relation":"extends","weight":1.0,"properties":{{}}}}"#
);
write_repo(repo, &entities, &edges);
let report = run_sync(repo, &db_path, "test-ns").await.unwrap();
assert_eq!(report.entities, 2);
assert_eq!(report.edges, 1);
let ns = khive_types::Namespace::parse("test-ns").unwrap();
let config = RuntimeConfig {
db_path: Some(db_path.clone()),
default_namespace: ns.clone(),
embedding_model: None,
..RuntimeConfig::default()
};
let rt = KhiveRuntime::new(config).unwrap();
let token = rt.authorize(ns);
let alpha = rt
.entities(&token)
.unwrap()
.get_entity(id_a.parse().unwrap())
.await
.unwrap()
.expect("entity Alpha must be retrievable after sync");
assert_eq!(alpha.name, "Alpha");
assert_eq!(alpha.kind, "concept");
}
#[tokio::test]
async fn sync_is_atomic_via_tmp_rename() {
let tmp = TempDir::new().unwrap();
let repo = tmp.path();
let db_path = repo.join(".khive/state/working.db");
std::fs::create_dir_all(db_path.parent().unwrap()).unwrap();
std::fs::write(&db_path, b"SENTINEL").unwrap();
write_repo(repo, "not json\n", "");
let err = run_sync(repo, &db_path, "test-ns").await.unwrap_err();
assert!(
err.to_string().to_lowercase().contains("parsing entity")
|| err.chain().any(|e| e.to_string().contains("expected")),
"expected parse error, got: {err}"
);
let after = std::fs::read(&db_path).unwrap();
assert_eq!(
after, b"SENTINEL",
"atomic guarantee: failed sync must not replace existing DB"
);
}
#[tokio::test]
async fn sync_missing_ndjson_files_succeeds_with_zero_counts() {
let tmp = TempDir::new().unwrap();
let repo = tmp.path();
let db_path = repo.join(".khive/state/working.db");
let report = run_sync(repo, &db_path, "test-ns").await.unwrap();
assert_eq!(report.entities, 0);
assert_eq!(report.edges, 0);
}
#[tokio::test]
async fn sync_populates_fts_for_text_search() {
use khive_runtime::RuntimeConfig;
use khive_storage::types::{TextFilter, TextQueryMode, TextSearchRequest};
let tmp = TempDir::new().unwrap();
let repo = tmp.path();
let db_path = repo.join(".khive/state/working.db");
let id_a = "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa";
let line_a = format!(
r#"{{"id":"{id_a}","kind":"concept","name":"FlashAttention","description":"Fast attention algorithm","properties":{{}},"tags":[]}}"#
);
write_repo(repo, &line_a, "");
run_sync(repo, &db_path, "test-ns").await.unwrap();
let ns = khive_types::Namespace::parse("test-ns").unwrap();
let config = RuntimeConfig {
db_path: Some(db_path.clone()),
default_namespace: ns.clone(),
embedding_model: None,
..RuntimeConfig::default()
};
let rt = KhiveRuntime::new(config).unwrap();
let token = rt.authorize(ns);
let hits = rt
.text(&token)
.expect("text store must be available")
.search(TextSearchRequest {
query: "FlashAttention".to_string(),
filter: Some(TextFilter {
namespaces: vec!["test-ns".to_string()],
..Default::default()
}),
mode: TextQueryMode::Phrase,
top_k: 10,
snippet_chars: 128,
})
.await
.expect("text search must succeed after sync");
assert!(
!hits.is_empty(),
"FTS search for 'FlashAttention' must return results after sync (F195)"
);
assert_eq!(
hits[0].subject_id.to_string(),
id_a,
"FTS hit must reference the synced entity UUID"
);
}
}