1use crate::{Store, StoreError};
10use rusqlite::params;
11use serde::{Deserialize, Serialize};
12
13fn fingerprint_row<T: serde::Serialize>(row: &T) -> String {
17 scc_core::fnv1a64_hex(serde_json::to_string(row).unwrap_or_default().as_bytes())
18}
19
20#[derive(Debug, Clone, Serialize, Deserialize)]
22pub struct ContextSnapshot {
24 pub id: String,
26 pub created_at: String,
28 pub task: String,
30 pub epoch: String,
32 pub revision: i64,
34 pub artifact: String,
36 pub artifact_hash: String,
39 pub entity_ids: Vec<String>,
41 pub entity_fp: std::collections::BTreeMap<String, String>,
44 pub rel_fp: std::collections::BTreeMap<String, String>,
46 pub contract_fp: std::collections::BTreeMap<String, String>,
49 pub state_fp: std::collections::BTreeMap<String, String>,
51 pub flow_names: Vec<String>,
53 pub budget: usize,
55 pub warnings: Vec<String>,
57}
58
59#[derive(Debug, Clone, Serialize, Deserialize)]
61pub struct SnapshotDiff {
63 pub still_valid: Vec<String>,
65 pub invalidated: Vec<String>,
67 pub modified_entities: Vec<String>,
70 pub changed_relationships: Vec<String>,
72 pub changed_contracts: Vec<String>,
74 pub changed_state: Vec<String>,
76 pub changed_flows: Vec<String>,
78 pub artifact_changed: bool,
80 pub snapshot_revision: i64,
82 pub current_revision: i64,
84}
85
86pub struct SnapshotSave<'a> {
90 pub task: &'a str,
92 pub epoch: &'a str,
94 pub revision: i64,
96 pub artifact: &'a str,
98 pub entity_ids: &'a [String],
100 pub budget: usize,
102 pub warnings: &'a [String],
104}
105
106impl Store {
108 pub fn save_snapshot(&self, save: SnapshotSave<'_>) -> Result<ContextSnapshot, StoreError> {
112 let hex =
113 scc_core::fnv1a64_hex(format!("{}\0{}", save.task, save.epoch).as_bytes());
114 let visible: std::collections::BTreeSet<&str> =
118 save.entity_ids.iter().map(|s| s.as_str()).collect();
119 let mut entity_fp = std::collections::BTreeMap::new();
120 let mut contract_fp = std::collections::BTreeMap::new();
121 let mut state_fp = std::collections::BTreeMap::new();
122 for e in self.all_entities()? {
123 let fp = fingerprint_row(&e);
124 if visible.contains(e.id.as_str()) {
125 entity_fp.insert(e.id.clone(), fp.clone());
126 }
127 match e.kind.as_str() {
128 k if k == scc_core::kinds::CONTRACT
129 || k == scc_core::kinds::ROUTE
130 || k == scc_core::kinds::TOPIC
131 || k == scc_core::kinds::CONFIGURATION =>
132 {
133 contract_fp.insert(e.id.clone(), fp);
134 }
135 k if k == scc_core::kinds::DATA_STORE || k == scc_core::kinds::DATA_ENTITY => {
136 state_fp.insert(e.id.clone(), fp);
137 }
138 _ => {}
139 }
140 }
141 let mut rel_fp = std::collections::BTreeMap::new();
142 for r in self.all_relationships()? {
143 if visible.contains(r.subject.as_str()) || visible.contains(r.object.as_str()) {
144 rel_fp.insert(r.id.clone(), fingerprint_row(&r));
145 }
146 }
147 let mut flow_names: Vec<String> = self
148 .flow_graphs()
149 .unwrap_or_default()
150 .into_iter()
151 .map(|g| g.name)
152 .collect();
153 flow_names.sort();
154 let snap = ContextSnapshot {
155 id: format!("snap-{}-{hex}", save.revision),
156 created_at: scc_core::now_rfc3339(),
157 task: save.task.to_string(),
158 epoch: save.epoch.to_string(),
159 revision: save.revision,
160 artifact: save.artifact.to_string(),
161 artifact_hash: scc_core::fnv1a64_hex(save.artifact.as_bytes()),
162 entity_ids: save.entity_ids.to_vec(),
163 entity_fp,
164 rel_fp,
165 contract_fp,
166 state_fp,
167 flow_names,
168 budget: save.budget,
169 warnings: save.warnings.to_vec(),
170 };
171 self.conn.execute(
172 "INSERT INTO context_snapshots
173 (id, created_at, task, epoch, revision, artifact, artifact_hash,
174 entity_ids, entity_fp, rel_fp, contract_fp, state_fp, flow_names,
175 budget, warnings)
176 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15)
177 ON CONFLICT(id) DO UPDATE SET
178 created_at = excluded.created_at, artifact = excluded.artifact,
179 artifact_hash = excluded.artifact_hash,
180 entity_ids = excluded.entity_ids, entity_fp = excluded.entity_fp,
181 rel_fp = excluded.rel_fp, contract_fp = excluded.contract_fp,
182 state_fp = excluded.state_fp, flow_names = excluded.flow_names,
183 budget = excluded.budget, warnings = excluded.warnings",
184 params![
185 snap.id,
186 snap.created_at,
187 snap.task,
188 snap.epoch,
189 snap.revision,
190 snap.artifact,
191 snap.artifact_hash,
192 serde_json::to_string(&snap.entity_ids).unwrap_or_default(),
193 serde_json::to_string(&snap.entity_fp).unwrap_or_default(),
194 serde_json::to_string(&snap.rel_fp).unwrap_or_default(),
195 serde_json::to_string(&snap.contract_fp).unwrap_or_default(),
196 serde_json::to_string(&snap.state_fp).unwrap_or_default(),
197 serde_json::to_string(&snap.flow_names).unwrap_or_default(),
198 snap.budget as i64,
199 serde_json::to_string(&snap.warnings).unwrap_or_default(),
200 ],
201 )?;
202 Ok(snap)
203 }
204
205 pub fn load_snapshot(&self, id: &str) -> Result<Option<ContextSnapshot>, StoreError> {
209 let mut stmt = self.conn.prepare(
210 "SELECT id, created_at, task, epoch, revision, artifact, artifact_hash,
211 entity_ids, entity_fp, rel_fp, contract_fp, state_fp, flow_names,
212 budget, warnings
213 FROM context_snapshots WHERE id = ?1",
214 )?;
215 let js = |r: &rusqlite::Row, i: usize| -> String { r.get(i).unwrap_or_default() };
216 let mut rows = stmt.query_map(params![id], |r| {
217 Ok(ContextSnapshot {
218 id: r.get(0)?,
219 created_at: r.get(1)?,
220 task: r.get(2)?,
221 epoch: r.get(3)?,
222 revision: r.get(4)?,
223 artifact: r.get(5)?,
224 artifact_hash: r.get(6)?,
225 entity_ids: serde_json::from_str(&js(r, 7)).unwrap_or_default(),
226 entity_fp: serde_json::from_str(&js(r, 8)).unwrap_or_default(),
227 rel_fp: serde_json::from_str(&js(r, 9)).unwrap_or_default(),
228 contract_fp: serde_json::from_str(&js(r, 10)).unwrap_or_default(),
229 state_fp: serde_json::from_str(&js(r, 11)).unwrap_or_default(),
230 flow_names: serde_json::from_str(&js(r, 12)).unwrap_or_default(),
231 budget: r.get::<_, i64>(13)? as usize,
232 warnings: serde_json::from_str(&js(r, 14)).unwrap_or_default(),
233 })
234 })?;
235 match rows.next() {
236 Some(r) => Ok(Some(r?)),
237 None => Ok(None),
238 }
239 }
240
241 pub fn diff_snapshot(&self, id: &str) -> Result<Option<SnapshotDiff>, StoreError> {
248 let Some(snap) = self.load_snapshot(id)? else {
249 return Ok(None);
250 };
251 let mut live_fp = std::collections::BTreeMap::new();
252 for e in self.all_entities()? {
253 live_fp.insert(e.id.clone(), fingerprint_row(&e));
254 }
255 let mut still_valid = Vec::new();
256 let mut invalidated = Vec::new();
257 let mut modified_entities = Vec::new();
258 for eid in &snap.entity_ids {
259 match live_fp.get(eid) {
260 None => invalidated.push(eid.clone()),
261 Some(fp) => {
262 if snap.entity_fp.get(eid).map(|s| s == fp).unwrap_or(true) {
263 still_valid.push(eid.clone());
264 } else {
265 modified_entities.push(eid.clone());
266 }
267 }
268 }
269 }
270 let visible: std::collections::BTreeSet<&str> =
272 snap.entity_ids.iter().map(|s| s.as_str()).collect();
273 let mut live_rel_fp = std::collections::BTreeMap::new();
274 for r in self.all_relationships()? {
275 if visible.contains(r.subject.as_str()) || visible.contains(r.object.as_str()) {
276 live_rel_fp.insert(r.id.clone(), fingerprint_row(&r));
277 }
278 }
279 let mut changed_relationships: Vec<String> = live_rel_fp
280 .iter()
281 .filter(|(k, v)| snap.rel_fp.get(*k) != Some(*v))
282 .map(|(k, _)| k.clone())
283 .chain(
284 snap.rel_fp
285 .keys()
286 .filter(|k| !live_rel_fp.contains_key(*k))
287 .cloned(),
288 )
289 .collect();
290 changed_relationships.sort();
291 let fp_diff = |old: &std::collections::BTreeMap<String, String>| -> Vec<String> {
293 let mut live = std::collections::BTreeMap::new();
294 for e in self.all_entities().unwrap_or_default() {
295 live.insert(e.id.clone(), fingerprint_row(&e));
296 }
297 let mut out: Vec<String> = live
298 .iter()
299 .filter(|(k, v)| old.get(*k) != Some(*v))
300 .map(|(k, _)| k.clone())
301 .chain(old.keys().filter(|k| !live.contains_key(*k)).cloned())
302 .collect();
303 out.sort();
304 out
305 };
306 let mut live_kind: std::collections::BTreeMap<String, String> = std::collections::BTreeMap::new();
309 for e in self.all_entities().unwrap_or_default() {
310 live_kind.insert(e.id.clone(), e.kind.clone());
311 }
312 let changed_contracts: Vec<String> = fp_diff(&snap.contract_fp)
313 .into_iter()
314 .filter(|id| {
315 snap.contract_fp.contains_key(id)
316 || live_kind.get(id).map(|k| {
317 k == scc_core::kinds::CONTRACT
318 || k == scc_core::kinds::ROUTE
319 || k == scc_core::kinds::TOPIC
320 || k == scc_core::kinds::CONFIGURATION
321 }).unwrap_or(false)
322 })
323 .collect();
324 let changed_state: Vec<String> = fp_diff(&snap.state_fp)
325 .into_iter()
326 .filter(|id| {
327 snap.state_fp.contains_key(id)
328 || live_kind.get(id).map(|k| {
329 k == scc_core::kinds::DATA_STORE || k == scc_core::kinds::DATA_ENTITY
330 }).unwrap_or(false)
331 })
332 .collect();
333 let mut live_flows: Vec<String> = self
334 .flow_graphs()
335 .unwrap_or_default()
336 .into_iter()
337 .map(|g| g.name)
338 .collect();
339 live_flows.sort();
340 let live_flow_set: std::collections::BTreeSet<&str> =
341 live_flows.iter().map(|s| s.as_str()).collect();
342 let saved_flow_set: std::collections::BTreeSet<&str> =
343 snap.flow_names.iter().map(|s| s.as_str()).collect();
344 let mut changed_flows: Vec<String> = live_flow_set
345 .symmetric_difference(&saved_flow_set)
346 .map(|s| s.to_string())
347 .collect();
348 changed_flows.sort();
349 let current_revision: i64 = self
350 .conn
351 .query_row("SELECT COALESCE(MAX(rev), 0) FROM graph_revisions", [], |r| {
352 r.get(0)
353 })
354 .unwrap_or(0);
355 let artifact_changed = !snap.artifact.is_empty()
360 && (!invalidated.is_empty()
361 || !modified_entities.is_empty()
362 || !changed_relationships.is_empty());
363 Ok(Some(SnapshotDiff {
364 still_valid,
365 invalidated,
366 modified_entities,
367 changed_relationships,
368 changed_contracts,
369 changed_state,
370 changed_flows,
371 artifact_changed,
372 snapshot_revision: snap.revision,
373 current_revision,
374 }))
375 }
376}
377
378#[cfg(test)]
379mod tests {
380 use super::*;
381 use crate::{tests::tmp_store, Entity};
382
383 #[test]
384 fn snapshots_persist_diff_and_survive() {
386 let (s, _d) = tmp_store();
387 s.upsert_file("a.py", "h1", "python", "source", 10).unwrap();
388 for id in ["repo://t/symbol/a.py/f", "repo://t/symbol/a.py/g"] {
389 s.insert_entity(&Entity::new(id, "symbol", "f"), &["a.py".into()])
390 .unwrap();
391 }
392 let r1 = s.record_current_revision().unwrap();
393 let epoch = "epoch:test";
394 let snap = s
395 .save_snapshot(SnapshotSave {
396 task: "do the thing",
397 epoch,
398 revision: r1.rev,
399 artifact: "pack showing f and g",
400 entity_ids: &[
401 "repo://t/symbol/a.py/f".into(),
402 "repo://t/symbol/a.py/g".into(),
403 ],
404 budget: 100,
405 warnings: &[],
406 })
407 .unwrap();
408 assert_eq!(snap.revision, r1.rev);
409 assert_eq!(snap.epoch, epoch);
410 let snap2 = s
412 .save_snapshot(SnapshotSave {
413 task: "do the thing",
414 epoch,
415 revision: r1.rev,
416 artifact: "pack showing f and g",
417 entity_ids: &[
418 "repo://t/symbol/a.py/f".into(),
419 "repo://t/symbol/a.py/g".into(),
420 ],
421 budget: 100,
422 warnings: &[],
423 })
424 .unwrap();
425 assert_eq!(snap.id, snap2.id);
426 let loaded = s.load_snapshot(&snap.id).unwrap().unwrap();
428 assert_eq!(loaded.artifact, "pack showing f and g");
429 let d0 = s.diff_snapshot(&snap.id).unwrap().unwrap();
431 assert!(d0.invalidated.is_empty());
432 assert_eq!(d0.still_valid.len(), 2);
433 s.delete_entity("repo://t/symbol/a.py/g").unwrap();
435 s.upsert_file("a.py", "h2", "python", "source", 10).unwrap();
436 let _r2 = s.record_current_revision().unwrap();
437 let d1 = s.diff_snapshot(&snap.id).unwrap().unwrap();
438 assert_eq!(d1.still_valid, vec!["repo://t/symbol/a.py/f".to_string()]);
439 assert_eq!(d1.invalidated, vec!["repo://t/symbol/a.py/g".to_string()]);
440 assert_eq!(d1.snapshot_revision, r1.rev);
441 assert_eq!(d1.current_revision, r1.rev + 1);
442 let mut f2 = Entity::new("repo://t/symbol/a.py/f", "symbol", "f");
444 f2.attr("confidence", serde_json::json!(0.99));
445 s.insert_entity(&f2, &["a.py".into()]).unwrap();
446 let d2 = s.diff_snapshot(&snap.id).unwrap().unwrap();
447 assert!(d2.still_valid.is_empty(), "{d2:?}");
448 assert_eq!(d2.modified_entities, vec!["repo://t/symbol/a.py/f".to_string()]);
449 assert!(d2.artifact_changed, "visible rows changed: re-render would differ");
450 assert!(s.diff_snapshot("snap-0-deadbeef").unwrap().is_none());
452 }
453}