1use std::path::{Path, PathBuf};
23
24use anyhow::{anyhow, Context, Result};
25use khive_runtime::{KhiveRuntime, RuntimeConfig};
26use khive_storage::types::{Edge, TextDocument};
27use khive_storage::{LinkId, SubstrateKind};
28use khive_types::EdgeRelation;
29use serde::Deserialize;
30use uuid::Uuid;
31
32#[derive(Debug, Deserialize)]
34struct NdjsonEntity {
35 id: Uuid,
36 kind: String,
37 name: String,
38 #[serde(default)]
39 description: Option<String>,
40 #[serde(default)]
41 properties: Option<serde_json::Value>,
42 #[serde(default)]
43 tags: Vec<String>,
44 #[serde(default)]
45 created_at: Option<String>,
46 #[serde(default)]
47 updated_at: Option<String>,
48}
49
50#[derive(Debug, Deserialize)]
52struct NdjsonEdge {
53 edge_id: Uuid,
54 source: Uuid,
55 target: Uuid,
56 relation: String,
57 #[serde(default = "default_weight")]
58 weight: f64,
59 #[serde(default)]
62 #[allow(dead_code)]
63 properties: Option<serde_json::Value>,
64 #[serde(default)]
65 created_at: Option<String>,
66 #[serde(default)]
67 #[allow(dead_code)]
68 updated_at: Option<String>,
69}
70
71fn default_weight() -> f64 {
72 1.0
73}
74
75fn parse_ts_micros(s: Option<&str>) -> i64 {
78 s.and_then(|t| chrono::DateTime::parse_from_rfc3339(t).ok())
79 .map(|dt| dt.timestamp_micros())
80 .unwrap_or_else(|| chrono::Utc::now().timestamp_micros())
81}
82
83#[derive(Debug, serde::Serialize)]
85pub struct SyncReport {
86 pub entities: usize,
87 pub edges: usize,
88 pub db_path: String,
89}
90
91pub async fn run_sync(repo_root: &Path, db_path: &Path, namespace: &str) -> Result<SyncReport> {
102 let entities_path = repo_root.join(".khive/kg/entities.ndjson");
103 let edges_path = repo_root.join(".khive/kg/edges.ndjson");
104
105 let entity_records = read_entities(&entities_path)
106 .with_context(|| format!("reading {}", entities_path.display()))?;
107 let edge_records =
108 read_edges(&edges_path).with_context(|| format!("reading {}", edges_path.display()))?;
109
110 let tmp_path = with_extension_suffix(db_path, ".tmp");
111 let _ = std::fs::remove_file(&tmp_path);
112
113 let ns = khive_types::Namespace::parse(namespace)
117 .map_err(|e| anyhow!("invalid namespace {namespace:?}: {e}"))?;
118 let config = RuntimeConfig {
119 db_path: Some(tmp_path.clone()),
120 default_namespace: ns,
121 embedding_model: None,
122 ..RuntimeConfig::default()
123 };
124 let runtime = KhiveRuntime::new(config)
125 .with_context(|| format!("building runtime for {}", tmp_path.display()))?;
126
127 let entity_count = upsert_entities(&runtime, namespace, entity_records).await?;
128 let edge_count = upsert_edges(&runtime, namespace, edge_records).await?;
129
130 checkpoint_wal(&runtime)
136 .await
137 .context("checkpoint WAL before rename")?;
138
139 drop(runtime);
141
142 if let Some(parent) = db_path.parent() {
143 std::fs::create_dir_all(parent)
144 .with_context(|| format!("creating {}", parent.display()))?;
145 }
146 std::fs::rename(&tmp_path, db_path)
147 .with_context(|| format!("renaming {} -> {}", tmp_path.display(), db_path.display()))?;
148
149 Ok(SyncReport {
150 entities: entity_count,
151 edges: edge_count,
152 db_path: db_path.to_string_lossy().into_owned(),
153 })
154}
155
156fn with_extension_suffix(p: &Path, suffix: &str) -> PathBuf {
157 let mut s = p.as_os_str().to_owned();
158 s.push(suffix);
159 PathBuf::from(s)
160}
161
162fn read_entities(path: &Path) -> Result<Vec<NdjsonEntity>> {
163 if !path.exists() {
164 return Ok(Vec::new());
165 }
166 let text = std::fs::read_to_string(path)?;
167 let mut out = Vec::new();
168 for (i, line) in text.lines().enumerate() {
169 let trimmed = line.trim();
170 if trimmed.is_empty() {
171 continue;
172 }
173 let e: NdjsonEntity = serde_json::from_str(trimmed)
174 .with_context(|| format!("parsing entity at line {}", i + 1))?;
175 out.push(e);
176 }
177 Ok(out)
178}
179
180fn read_edges(path: &Path) -> Result<Vec<NdjsonEdge>> {
181 if !path.exists() {
182 return Ok(Vec::new());
183 }
184 let text = std::fs::read_to_string(path)?;
185 let mut out = Vec::new();
186 for (i, line) in text.lines().enumerate() {
187 let trimmed = line.trim();
188 if trimmed.is_empty() {
189 continue;
190 }
191 let e: NdjsonEdge = serde_json::from_str(trimmed)
192 .with_context(|| format!("parsing edge at line {}", i + 1))?;
193 out.push(e);
194 }
195 Ok(out)
196}
197
198async fn checkpoint_wal(runtime: &KhiveRuntime) -> Result<()> {
199 let mut writer = runtime.backend().sql().writer().await?;
200 writer
201 .execute_script("PRAGMA wal_checkpoint(TRUNCATE);".to_string())
202 .await?;
203 Ok(())
204}
205
206async fn upsert_entities(
207 runtime: &KhiveRuntime,
208 namespace: &str,
209 records: Vec<NdjsonEntity>,
210) -> Result<usize> {
211 let ns = khive_types::Namespace::parse(namespace)
212 .map_err(|e| anyhow!("invalid namespace {namespace:?}: {e}"))?;
213 let token = runtime.authorize(ns);
214 let store = runtime.entities(&token).context("opening entity store")?;
215 let text = runtime.text(&token).context("opening text store")?;
216 let mut count = 0;
217 for r in records {
218 let created_at = parse_ts_micros(r.created_at.as_deref());
219 let updated_at = parse_ts_micros(r.updated_at.as_deref());
220 let body = match &r.description {
222 Some(d) if !d.is_empty() => format!("{} {}", r.name, d),
223 _ => r.name.clone(),
224 };
225 let entity = khive_storage::entity::Entity {
226 id: r.id,
227 namespace: namespace.to_string(),
228 kind: r.kind.clone(),
229 entity_type: None,
230 name: r.name.clone(),
231 description: r.description.clone(),
232 properties: r.properties.clone(),
233 tags: r.tags.clone(),
234 created_at,
235 updated_at,
236 deleted_at: None,
237 merge_event_id: None,
238 merged_into: None,
239 };
240 store
241 .upsert_entity(entity)
242 .await
243 .with_context(|| format!("upsert entity {}", r.id))?;
244 text.upsert_document(TextDocument {
248 subject_id: r.id,
249 kind: SubstrateKind::Entity,
250 title: Some(r.name.clone()),
251 body,
252 tags: r.tags.clone(),
253 namespace: namespace.to_string(),
254 metadata: r.properties.clone(),
255 updated_at: chrono::DateTime::from_timestamp_micros(updated_at)
256 .unwrap_or_else(chrono::Utc::now),
257 })
258 .await
259 .with_context(|| format!("fts index entity {}", r.id))?;
260 count += 1;
261 }
262 Ok(count)
263}
264
265async fn upsert_edges(
266 runtime: &KhiveRuntime,
267 namespace: &str,
268 records: Vec<NdjsonEdge>,
269) -> Result<usize> {
270 let ns = khive_types::Namespace::parse(namespace)
271 .map_err(|e| anyhow!("invalid namespace {namespace:?}: {e}"))?;
272 let token = runtime.authorize(ns);
273 let graph = runtime.graph(&token).context("opening graph store")?;
274 let mut count = 0;
275 for r in records {
276 let relation: EdgeRelation = r
277 .relation
278 .parse()
279 .map_err(|e| anyhow!("invalid relation {:?}: {}", r.relation, e))?;
280 let created_at =
281 chrono::DateTime::from_timestamp_micros(parse_ts_micros(r.created_at.as_deref()))
282 .unwrap_or_else(chrono::Utc::now);
283 let edge = Edge {
284 id: LinkId::from(r.edge_id),
285 namespace: namespace.to_string(),
286 source_id: r.source,
287 target_id: r.target,
288 relation,
289 weight: r.weight,
290 created_at,
291 updated_at: created_at,
292 deleted_at: None,
293 metadata: None,
294 target_backend: None,
295 };
296 graph
297 .upsert_edge(edge)
298 .await
299 .with_context(|| format!("upsert edge {}", r.edge_id))?;
300 count += 1;
301 }
302 Ok(count)
303}
304
305#[cfg(test)]
308mod tests {
309 use super::*;
310 use tempfile::TempDir;
311
312 fn write_repo(dir: &Path, entities_ndjson: &str, edges_ndjson: &str) {
313 let kg_dir = dir.join(".khive/kg");
314 std::fs::create_dir_all(&kg_dir).unwrap();
315 std::fs::write(kg_dir.join("entities.ndjson"), entities_ndjson).unwrap();
316 std::fs::write(kg_dir.join("edges.ndjson"), edges_ndjson).unwrap();
317 }
318
319 #[tokio::test]
320 async fn sync_empty_ndjson_produces_real_sqlite_file() {
321 let tmp = TempDir::new().unwrap();
322 let repo = tmp.path();
323 let db_path = repo.join(".khive/state/working.db");
324 write_repo(repo, "", "");
325
326 let report = run_sync(repo, &db_path, "test-ns").await.unwrap();
327 assert_eq!(report.entities, 0);
328 assert_eq!(report.edges, 0);
329
330 let bytes = std::fs::read(&db_path).unwrap();
331 assert!(!bytes.is_empty(), "DB file must be non-empty after sync");
332 assert!(
333 bytes.starts_with(b"SQLite format 3\0"),
334 "DB file must start with SQLite magic header, got {:?}",
335 &bytes[..bytes.len().min(20)]
336 );
337 }
338
339 #[tokio::test]
340 async fn sync_imports_entities_and_edges_into_real_db() {
341 let tmp = TempDir::new().unwrap();
342 let repo = tmp.path();
343 let db_path = repo.join(".khive/state/working.db");
344
345 let id_a = "11111111-1111-1111-1111-111111111111";
346 let id_b = "22222222-2222-2222-2222-222222222222";
347 let edge_id = "33333333-3333-3333-3333-333333333333";
348
349 let line_a = format!(
350 r#"{{"id":"{id_a}","kind":"concept","name":"Alpha","properties":{{}},"tags":[]}}"#
351 );
352 let line_b = format!(
353 r#"{{"id":"{id_b}","kind":"concept","name":"Beta","properties":{{}},"tags":[]}}"#
354 );
355 let entities = format!("{line_a}\n{line_b}\n");
356 let edges = format!(
357 r#"{{"edge_id":"{edge_id}","source":"{id_a}","target":"{id_b}","relation":"extends","weight":1.0,"properties":{{}}}}"#
358 );
359 write_repo(repo, &entities, &edges);
360
361 let report = run_sync(repo, &db_path, "test-ns").await.unwrap();
362 assert_eq!(report.entities, 2);
363 assert_eq!(report.edges, 1);
364
365 let ns = khive_types::Namespace::parse("test-ns").unwrap();
366 let config = RuntimeConfig {
367 db_path: Some(db_path.clone()),
368 default_namespace: ns.clone(),
369 embedding_model: None,
370 ..RuntimeConfig::default()
371 };
372 let rt = KhiveRuntime::new(config).unwrap();
373 let token = rt.authorize(ns);
374 let alpha = rt
375 .entities(&token)
376 .unwrap()
377 .get_entity(id_a.parse().unwrap())
378 .await
379 .unwrap()
380 .expect("entity Alpha must be retrievable after sync");
381 assert_eq!(alpha.name, "Alpha");
382 assert_eq!(alpha.kind, "concept");
383 }
384
385 #[tokio::test]
386 async fn sync_is_atomic_via_tmp_rename() {
387 let tmp = TempDir::new().unwrap();
388 let repo = tmp.path();
389 let db_path = repo.join(".khive/state/working.db");
390 std::fs::create_dir_all(db_path.parent().unwrap()).unwrap();
391 std::fs::write(&db_path, b"SENTINEL").unwrap();
392
393 write_repo(repo, "not json\n", "");
394 let err = run_sync(repo, &db_path, "test-ns").await.unwrap_err();
395 assert!(
396 err.to_string().to_lowercase().contains("parsing entity")
397 || err.chain().any(|e| e.to_string().contains("expected")),
398 "expected parse error, got: {err}"
399 );
400
401 let after = std::fs::read(&db_path).unwrap();
402 assert_eq!(
403 after, b"SENTINEL",
404 "atomic guarantee: failed sync must not replace existing DB"
405 );
406 }
407
408 #[tokio::test]
409 async fn sync_missing_ndjson_files_succeeds_with_zero_counts() {
410 let tmp = TempDir::new().unwrap();
411 let repo = tmp.path();
412 let db_path = repo.join(".khive/state/working.db");
413
414 let report = run_sync(repo, &db_path, "test-ns").await.unwrap();
415 assert_eq!(report.entities, 0);
416 assert_eq!(report.edges, 0);
417 }
418
419 #[tokio::test]
422 async fn sync_populates_fts_for_text_search() {
423 use khive_runtime::RuntimeConfig;
424 use khive_storage::types::{TextFilter, TextQueryMode, TextSearchRequest};
425
426 let tmp = TempDir::new().unwrap();
427 let repo = tmp.path();
428 let db_path = repo.join(".khive/state/working.db");
429
430 let id_a = "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa";
431 let line_a = format!(
432 r#"{{"id":"{id_a}","kind":"concept","name":"FlashAttention","description":"Fast attention algorithm","properties":{{}},"tags":[]}}"#
433 );
434 write_repo(repo, &line_a, "");
435
436 run_sync(repo, &db_path, "test-ns").await.unwrap();
437
438 let ns = khive_types::Namespace::parse("test-ns").unwrap();
439 let config = RuntimeConfig {
440 db_path: Some(db_path.clone()),
441 default_namespace: ns.clone(),
442 embedding_model: None,
443 ..RuntimeConfig::default()
444 };
445 let rt = KhiveRuntime::new(config).unwrap();
446 let token = rt.authorize(ns);
447
448 let hits = rt
449 .text(&token)
450 .expect("text store must be available")
451 .search(TextSearchRequest {
452 query: "FlashAttention".to_string(),
453 filter: Some(TextFilter {
454 namespaces: vec!["test-ns".to_string()],
455 ..Default::default()
456 }),
457 mode: TextQueryMode::Phrase,
458 top_k: 10,
459 snippet_chars: 128,
460 })
461 .await
462 .expect("text search must succeed after sync");
463
464 assert!(
465 !hits.is_empty(),
466 "FTS search for 'FlashAttention' must return results after sync (F195)"
467 );
468 assert_eq!(
469 hits[0].subject_id.to_string(),
470 id_a,
471 "FTS hit must reference the synced entity UUID"
472 );
473 }
474}