1use crate::{Entity, Relationship, Store, StoreError};
16use rusqlite::params;
17use serde::{Deserialize, Serialize};
18
19#[derive(Debug, Clone, Serialize, Deserialize)]
21pub struct GraphRevision {
23 pub rev: i64,
25 pub base_rev: i64,
27 pub created_at: String,
29 pub source_hash: String,
31 pub extractor_version: String,
33 pub semantic_config_hash: String,
36 pub graph_content_hash: String,
40 pub entity_count: u64,
42 pub rel_count: u64,
44 pub file_count: u64,
46}
47
48#[derive(Debug, Clone, Default, Serialize, Deserialize)]
50pub struct SemanticDelta {
52 pub added_entities: Vec<String>,
54 pub removed_entities: Vec<String>,
56 pub modified_entities: Vec<String>,
59 pub added_relationships: Vec<String>,
61 pub removed_relationships: Vec<String>,
63 pub modified_relationships: Vec<String>,
65 pub modified_kinds: std::collections::BTreeMap<String, usize>,
70}
71
72impl SemanticDelta {
74 pub fn is_empty(&self) -> bool {
77 self.added_entities.is_empty()
78 && self.removed_entities.is_empty()
79 && self.modified_entities.is_empty()
80 && self.added_relationships.is_empty()
81 && self.removed_relationships.is_empty()
82 && self.modified_relationships.is_empty()
83 }
84}
85
86impl Store {
89 pub fn record_revision(
94 &self,
95 source_hash: &str,
96 extractor_version: &str,
97 file_count: u64,
98 semantic_config_hash: &str,
99 graph_content_hash: &str,
100 ) -> Result<GraphRevision, StoreError> {
101 let entities = self.all_entities()?;
102 let rels = self.all_relationships()?;
103 let base: i64 = self
104 .conn
105 .query_row(
106 "SELECT COALESCE(MAX(rev), 0) FROM graph_revisions",
107 [],
108 |r| r.get(0),
109 )
110 .unwrap_or(0);
111 let tx = self.conn.unchecked_transaction()?;
112 let rev_row = GraphRevision {
113 rev: base + 1,
114 base_rev: base,
115 created_at: scc_core::now_rfc3339(),
116 source_hash: source_hash.to_string(),
117 extractor_version: extractor_version.to_string(),
118 semantic_config_hash: semantic_config_hash.to_string(),
119 graph_content_hash: graph_content_hash.to_string(),
120 entity_count: entities.len() as u64,
121 rel_count: rels.len() as u64,
122 file_count,
123 };
124 {
125 let mut ins_rev = tx.prepare(
126 "INSERT INTO graph_revisions
127 (rev, base_rev, created_at, source_hash, extractor_version,
128 semantic_config_hash, graph_content_hash,
129 entity_count, rel_count, file_count)
130 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)",
131 )?;
132 ins_rev.execute(params![
133 rev_row.rev,
134 rev_row.base_rev,
135 rev_row.created_at,
136 rev_row.source_hash,
137 rev_row.extractor_version,
138 rev_row.semantic_config_hash,
139 rev_row.graph_content_hash,
140 rev_row.entity_count as i64,
141 rev_row.rel_count as i64,
142 rev_row.file_count as i64,
143 ])?;
144 let mut ins_mem = tx.prepare(
145 "INSERT INTO revision_members (rev, kind, id, row_json)
146 VALUES (?1, ?2, ?3, ?4)",
147 )?;
148 for e in &entities {
149 ins_mem.execute(params![
150 rev_row.rev,
151 "entity",
152 e.id,
153 serde_json::to_string(e).unwrap_or_default(),
154 ])?;
155 }
156 for r in &rels {
157 ins_mem.execute(params![
158 rev_row.rev,
159 "relationship",
160 r.id,
161 serde_json::to_string(r).unwrap_or_default(),
162 ])?;
163 }
164 }
165 tx.commit()?;
166 Ok(rev_row)
167 }
168
169 pub fn revisions(&self) -> Result<Vec<GraphRevision>, StoreError> {
172 let mut stmt = self.conn.prepare(
173 "SELECT rev, base_rev, created_at, source_hash, extractor_version,
174 semantic_config_hash, graph_content_hash,
175 entity_count, rel_count, file_count
176 FROM graph_revisions ORDER BY rev",
177 )?;
178 let rows = stmt.query_map([], |r| {
179 Ok(GraphRevision {
180 rev: r.get(0)?,
181 base_rev: r.get(1)?,
182 created_at: r.get(2)?,
183 source_hash: r.get(3)?,
184 extractor_version: r.get(4)?,
185 semantic_config_hash: r.get(5)?,
186 graph_content_hash: r.get(6)?,
187 entity_count: r.get::<_, i64>(7)? as u64,
188 rel_count: r.get::<_, i64>(8)? as u64,
189 file_count: r.get::<_, i64>(9)? as u64,
190 })
191 })?;
192 rows.collect::<Result<Vec<_>, _>>().map_err(StoreError::from)
193 }
194
195 pub fn prune_revisions(&self, keep: usize) -> Result<usize, StoreError> {
205 let max: i64 = self
206 .conn
207 .query_row("SELECT COALESCE(MAX(rev), 0) FROM graph_revisions", [], |r| {
208 r.get(0)
209 })
210 .unwrap_or(0);
211 if max <= 0 {
212 return Ok(0);
213 }
214 let cutoff = max - keep.max(1) as i64;
215 if cutoff < 1 {
216 return Ok(0);
217 }
218 let tx = self.conn.unchecked_transaction()?;
219 tx.execute("DELETE FROM revision_members WHERE rev < ?1", [cutoff + 1])?;
220 let r = tx.execute("DELETE FROM graph_revisions WHERE rev < ?1", [cutoff + 1])?;
221 tx.commit()?;
222 Ok(r)
223 }
224
225 pub fn revision_members(
230 &self,
231 rev: i64,
232 ) -> Result<(Vec<Entity>, Vec<Relationship>), StoreError> {
233 let mut stmt = self.conn.prepare(
234 "SELECT kind, row_json FROM revision_members WHERE rev = ?1",
235 )?;
236 let mut entities = Vec::new();
237 let mut rels = Vec::new();
238 let rows = stmt.query_map(params![rev], |r| {
239 Ok((r.get::<_, String>(0)?, r.get::<_, String>(1)?))
240 })?;
241 for row in rows {
242 let (kind, js) = row?;
243 if kind == "entity" {
244 if let Ok(e) = serde_json::from_str::<Entity>(&js) {
245 entities.push(e);
246 }
247 } else if let Ok(r) = serde_json::from_str::<Relationship>(&js) {
248 rels.push(r);
249 }
250 }
251 Ok((entities, rels))
252 }
253
254 pub fn revision_rows(
260 &self,
261 rev: i64,
262 ) -> Result<
263 (
264 std::collections::BTreeMap<String, String>,
265 std::collections::BTreeMap<String, String>,
266 ),
267 StoreError,
268 > {
269 let mut stmt = self
270 .conn
271 .prepare("SELECT kind, id, row_json FROM revision_members WHERE rev = ?1")?;
272 let mut entities = std::collections::BTreeMap::new();
273 let mut rels = std::collections::BTreeMap::new();
274 let rows = stmt.query_map(params![rev], |r| {
275 Ok((
276 r.get::<_, String>(0)?,
277 r.get::<_, String>(1)?,
278 r.get::<_, String>(2)?,
279 ))
280 })?;
281 for row in rows {
282 let (kind, id, js) = row?;
283 if kind == "entity" {
284 entities.insert(id, js);
285 } else {
286 rels.insert(id, js);
287 }
288 }
289 Ok((entities, rels))
290 }
291
292 pub fn semantic_diff(&self, from: i64, to: i64) -> Result<SemanticDelta, StoreError> {
298 let (fe, fr) = self.revision_rows(from)?;
299 let (te, tr) = self.revision_rows(to)?;
300 let ids = |v: Vec<String>| {
301 let mut v = v;
302 v.sort();
303 v
304 };
305 let fset: std::collections::BTreeSet<String> = fe.keys().cloned().collect();
306 let tset: std::collections::BTreeSet<String> = te.keys().cloned().collect();
307 let frset: std::collections::BTreeSet<String> = fr.keys().cloned().collect();
308 let trset: std::collections::BTreeSet<String> = tr.keys().cloned().collect();
309 let mut modified_entities: Vec<String> = tset
310 .intersection(&fset)
311 .filter(|id| te.get(*id) != fe.get(*id))
312 .cloned()
313 .collect();
314 modified_entities.sort();
315 let mut modified_relationships: Vec<String> = trset
316 .intersection(&frset)
317 .filter(|id| tr.get(*id) != fr.get(*id))
318 .cloned()
319 .collect();
320 modified_relationships.sort();
321 let mut modified_kinds: std::collections::BTreeMap<String, usize> = std::collections::BTreeMap::new();
324 for id in &modified_entities {
325 if let Some(js) = te.get(id) {
326 if let Ok(v) = serde_json::from_str::<serde_json::Value>(js) {
327 if let Some(k) = v.get("kind").and_then(|k| k.as_str()) {
328 *modified_kinds.entry(k.to_string()).or_default() += 1;
329 }
330 }
331 }
332 }
333 Ok(SemanticDelta {
334 added_entities: ids(tset.difference(&fset).cloned().collect()),
335 removed_entities: ids(fset.difference(&tset).cloned().collect()),
336 modified_entities,
337 added_relationships: ids(trset.difference(&frset).cloned().collect()),
338 removed_relationships: ids(frset.difference(&trset).cloned().collect()),
339 modified_relationships,
340 modified_kinds,
341 })
342 }
343}
344
345impl Store {
347 pub fn graph_content_hash(&self) -> Result<String, StoreError> {
359 const OFFSET: u64 = 0xcbf29ce484222325;
360 const PRIME: u64 = 0x100000001b3;
361 let mut h = OFFSET;
362 let mut feed = |b: &[u8]| {
363 for byte in b {
364 h ^= u64::from(*byte);
365 h = h.wrapping_mul(PRIME);
366 }
367 h ^= 0xff;
368 h = h.wrapping_mul(PRIME);
369 };
370 let mut stmt = self.conn.prepare(
371 "SELECT id, kind, name, attributes, evidence FROM entities ORDER BY kind, name",
372 )?;
373 let rows = stmt.query_map([], |r| {
374 Ok((
375 r.get::<_, String>(0)?,
376 r.get::<_, String>(1)?,
377 r.get::<_, String>(2)?,
378 r.get::<_, String>(3)?,
379 r.get::<_, String>(4)?,
380 ))
381 })?;
382 for r in rows {
383 let (id, kind, name, attributes, evidence) = r?;
384 feed(id.as_bytes());
385 feed(kind.as_bytes());
386 feed(name.as_bytes());
387 feed(attributes.as_bytes());
388 feed(evidence.as_bytes());
389 }
390 let mut stmt = self.conn.prepare(
391 "SELECT id, subject, predicate, object, provenance, confidence, evidence, verified_at FROM relationships ORDER BY id",
392 )?;
393 let rows = stmt.query_map([], |r| {
394 Ok((
395 r.get::<_, String>(0)?,
396 r.get::<_, String>(1)?,
397 r.get::<_, String>(2)?,
398 r.get::<_, String>(3)?,
399 r.get::<_, String>(4)?,
400 r.get::<_, f64>(5)?,
401 r.get::<_, String>(6)?,
402 r.get::<_, String>(7)?,
403 ))
404 })?;
405 for r in rows {
406 let (id, subject, predicate, object, provenance, confidence, evidence, verified_at) =
407 r?;
408 feed(id.as_bytes());
409 feed(subject.as_bytes());
410 feed(predicate.as_bytes());
411 feed(object.as_bytes());
412 feed(provenance.as_bytes());
413 feed(&confidence.to_le_bytes());
414 feed(evidence.as_bytes());
415 feed(verified_at.as_bytes());
416 }
417 Ok(format!("{h:016x}"))
418 }
419
420 pub fn record_current_revision(&self) -> Result<GraphRevision, StoreError> {
426 self.record_current_revision_with_config("")
427 }
428
429 pub fn record_current_revision_with_config(
434 &self,
435 semantic_config_hash: &str,
436 ) -> Result<GraphRevision, StoreError> {
437 let files = self.all_files()?;
438 let mut buf = String::new();
439 for (path, hash, _, _, _) in files.iter() {
440 buf.push_str(path);
441 buf.push('\0');
442 buf.push_str(hash);
443 buf.push('\n');
444 }
445 let source_hash = scc_core::fnv1a64_hex(buf.as_bytes());
446 let content_hash = self.graph_content_hash()?;
447 let extractor = format!(
448 "store:{};core:{}",
449 crate::SCHEMA_VERSION,
450 scc_core::SCHEMA_VERSION
451 );
452 let head: Option<GraphRevision> = self.revisions()?.into_iter().last();
453 if let Some(h) = head {
454 if h.source_hash == source_hash
455 && h.extractor_version == extractor
456 && h.semantic_config_hash == semantic_config_hash
457 && h.graph_content_hash == content_hash
458 {
459 return Ok(h);
460 }
461 }
462 self.record_revision(
463 &source_hash,
464 &extractor,
465 files.len() as u64,
466 semantic_config_hash,
467 &content_hash,
468 )
469 }
470}
471
472#[cfg(test)]
473mod tests {
474 use super::*;
475 use crate::tests::tmp_store;
476
477 #[test]
478 fn revisions_record_intro_removal_and_diff() {
480 let (s, _d) = tmp_store();
481 let r1 = s.record_current_revision().unwrap();
482 assert_eq!(r1.rev, 1);
483 assert_eq!(r1.base_rev, 0);
484 assert!(!r1.source_hash.is_empty());
485 assert!(!r1.extractor_version.is_empty());
486 let r1b = s.record_current_revision().unwrap();
488 assert_eq!(r1b.rev, 1);
489 s.upsert_file("a.py", "h1", "python", "source", 10).unwrap();
491 s.insert_entity(
492 &Entity::new("repo://t/symbol/a.py/f", "symbol", "f"),
493 &["a.py".into()],
494 )
495 .unwrap();
496 let r2 = s.record_current_revision().unwrap();
497 assert_eq!((r2.rev, r2.base_rev), (2, 1));
498 let d = s.semantic_diff(1, 2).unwrap();
499 assert_eq!(d.added_entities, vec!["repo://t/symbol/a.py/f".to_string()]);
500 assert!(d.removed_entities.is_empty());
501 assert!(!d.is_empty());
502 let (e1, _) = s.revision_members(1).unwrap();
504 assert!(e1.is_empty());
505 let (e2, _) = s.revision_members(2).unwrap();
506 assert_eq!(e2.len(), 1);
507 s.delete_entity("repo://t/symbol/a.py/f").unwrap();
509 s.delete_file("a.py").unwrap();
510 let r3 = s.record_current_revision().unwrap();
511 assert_eq!(r3.rev, 3);
512 let d2 = s.semantic_diff(2, 3).unwrap();
513 assert_eq!(d2.removed_entities, vec!["repo://t/symbol/a.py/f".to_string()]);
514 assert!(s.semantic_diff(3, 3).unwrap().is_empty());
515 let revs = s.revisions().unwrap();
517 assert_eq!(revs.len(), 3);
518 assert_eq!(revs[0].base_rev, 0);
519 s.upsert_file("a.py", "h1", "python", "source", 10).unwrap();
522 s.insert_entity(
523 &Entity::new("repo://t/symbol/a.py/g", "symbol", "g"),
524 &["a.py".into()],
525 )
526 .unwrap();
527 let r4 = s.record_current_revision().unwrap();
528 assert_eq!(r4.rev, 4);
529 let mut g = Entity::new("repo://t/symbol/a.py/g", "symbol", "g");
530 g.attr("confidence", serde_json::json!(0.77));
531 s.insert_entity(&g, &["a.py".into()]).unwrap();
532 let r5 = s.record_current_revision().unwrap();
533 assert_eq!(r5.rev, 5, "content change without file change must advance");
534 assert_ne!(r5.graph_content_hash, r4.graph_content_hash);
535 let d4 = s.semantic_diff(4, 5).unwrap();
536 assert!(d4.added_entities.is_empty() && d4.removed_entities.is_empty());
537 assert_eq!(d4.modified_entities, vec!["repo://t/symbol/a.py/g".to_string()]);
538 assert_eq!(d4.modified_kinds.get("symbol"), Some(&1));
539 assert!(s.semantic_diff(5, 5).unwrap().is_empty());
540 let r6 = s.record_current_revision_with_config("cfg2").unwrap();
542 assert_eq!(r6.rev, 6);
543 assert_eq!(r6.semantic_config_hash, "cfg2");
544 let r6b = s.record_current_revision_with_config("cfg2").unwrap();
545 assert_eq!(r6b.rev, 6, "fully-identical rerun dedups");
546 }
547
548 #[test]
549 fn retention_prunes_old_revisions_but_keeps_head() {
551 let (s, _d) = tmp_store();
552 for i in 0..8 {
554 s.upsert_file("a.py", &format!("h{i}"), "python", "source", 10).unwrap();
555 s.record_current_revision().unwrap();
556 }
557 assert_eq!(s.revisions().unwrap().len(), 8);
558 assert_eq!(s.prune_revisions(3).unwrap(), 5);
560 let revs = s.revisions().unwrap();
561 assert_eq!(revs.len(), 3);
562 assert_eq!(revs.last().unwrap().rev, 8);
563 assert!(!s.semantic_diff(6, 8).unwrap().is_empty() || s.semantic_diff(7, 8).unwrap().is_empty());
564 assert_eq!(s.prune_revisions(0).unwrap(), 2);
566 let revs = s.revisions().unwrap();
567 assert_eq!(revs.len(), 1);
568 assert_eq!(revs[0].rev, 8);
569 let (e, _) = s.revision_members(8).unwrap();
570 assert!(!e.is_empty() || e.is_empty());
571 }
572}