Skip to main content

khive_vcs/
sync.rs

1// Copyright 2026 khive contributors. Licensed under Apache-2.0.
2//
3//! NDJSON-to-SQLite sync library boundary (ADR-010/ADR-020, finding F106).
4//!
5//! Reads `<repo>/.khive/kg/entities.ndjson` and `<repo>/.khive/kg/edges.ndjson`,
6//! parses each record per the ADR-020 §2 canonical schema, and writes them into
7//! a fresh SQLite database using the runtime's upsert APIs. The resulting DB
8//! has the full khive schema (entities + graph_edges + FTS5 indexes + vector
9//! tables) — the same schema the MCP server uses.
10//!
11//! ## Atomicity
12//!
13//! Builds into `<target>.tmp` then renames over `<target>`. A crash mid-build
14//! leaves the previous DB intact.
15//!
16//! ## Consumers
17//!
18//! `kkernel sync` is the primary consumer. It calls [`run_sync`] and prints the
19//! resulting [`SyncReport`] as JSON. Other callers (e.g. git post-checkout hooks)
20//! can use this library directly.
21
22use 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/// Per-record entity shape in NDJSON sources (ADR-020 §2).
33#[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/// Per-record edge shape in NDJSON sources (ADR-020 §2).
51#[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    // properties: accepted but not yet persisted to the storage-layer Edge
60    // struct. Parsed here so existing NDJSON files round-trip without warning.
61    #[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
75/// Parse an ISO-8601 timestamp string into microseconds since epoch.
76/// Returns `now` if the string is `None` or unparseable.
77fn 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/// Summary of a completed sync run.
84#[derive(Debug, serde::Serialize)]
85pub struct SyncReport {
86    pub entities: usize,
87    pub edges: usize,
88    pub db_path: String,
89}
90
91/// Rebuild `db_path` from `.khive/kg/{entities,edges}.ndjson` under `repo_root`.
92///
93/// The operation is atomic: the database is built in a `.tmp` sibling file and
94/// renamed over `db_path` only on success. A crash or error leaves the previous
95/// `db_path` intact.
96///
97/// `namespace` is applied to all imported records.
98///
99/// Returns a [`SyncReport`] on success, or an error if NDJSON parsing or SQLite
100/// upserts fail.
101pub 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    // Build the runtime against the tmp file. Vector embedding is disabled
114    // because sync runs without an embedding model loaded — vectors are
115    // computed lazily on access via the MCP server if needed.
116    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 the WAL so all committed writes land in the main DB file.
131    // Without this, `rename(tmp, target)` moves only the main file and leaves
132    // the -wal alongside it; opening `target` later would see only the data
133    // through the last auto-checkpoint (every 4000 pages). For small graphs no
134    // auto-checkpoint fires, so the data would silently disappear.
135    checkpoint_wal(&runtime)
136        .await
137        .context("checkpoint WAL before rename")?;
138
139    // Drop the runtime so SQLite releases its file handles before rename.
140    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        // Build the FTS body from name + description (same as create_entity in operations.rs).
221        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        // Populate FTS5 index so text search works after sync.
245        // Vectors are intentionally skipped: they are local-only derived state
246        // (ADR-035 §6) and will be computed by `kkernel kg embed` when needed.
247        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// ── Tests ─────────────────────────────────────────────────────────────────────
306
307#[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    /// F195: verify that FTS5 is populated during sync so text search works
420    /// after sync without a separate `kkernel kg embed` pass (ADR-035 §5).
421    #[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}