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 {
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");
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)
);
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);
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"
);
let e2 = Uuid::from_u128(0x101);
let edge = g.edge(e2).unwrap().unwrap();
assert_eq!(edge.invalid_at, Some(1_000_500));
assert_eq!(grit_core::SCHEMA_VERSION, 5);
let vec = g.get_node_embedding(n0).unwrap();
assert_eq!(
vec.as_ref().map(Vec::len),
Some(4),
"the v1 fixture's vector must survive the v5 rebuild"
);
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"
);
drop(g);
let conn = rusqlite::Connection::open(dir.path().join("v1.db")).unwrap();
let group: String = conn
.query_row(
"SELECT group_id FROM vec_nodes WHERE id = ?1",
[n0.to_string()],
|r| r.get(0),
)
.unwrap();
assert_eq!(group, "g0", "migrated vector must carry its node's group");
}
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();
g.apply(GraphOp::UpdateNode {
id: n(1),
name: Some("node-1 promoted".into()),
summary: Some("updated summary".into()),
kind: None,
attrs: None,
})
.unwrap();
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)
);
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);
let eps = g.episodes_in_group("g").unwrap();
assert_eq!(eps.len(), 1);
assert_eq!(eps[0].kind, "", "pre-v3 episode gets the '' default");
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"
);
let n1 = Uuid::from_u128(1);
g.register_embedding_model("fixture-model", 4, "1").unwrap();
g.set_node_embedding(n1, vec![1.0, 0.0, 0.0, 0.0]).unwrap();
assert_eq!(
g.get_node_embedding(n1).unwrap(),
Some(vec![1.0, 0.0, 0.0, 0.0])
);
}
#[test]
fn v4_vec_tables_rebuild_into_group_partitions() {
fn f32s(v: &[f32]) -> Vec<u8> {
v.iter().flat_map(|x| x.to_le_bytes()).collect()
}
use grit_core::GraphOp;
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("v4vec.db");
let clock = Arc::new(ManualClock::new(1_000_000));
let g = Grit::open(&path, Options::new("v4vec").clock(clock.clone())).unwrap();
let a1 = Uuid::from_u128(1);
let a2 = Uuid::from_u128(2);
let b1 = Uuid::from_u128(3);
let ea = Uuid::from_u128(0x100);
for (id, name, group) in [
(a1, "alpha-1", "a"),
(a2, "alpha-2", "a"),
(b1, "beta-1", "b"),
] {
g.apply(GraphOp::AddNode {
id,
kind: "k".into(),
name: name.into(),
summary: String::new(),
attrs: serde_json::json!({}),
group_id: group.into(),
})
.unwrap();
}
g.apply(GraphOp::AddEdge {
id: ea,
src: a1,
dst: a2,
rel: "R".into(),
fact: "alpha-1 relates to alpha-2".into(),
attrs: serde_json::json!({}),
group_id: "a".into(),
valid_at: None,
invalid_at: None,
})
.unwrap();
g.register_embedding_model("m", 4, "1").unwrap();
drop(g);
let conn = rusqlite::Connection::open(&path).unwrap();
conn.execute_batch(
"DROP TABLE vec_nodes;
DROP TABLE vec_edges;
CREATE VIRTUAL TABLE vec_nodes USING vec0(
id TEXT PRIMARY KEY, embedding FLOAT[4] distance_metric=cosine);
CREATE VIRTUAL TABLE vec_edges USING vec0(
id TEXT PRIMARY KEY, embedding FLOAT[4] distance_metric=cosine);",
)
.unwrap();
let orphan = Uuid::from_u128(0xdead);
for (id, vec) in [
(a1, [0.0f32, 1.0, 0.0, 0.0]),
(a2, [0.0, 0.9, 0.1, 0.0]),
(b1, [1.0, 0.0, 0.0, 0.0]),
(orphan, [0.5, 0.5, 0.0, 0.0]),
] {
conn.execute(
"INSERT INTO vec_nodes (id, embedding) VALUES (?1, ?2)",
rusqlite::params![id.to_string(), f32s(&vec)],
)
.unwrap();
}
conn.execute(
"INSERT INTO vec_edges (id, embedding) VALUES (?1, ?2)",
rusqlite::params![ea.to_string(), f32s(&[0.0, 1.0, 0.0, 0.0])],
)
.unwrap();
conn.pragma_update(None, "user_version", 4).unwrap();
drop(conn);
let g = Grit::open(&path, Options::new("v4vec").clock(clock)).unwrap();
assert_eq!(
g.get_node_embedding(a1).unwrap(),
Some(vec![0.0, 1.0, 0.0, 0.0]),
"node vector must survive the rebuild"
);
assert_eq!(
g.get_edge_embedding(ea).unwrap(),
Some(vec![0.0, 1.0, 0.0, 0.0]),
"edge vector must survive the rebuild"
);
assert_eq!(
g.get_node_embedding(orphan).unwrap(),
None,
"orphan vectors (no base row) are dropped by the rebuild join"
);
let hits = g
.search(
Query::text("")
.vector(vec![1.0, 0.0, 0.0, 0.0])
.group("a")
.budget(Budget::items(5)),
)
.unwrap();
let node_ids: Vec<Uuid> = hits
.iter()
.filter_map(|h| match &h.target {
grit_core::SearchTarget::Node(n) => Some(n.id),
_ => None,
})
.collect();
assert!(
node_ids.contains(&a1) && node_ids.contains(&a2),
"group-a nodes must be reachable through the partitioned vector leg, got {node_ids:?}"
);
assert!(
!node_ids.contains(&b1),
"group-b results must not leak into a group-a search"
);
}
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);
drop(g);
let conn = rusqlite::Connection::open(dir.path().join("v3.db")).unwrap();
let hits: i64 = conn
.query_row(
"SELECT count(*) FROM nodes_fts_tri WHERE nodes_fts_tri MATCH 'promoted'",
[],
|r| r.get(0),
)
.unwrap();
assert!(hits >= 1, "trigram rebuild must index pre-v4 rows");
let ep_hits: i64 = conn
.query_row(
"SELECT count(*) FROM episodes_fts_tri WHERE episodes_fts_tri MATCH 'fixture'",
[],
|r| r.get(0),
)
.unwrap();
assert!(
ep_hits >= 1,
"episode trigram rebuild must index pre-v4 rows"
);
}
fn build_v4_content(g: &Grit) {
build_v3_content(g);
use grit_core::GraphOp;
let li = Uuid::from_u128(0x20);
let bd = Uuid::from_u128(0x21);
for (id, name) in [(li, "李雷"), (bd, "字节跳动")] {
g.apply(GraphOp::AddNode {
id,
kind: "k".into(),
name: name.into(),
summary: String::new(),
attrs: serde_json::json!({}),
group_id: "g".into(),
})
.unwrap();
}
g.apply(GraphOp::AddEdge {
id: Uuid::from_u128(0x120),
src: li,
dst: bd,
rel: "WORKS_AT".into(),
fact: "李雷在字节跳动担任数据工程师".into(),
attrs: serde_json::json!({}),
group_id: "g".into(),
valid_at: Some(1_000_300),
invalid_at: None,
})
.unwrap();
g.apply(GraphOp::AddEpisode {
id: Uuid::from_u128(0x203),
source: "chat".into(),
kind: "message".into(),
content: "李雷说他在字节跳动的新工作很充实".into(),
occurred_at: 1_000_300,
group_id: "g".into(),
mentions: vec![li],
})
.unwrap();
}
fn assert_v4_content(g: &Grit) {
assert_v2_content(g);
let eps = g.episodes_in_group("g").unwrap();
assert_eq!(eps.len(), 3);
assert_eq!(eps[0].kind, "", "v2-era episode keeps the '' default");
assert_eq!(eps[1].kind, "text");
assert_eq!(eps[2].kind, "message");
assert_eq!(eps[2].content, "李雷说他在字节跳动的新工作很充实");
let li = g.node(Uuid::from_u128(0x20)).unwrap().unwrap();
assert_eq!(li.name, "李雷");
assert_eq!(
g.edge(Uuid::from_u128(0x120)).unwrap().unwrap().fact,
"李雷在字节跳动担任数据工程师"
);
}
#[test]
fn v4_fixture_opens_and_reads() {
let dir = tempfile::tempdir().unwrap();
let g = open_fixture_copy(&dir, "v4.db");
assert_v4_content(&g);
let hits = g
.search(Query::text("字节跳动").group("g").budget(Budget::items(5)))
.unwrap();
assert!(!hits.is_empty(), "frozen CJK content must be searchable");
}
#[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);
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, "v2.db");
assert_v2_content(&g2);
}
#[test]
#[ignore = "fixture generator — run once per released schema version"]
fn generate_v4_fixture() {
let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/v4.db");
assert!(
!path.exists(),
"fixtures are frozen artifacts — never regenerate {}",
path.display()
);
let dir = tempfile::tempdir().unwrap();
let work = dir.path().join("v4.db");
let g = Grit::open(
&work,
Options::new("fixture-v4").clock(Arc::new(ManualClock::new(1_000_000))),
)
.unwrap();
build_v4_content(&g);
assert_v4_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, "v4.db");
assert_v4_content(&g2);
}
fn build_v5_content(g: &Grit) {
build_v4_content(g);
g.set_node_embedding(Uuid::from_u128(1), vec![1.0, 0.0, 0.0, 0.0])
.unwrap();
g.set_edge_embedding(Uuid::from_u128(0x120), vec![0.0, 1.0, 0.0, 0.0])
.unwrap();
}
fn assert_v5_content(g: &Grit) {
assert_v4_content(g);
}
#[test]
fn v5_fixture_opens_and_reads() {
let dir = tempfile::tempdir().unwrap();
let g = open_fixture_copy(&dir, "v5.db");
assert_v5_content(&g);
let n1 = Uuid::from_u128(1);
g.register_embedding_model("fixture-model", 4, "1").unwrap();
g.set_node_embedding(n1, vec![0.5, 0.5, 0.0, 0.0]).unwrap();
let hits = g
.search(
Query::text("")
.vector(vec![0.5, 0.5, 0.0, 0.0])
.group("g")
.budget(Budget::items(3)),
)
.unwrap();
assert!(
hits.iter().any(|h| match &h.target {
grit_core::SearchTarget::Node(n) => n.id == n1,
_ => false,
}),
"re-embedded node must be reachable through the partitioned vector leg"
);
}
#[test]
#[ignore = "fixture generator — run once per released schema version"]
fn generate_v5_fixture() {
let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/v5.db");
assert!(
!path.exists(),
"fixtures are frozen artifacts — never regenerate {}",
path.display()
);
let dir = tempfile::tempdir().unwrap();
let work = dir.path().join("v5.db");
let g = Grit::open(
&work,
Options::new("fixture-v5").clock(Arc::new(ManualClock::new(1_000_000))),
)
.unwrap();
build_v5_content(&g);
assert_v5_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, "v5.db");
assert_v5_content(&g2);
}
#[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);
}