grit-core 0.2.2

Embedded, bi-temporal property graph for agent memory: one SQLite file, in-process, deterministic
Documentation
//! Migration tests (Design Invariant 8): open + migrate fixture databases
//! frozen from every released schema version. `fixtures/v1.db` was generated
//! at v0.1.0 (deterministic content touching every table) and must stay
//! openable by every future release; `fixtures/v2.db` was frozen at v0.2.0
//! by `generate_v2_fixture` below (run with `-- --ignored` once per release;
//! it refuses to overwrite an existing fixture).
//!
//! When SCHEMA_VERSION grows: add the migration, freeze a new fixture, and
//! add a case here — never edit an existing fixture.

use std::sync::Arc;

use grit_core::{Budget, Grit, ManualClock, Options, Query, Traversal};
use uuid::Uuid;

fn open_fixture_copy(dir: &tempfile::TempDir, name: &str) -> Grit {
    // Fixtures are immutable artifacts; work on a copy (opening creates WAL
    // sidecars and a future version would migrate in place).
    let src = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
        .join("tests/fixtures")
        .join(name);
    let dst = dir.path().join(name);
    std::fs::copy(&src, &dst).unwrap();
    Grit::open(
        &dst,
        Options::new("migration-test").clock(Arc::new(ManualClock::new(2_000_000))),
    )
    .unwrap()
}

#[test]
fn v1_fixture_opens_and_reads() {
    let dir = tempfile::tempdir().unwrap();
    let g = open_fixture_copy(&dir, "v1.db");

    // Exact counts frozen with the fixture.
    let stats = g.stats().unwrap();
    assert_eq!(
        (
            stats.nodes,
            stats.edges,
            stats.episodes,
            stats.mentions,
            stats.oplog,
            stats.purged
        ),
        (5, 2, 1, 2, 12, 1)
    );

    // The graph is queryable: FTS, traversal, provenance, bi-temporal state.
    let hits = g
        .search(Query::text("fixtures").budget(Budget::items(5)))
        .unwrap();
    assert!(!hits.is_empty(), "episode content must be FTS-searchable");

    let n0 = Uuid::from_u128(1);
    let sub = g.traverse(&[n0], &Traversal::default().depth(1)).unwrap();
    assert_eq!(sub.edges.len(), 1, "n0 -R-> n1 must survive");
    assert_eq!(g.mentions_of(n0).unwrap().len(), 1);

    // Merge audit pointer and purge tombstone survived.
    let merged = g.node(Uuid::from_u128(6)).unwrap().unwrap();
    assert_eq!(merged.merged_into, Some(Uuid::from_u128(5)));
    assert!(
        g.node(Uuid::from_u128(4)).unwrap().is_none(),
        "purged node stays gone"
    );

    // The invalidated edge is belief-versioned: gone now, present before the
    // invalidation was recorded.
    let e2 = Uuid::from_u128(0x101);
    let edge = g.edge(e2).unwrap().unwrap();
    assert_eq!(edge.invalid_at, Some(1_000_500));

    // The v1 file migrated up to head on open.
    assert_eq!(grit_core::SCHEMA_VERSION, 3);

    // And new writes still work post-open — including v2's UpdateNode
    // against the migration-created node_updates table.
    g.apply(grit_core::GraphOp::AddNode {
        id: g.new_id(),
        kind: "k".into(),
        name: "post-migration write".into(),
        summary: String::new(),
        attrs: serde_json::json!({}),
        group_id: String::new(),
    })
    .unwrap();
    assert_eq!(g.stats().unwrap().nodes, 6);
    g.apply(grit_core::GraphOp::UpdateNode {
        id: n0,
        name: None,
        summary: Some("post-migration summary".into()),
        kind: None,
        attrs: None,
    })
    .unwrap();
    assert_eq!(
        g.node(n0).unwrap().unwrap().summary,
        "post-migration summary"
    );
}

/// Deterministic v2 fixture content: every table touched, including the
/// v2 node_updates machinery (a folded update AND a pending update whose
/// node never arrived). Shared by the generator and the assertions.
fn build_v2_content(g: &Grit) {
    use grit_core::GraphOp;
    let n = |i: u128| Uuid::from_u128(i);
    let e = |i: u128| Uuid::from_u128(0x100 + i);
    let ep = |i: u128| Uuid::from_u128(0x200 + i);
    for i in 1..=4u128 {
        g.apply(GraphOp::AddNode {
            id: n(i),
            kind: "k".into(),
            name: format!("node-{i}"),
            summary: String::new(),
            attrs: serde_json::json!({"i": i}),
            group_id: "g".into(),
        })
        .unwrap();
    }
    g.apply(GraphOp::AddEdge {
        id: e(1),
        src: n(1),
        dst: n(2),
        rel: "R".into(),
        fact: "node-1 relates to node-2".into(),
        attrs: serde_json::json!({}),
        group_id: "g".into(),
        valid_at: Some(1_000_000),
        invalid_at: None,
    })
    .unwrap();
    g.apply(GraphOp::AddEpisode {
        id: ep(1),
        source: "fixtures".into(),
        kind: String::new(),
        content: "episode exercising the v2 fixture tables".into(),
        occurred_at: 1_000_100,
        group_id: "g".into(),
        mentions: vec![n(1), e(1)],
    })
    .unwrap();
    g.apply(GraphOp::InvalidateEdge {
        edge_id: e(1),
        invalid_at: 1_000_500,
    })
    .unwrap();
    // v2: a folded update on a live node...
    g.apply(GraphOp::UpdateNode {
        id: n(1),
        name: Some("node-1 promoted".into()),
        summary: Some("updated summary".into()),
        kind: None,
        attrs: None,
    })
    .unwrap();
    // ...and a pending update whose node never arrives (out-of-order sync).
    g.apply(GraphOp::UpdateNode {
        id: n(9),
        name: None,
        summary: Some("pending until node-9 lands".into()),
        kind: None,
        attrs: None,
    })
    .unwrap();
    g.apply(GraphOp::MergeNodes {
        from: n(3),
        into: n(2),
    })
    .unwrap();
    g.apply(GraphOp::Purge { ids: vec![n(4)] }).unwrap();
    g.register_embedding_model("fixture-model", 4, "1").unwrap();
}

fn assert_v2_content(g: &Grit) {
    let n1 = Uuid::from_u128(1);
    let node = g.node(n1).unwrap().unwrap();
    assert_eq!(node.name, "node-1 promoted");
    assert_eq!(node.summary, "updated summary");
    assert_eq!(node.kind, "k", "untouched field keeps AddNode base");
    let merged = g.node(Uuid::from_u128(3)).unwrap().unwrap();
    assert_eq!(merged.merged_into, Some(Uuid::from_u128(2)));
    assert!(g.node(Uuid::from_u128(4)).unwrap().is_none());
    assert_eq!(
        g.edge(Uuid::from_u128(0x101)).unwrap().unwrap().invalid_at,
        Some(1_000_500)
    );
    // The updated name reaches FTS.
    let hits = g
        .search(Query::text("promoted").budget(Budget::items(5)))
        .unwrap();
    assert!(!hits.is_empty(), "updated node name must be FTS-searchable");
}

#[test]
fn v2_fixture_opens_and_reads() {
    let dir = tempfile::tempdir().unwrap();
    let g = open_fixture_copy(&dir, "v2.db");
    assert_v2_content(&g);
    // v3's episodes.kind backfills empty on migration.
    let eps = g.episodes_in_group("g").unwrap();
    assert_eq!(eps.len(), 1);
    assert_eq!(eps[0].kind, "", "pre-v3 episode gets the '' default");
    // The pending update folds when its node finally arrives.
    let n9 = Uuid::from_u128(9);
    g.apply(grit_core::GraphOp::AddNode {
        id: n9,
        kind: "k".into(),
        name: "node-9".into(),
        summary: String::new(),
        attrs: serde_json::json!({}),
        group_id: "g".into(),
    })
    .unwrap();
    assert_eq!(
        g.node(n9).unwrap().unwrap().summary,
        "pending until node-9 lands"
    );
}

/// Deterministic v3 fixture content: the v2 content plus an episode
/// carrying a non-empty source-kind tag (the v3 column).
fn build_v3_content(g: &Grit) {
    build_v2_content(g);
    g.apply(grit_core::GraphOp::AddEpisode {
        id: Uuid::from_u128(0x202),
        source: "doc:profile.md".into(),
        kind: "text".into(),
        content: "a document-chunk episode exercising the v3 kind column".into(),
        occurred_at: 1_000_200,
        group_id: "g".into(),
        mentions: vec![Uuid::from_u128(1)],
    })
    .unwrap();
}

fn assert_v3_content(g: &Grit) {
    assert_v2_content(g);
    let eps = g.episodes_in_group("g").unwrap();
    assert_eq!(eps.len(), 2);
    assert_eq!(eps[0].kind, "", "v2-era episode keeps the '' default");
    assert_eq!(eps[1].kind, "text", "v3 kind round-trips");
    assert_eq!(eps[1].source, "doc:profile.md");
}

#[test]
fn v3_fixture_opens_and_reads() {
    let dir = tempfile::tempdir().unwrap();
    let g = open_fixture_copy(&dir, "v3.db");
    assert_v3_content(&g);
}

/// One-time fixture freeze for a release; run manually:
/// `cargo test -p grit-core --test migration -- --ignored`.
#[test]
#[ignore = "fixture generator — run once per released schema version"]
fn generate_v2_fixture() {
    let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/v2.db");
    assert!(
        !path.exists(),
        "fixtures are frozen artifacts — never regenerate {}",
        path.display()
    );
    let dir = tempfile::tempdir().unwrap();
    let work = dir.path().join("v2.db");
    let g = Grit::open(
        &work,
        Options::new("fixture-v2").clock(Arc::new(ManualClock::new(1_000_000))),
    )
    .unwrap();
    build_v2_content(&g);
    assert_v2_content(&g);
    // Freeze via export → import: a live Grit's WAL sidecar holds recent
    // writes, so copying the bare .db would lose them; import_jsonl builds
    // the fixture with one short-lived connection whose close checkpoints
    // the WAL, and export/import losslessness is itself under test
    // (basic.rs::export_import_roundtrip_is_lossless).
    let mut stream = Vec::new();
    g.export_jsonl(&mut stream).unwrap();
    grit_core::import_jsonl(&path, stream.as_slice()).unwrap();
    // Prove the frozen .db is complete on its own (the copy leaves any
    // sidecar behind, mirroring how tests consume fixtures).
    let check = tempfile::tempdir().unwrap();
    let g2 = open_fixture_copy(&check, "v2.db");
    assert_v2_content(&g2);
}

/// One-time fixture freeze for grit 0.2.2 (schema v3); run manually:
/// `cargo test -p grit-core --test migration -- --ignored`.
#[test]
#[ignore = "fixture generator — run once per released schema version"]
fn generate_v3_fixture() {
    let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/v3.db");
    assert!(
        !path.exists(),
        "fixtures are frozen artifacts — never regenerate {}",
        path.display()
    );
    let dir = tempfile::tempdir().unwrap();
    let work = dir.path().join("v3.db");
    let g = Grit::open(
        &work,
        Options::new("fixture-v3").clock(Arc::new(ManualClock::new(1_000_000))),
    )
    .unwrap();
    build_v3_content(&g);
    assert_v3_content(&g);
    let mut stream = Vec::new();
    g.export_jsonl(&mut stream).unwrap();
    grit_core::import_jsonl(&path, stream.as_slice()).unwrap();
    let check = tempfile::tempdir().unwrap();
    let g2 = open_fixture_copy(&check, "v3.db");
    assert_v3_content(&g2);
}