1use crate::error::MemoryError;
27use crate::types::{GraphEdge, GraphEdgeType};
28use chrono::{DateTime, NaiveDateTime, Utc};
29use rusqlite::{params, Connection};
30use std::collections::HashSet;
31
32const CANONICAL_TS_FMT: &str = "%Y-%m-%d %H:%M:%S%.6f";
36
37pub(crate) fn canonicalize_timestamp(s: &str) -> Option<String> {
46 if NaiveDateTime::parse_from_str(s, CANONICAL_TS_FMT).is_ok() {
48 return Some(s.to_string());
49 }
50 if let Ok(dt) = NaiveDateTime::parse_from_str(s, "%Y-%m-%d %H:%M:%S") {
52 return Some(dt.format(CANONICAL_TS_FMT).to_string());
53 }
54 if let Ok(dt) = DateTime::parse_from_rfc3339(s) {
56 return Some(dt.naive_utc().format(CANONICAL_TS_FMT).to_string());
57 }
58 None
59}
60
61fn normalize_or_pass_through(s: &str) -> String {
64 canonicalize_timestamp(s).unwrap_or_else(|| s.to_string())
65}
66
67#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
69pub struct StoredGraphEdge {
70 pub id: String,
72 pub source: String,
74 pub target: String,
76 pub edge_type: String,
78 #[serde(skip)]
80 pub edge_type_parsed: Option<GraphEdgeType>,
81 pub weight: f64,
83 pub metadata: Option<String>,
85 pub content_digest: String,
87 pub recorded_at: String,
89 pub is_invalidated: bool,
91 pub invalidated_at: Option<String>,
93 pub invalidation_reason: Option<String>,
95 pub valid_time: Option<String>,
97 pub recorded_time: Option<String>,
99}
100
101#[derive(Debug, Clone)]
103pub struct AddGraphEdgeParams {
104 pub source: String,
106 pub target: String,
108 pub edge_type: GraphEdgeType,
110 pub weight: f64,
112 pub metadata: Option<serde_json::Value>,
114 pub valid_time: Option<String>,
116 pub recorded_time: Option<String>,
118}
119
120pub(crate) fn insert_graph_edge(
122 conn: &Connection,
123 params: &AddGraphEdgeParams,
124) -> Result<StoredGraphEdge, MemoryError> {
125 let edge_type_json = serde_json::to_string(¶ms.edge_type)
126 .map_err(|e| MemoryError::Other(format!("failed to serialize edge_type: {e}")))?;
127
128 let metadata_json = match ¶ms.metadata {
129 Some(v) => Some(
130 serde_json::to_string(v)
131 .map_err(|e| MemoryError::Other(format!("failed to serialize metadata: {e}")))?,
132 ),
133 None => None,
134 };
135
136 let content_digest = compute_edge_digest(
137 ¶ms.source,
138 ¶ms.target,
139 &edge_type_json,
140 params.weight,
141 &metadata_json,
142 params.valid_time.as_deref(),
143 params.recorded_time.as_deref(),
144 );
145
146 let existing: Option<(String, String)> = conn
148 .query_row(
149 "SELECT id, recorded_at FROM graph_edges WHERE content_digest = ?1 AND is_invalidated = 0",
150 params![&content_digest],
151 |row| Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?)),
152 )
153 .ok();
154
155 if let Some((id, recorded_at)) = existing {
156 return Ok(StoredGraphEdge {
157 id,
158 source: params.source.clone(),
159 target: params.target.clone(),
160 edge_type: edge_type_json,
161 edge_type_parsed: Some(params.edge_type.clone()),
162 weight: params.weight,
163 metadata: metadata_json,
164 content_digest,
165 recorded_at: recorded_at.clone(),
166 is_invalidated: false,
167 invalidated_at: None,
168 invalidation_reason: None,
169 valid_time: params
170 .valid_time
171 .as_deref()
172 .map(normalize_or_pass_through)
173 .or_else(|| Some(recorded_at.clone())),
174 recorded_time: params
175 .recorded_time
176 .as_deref()
177 .map(normalize_or_pass_through)
178 .or_else(|| Some(recorded_at.clone())),
179 });
180 }
181
182 let id = uuid::Uuid::new_v4().to_string();
183 let recorded_at = Utc::now().format("%Y-%m-%d %H:%M:%S%.6f").to_string();
184 let valid_time = params
185 .valid_time
186 .as_deref()
187 .map(normalize_or_pass_through)
188 .unwrap_or_else(|| recorded_at.clone());
189 let recorded_time = params
190 .recorded_time
191 .as_deref()
192 .map(normalize_or_pass_through)
193 .unwrap_or_else(|| recorded_at.clone());
194
195 conn.execute(
196 "INSERT INTO graph_edges (id, source, target, edge_type, weight, metadata, content_digest, recorded_at, valid_time, recorded_time)
197 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)",
198 params![
199 &id,
200 ¶ms.source,
201 ¶ms.target,
202 &edge_type_json,
203 params.weight,
204 metadata_json.as_deref(),
205 &content_digest,
206 &recorded_at,
207 &valid_time,
208 &recorded_time,
209 ],
210 )
211 .map_err(|e| MemoryError::Database(e))?;
212
213 Ok(StoredGraphEdge {
214 id,
215 source: params.source.clone(),
216 target: params.target.clone(),
217 edge_type: edge_type_json,
218 edge_type_parsed: Some(params.edge_type.clone()),
219 weight: params.weight,
220 metadata: metadata_json,
221 content_digest,
222 recorded_at,
223 is_invalidated: false,
224 invalidated_at: None,
225 invalidation_reason: None,
226 valid_time: Some(valid_time),
227 recorded_time: Some(recorded_time),
228 })
229}
230
231pub(crate) fn list_graph_edges_for_node(
234 conn: &Connection,
235 node_id: &str,
236) -> Result<Vec<StoredGraphEdge>, MemoryError> {
237 let mut stmt = conn.prepare(
238 "SELECT id, source, target, edge_type, weight, metadata, content_digest, recorded_at,
239 is_invalidated, invalidated_at, invalidation_reason, valid_time, recorded_time
240 FROM graph_edges
241 WHERE (source = ?1 OR target = ?1) AND is_invalidated = 0
242 ORDER BY recorded_at ASC",
243 )?;
244 let rows = stmt
245 .query_map(params![node_id], |row| {
246 Ok(StoredGraphEdge {
247 id: row.get(0)?,
248 source: row.get(1)?,
249 target: row.get(2)?,
250 edge_type: row.get(3)?,
251 edge_type_parsed: None,
252 weight: row.get(4)?,
253 metadata: row.get(5)?,
254 content_digest: row.get(6)?,
255 recorded_at: row.get(7)?,
256 is_invalidated: row.get::<_, i64>(8)? != 0,
257 invalidated_at: row.get(9)?,
258 invalidation_reason: row.get(10)?,
259 valid_time: row.get(11)?,
260 recorded_time: row.get(12)?,
261 })
262 })?
263 .collect::<Result<Vec<_>, _>>()?;
264 Ok(rows)
265}
266
267pub(crate) fn list_all_graph_edges(conn: &Connection) -> Result<Vec<StoredGraphEdge>, MemoryError> {
269 let mut stmt = conn.prepare(
270 "SELECT id, source, target, edge_type, weight, metadata, content_digest, recorded_at,
271 is_invalidated, invalidated_at, invalidation_reason, valid_time, recorded_time
272 FROM graph_edges
273 WHERE is_invalidated = 0
274 ORDER BY recorded_at ASC",
275 )?;
276 let rows = stmt
277 .query_map([], |row| {
278 Ok(StoredGraphEdge {
279 id: row.get(0)?,
280 source: row.get(1)?,
281 target: row.get(2)?,
282 edge_type: row.get(3)?,
283 edge_type_parsed: None,
284 weight: row.get(4)?,
285 metadata: row.get(5)?,
286 content_digest: row.get(6)?,
287 recorded_at: row.get(7)?,
288 is_invalidated: row.get::<_, i64>(8)? != 0,
289 invalidated_at: row.get(9)?,
290 invalidation_reason: row.get(10)?,
291 valid_time: row.get(11)?,
292 recorded_time: row.get(12)?,
293 })
294 })?
295 .collect::<Result<Vec<_>, _>>()?;
296 Ok(rows)
297}
298
299pub(crate) fn list_all_graph_edges_with_limit(
305 conn: &Connection,
306 max_rows: usize,
307) -> Result<Vec<StoredGraphEdge>, MemoryError> {
308 let max_rows = max_rows.max(1) as i64;
309 let mut stmt = conn.prepare(
310 "SELECT id, source, target, edge_type, weight, metadata, content_digest, recorded_at,
311 is_invalidated, invalidated_at, invalidation_reason, valid_time, recorded_time
312 FROM graph_edges
313 WHERE is_invalidated = 0
314 ORDER BY recorded_at ASC
315 LIMIT ?1",
316 )?;
317 let rows = stmt
318 .query_map(params![max_rows], |row| {
319 Ok(StoredGraphEdge {
320 id: row.get(0)?,
321 source: row.get(1)?,
322 target: row.get(2)?,
323 edge_type: row.get(3)?,
324 edge_type_parsed: None,
325 weight: row.get(4)?,
326 metadata: row.get(5)?,
327 content_digest: row.get(6)?,
328 recorded_at: row.get(7)?,
329 is_invalidated: row.get::<_, i64>(8)? != 0,
330 invalidated_at: row.get(9)?,
331 invalidation_reason: row.get(10)?,
332 valid_time: row.get(11)?,
333 recorded_time: row.get(12)?,
334 })
335 })?
336 .collect::<Result<Vec<_>, _>>()?;
337 Ok(rows)
338}
339
340pub(crate) fn list_graph_edges_for_node_with_limit(
343 conn: &Connection,
344 node_id: &str,
345 max_rows: usize,
346) -> Result<Vec<StoredGraphEdge>, MemoryError> {
347 let max_rows = max_rows.max(1) as i64;
348 let mut stmt = conn.prepare(
349 "SELECT id, source, target, edge_type, weight, metadata, content_digest, recorded_at,
350 is_invalidated, invalidated_at, invalidation_reason, valid_time, recorded_time
351 FROM graph_edges
352 WHERE (source = ?1 OR target = ?1) AND is_invalidated = 0
353 ORDER BY recorded_at ASC
354 LIMIT ?2",
355 )?;
356 let rows = stmt
357 .query_map(params![node_id, max_rows], |row| {
358 Ok(StoredGraphEdge {
359 id: row.get(0)?,
360 source: row.get(1)?,
361 target: row.get(2)?,
362 edge_type: row.get(3)?,
363 edge_type_parsed: None,
364 weight: row.get(4)?,
365 metadata: row.get(5)?,
366 content_digest: row.get(6)?,
367 recorded_at: row.get(7)?,
368 is_invalidated: row.get::<_, i64>(8)? != 0,
369 invalidated_at: row.get(9)?,
370 invalidation_reason: row.get(10)?,
371 valid_time: row.get(11)?,
372 recorded_time: row.get(12)?,
373 })
374 })?
375 .collect::<Result<Vec<_>, _>>()?;
376 Ok(rows)
377}
378
379pub(crate) fn list_graph_edges_for_node_as_of(
384 conn: &Connection,
385 node_id: &str,
386 as_of_valid_time: &str,
387 as_of_recorded_time: &str,
388) -> Result<Vec<StoredGraphEdge>, MemoryError> {
389 let as_of_valid_time = canonicalize_timestamp(as_of_valid_time).ok_or_else(|| {
391 MemoryError::Other(format!(
392 "invalid as_of_valid_time timestamp: {as_of_valid_time}"
393 ))
394 })?;
395 let as_of_recorded_time = canonicalize_timestamp(as_of_recorded_time).ok_or_else(|| {
396 MemoryError::Other(format!(
397 "invalid as_of_recorded_time timestamp: {as_of_recorded_time}"
398 ))
399 })?;
400
401 let mut stmt = conn.prepare(
402 "SELECT id, source, target, edge_type, weight, metadata, content_digest, recorded_at,
403 is_invalidated, invalidated_at, invalidation_reason, valid_time, recorded_time
404 FROM graph_edges
405 WHERE (source = ?1 OR target = ?1)
406 AND COALESCE(valid_time, recorded_at) <= ?2
407 AND COALESCE(recorded_time, recorded_at) <= ?3
408 AND (is_invalidated = 0 OR invalidated_at IS NULL OR invalidated_at > ?3)
409 ORDER BY COALESCE(recorded_time, recorded_at) ASC, id ASC",
410 )?;
411 let rows = stmt
412 .query_map(
413 params![node_id, as_of_valid_time, as_of_recorded_time],
414 |row| {
415 Ok(StoredGraphEdge {
416 id: row.get(0)?,
417 source: row.get(1)?,
418 target: row.get(2)?,
419 edge_type: row.get(3)?,
420 edge_type_parsed: None,
421 weight: row.get(4)?,
422 metadata: row.get(5)?,
423 content_digest: row.get(6)?,
424 recorded_at: row.get(7)?,
425 is_invalidated: row.get::<_, i64>(8)? != 0,
426 invalidated_at: row.get(9)?,
427 invalidation_reason: row.get(10)?,
428 valid_time: row.get(11)?,
429 recorded_time: row.get(12)?,
430 })
431 },
432 )?
433 .collect::<Result<Vec<_>, _>>()?;
434 Ok(rows)
435}
436
437pub(crate) fn list_graph_edges_for_neighborhood(
447 conn: &Connection,
448 seed_ids: &[String],
449 max_hops: usize,
450 max_nodes: usize,
451) -> Result<Vec<StoredGraphEdge>, MemoryError> {
452 if seed_ids.is_empty() || max_hops == 0 {
453 return Ok(Vec::new());
454 }
455
456 let mut visited: HashSet<String> = seed_ids.iter().cloned().collect();
457 let mut all_edges: Vec<StoredGraphEdge> = Vec::new();
458 let mut frontier: Vec<String> = seed_ids.iter().cloned().collect();
459
460 for _hop in 0..max_hops {
461 if frontier.is_empty() || visited.len() >= max_nodes {
462 break;
463 }
464
465 let mut next_frontier: Vec<String> = Vec::new();
466
467 for node_id in &frontier {
468 let edges = list_graph_edges_for_node(conn, node_id)?;
469 for edge in edges {
470 let other = if edge.source == *node_id {
472 &edge.target
473 } else {
474 &edge.source
475 };
476
477 if !visited.contains(other) {
478 visited.insert(other.clone());
479 next_frontier.push(other.clone());
480 }
481
482 if !all_edges.iter().any(|e: &StoredGraphEdge| e.id == edge.id) {
484 all_edges.push(edge);
485 }
486 }
487 }
488
489 frontier = next_frontier;
490 if visited.len() >= max_nodes {
491 break;
492 }
493 }
494
495 all_edges.sort_by(|a, b| a.recorded_at.cmp(&b.recorded_at));
497 Ok(all_edges)
498}
499
500pub(crate) fn invalidate_graph_edge(
502 conn: &Connection,
503 edge_id: &str,
504 reason: &str,
505) -> Result<(), MemoryError> {
506 let invalidated_at =
507 normalize_or_pass_through(&Utc::now().format("%Y-%m-%d %H:%M:%S%.6f").to_string());
508 let count = conn.execute(
509 "UPDATE graph_edges SET is_invalidated = 1, invalidated_at = ?1, invalidation_reason = ?2
510 WHERE id = ?3 AND is_invalidated = 0",
511 params![&invalidated_at, reason, edge_id],
512 )?;
513 if count == 0 {
514 return Err(MemoryError::Other(format!(
515 "graph edge {} not found or already invalidated",
516 edge_id
517 )));
518 }
519 Ok(())
520}
521
522#[allow(dead_code)] pub(crate) fn stored_edges_for_node(
526 conn: &Connection,
527 node_id: &str,
528) -> Result<Vec<GraphEdge>, MemoryError> {
529 let rows = list_graph_edges_for_node(conn, node_id)?;
530 let mut edges = Vec::new();
531 for row in rows {
532 let edge_type: GraphEdgeType = serde_json::from_str(&row.edge_type)
533 .map_err(|e| MemoryError::Other(format!("failed to deserialize edge_type: {e}")))?;
534 let metadata: Option<serde_json::Value> =
535 match &row.metadata {
536 Some(s) => Some(serde_json::from_str(s).map_err(|e| {
537 MemoryError::Other(format!("failed to deserialize metadata: {e}"))
538 })?),
539 None => None,
540 };
541 edges.push(GraphEdge {
542 source: row.source,
543 target: row.target,
544 edge_type,
545 weight: row.weight,
546 metadata,
547 });
548 }
549 Ok(edges)
550}
551
552pub(crate) fn stored_outgoing_edges(
555 conn: &Connection,
556 node_id: &str,
557) -> Result<Vec<GraphEdge>, MemoryError> {
558 let mut stmt = conn.prepare(
559 "SELECT id, source, target, edge_type, weight, metadata, content_digest, recorded_at,
560 is_invalidated, invalidated_at, invalidation_reason, valid_time, recorded_time
561 FROM graph_edges
562 WHERE source = ?1 AND is_invalidated = 0
563 ORDER BY recorded_at ASC",
564 )?;
565 let rows = stmt
566 .query_map(params![node_id], |row| {
567 Ok(StoredGraphEdge {
568 id: row.get(0)?,
569 source: row.get(1)?,
570 target: row.get(2)?,
571 edge_type: row.get(3)?,
572 edge_type_parsed: None,
573 weight: row.get(4)?,
574 metadata: row.get(5)?,
575 content_digest: row.get(6)?,
576 recorded_at: row.get(7)?,
577 is_invalidated: row.get::<_, i64>(8)? != 0,
578 invalidated_at: row.get(9)?,
579 invalidation_reason: row.get(10)?,
580 valid_time: row.get(11)?,
581 recorded_time: row.get(12)?,
582 })
583 })?
584 .collect::<Result<Vec<_>, _>>()?;
585 rows_to_graph_edges(rows)
586}
587
588pub(crate) fn stored_incoming_edges(
591 conn: &Connection,
592 node_id: &str,
593) -> Result<Vec<GraphEdge>, MemoryError> {
594 let mut stmt = conn.prepare(
595 "SELECT id, source, target, edge_type, weight, metadata, content_digest, recorded_at,
596 is_invalidated, invalidated_at, invalidation_reason, valid_time, recorded_time
597 FROM graph_edges
598 WHERE target = ?1 AND is_invalidated = 0
599 ORDER BY recorded_at ASC",
600 )?;
601 let rows = stmt
602 .query_map(params![node_id], |row| {
603 Ok(StoredGraphEdge {
604 id: row.get(0)?,
605 source: row.get(1)?,
606 target: row.get(2)?,
607 edge_type: row.get(3)?,
608 edge_type_parsed: None,
609 weight: row.get(4)?,
610 metadata: row.get(5)?,
611 content_digest: row.get(6)?,
612 recorded_at: row.get(7)?,
613 is_invalidated: row.get::<_, i64>(8)? != 0,
614 invalidated_at: row.get(9)?,
615 invalidation_reason: row.get(10)?,
616 valid_time: row.get(11)?,
617 recorded_time: row.get(12)?,
618 })
619 })?
620 .collect::<Result<Vec<_>, _>>()?;
621 rows_to_graph_edges(rows)
622}
623
624fn rows_to_graph_edges(rows: Vec<StoredGraphEdge>) -> Result<Vec<GraphEdge>, MemoryError> {
626 let mut edges = Vec::new();
627 for row in rows {
628 let edge_type: GraphEdgeType = serde_json::from_str(&row.edge_type)
629 .map_err(|e| MemoryError::Other(format!("failed to deserialize edge_type: {e}")))?;
630 let metadata: Option<serde_json::Value> =
631 match &row.metadata {
632 Some(s) => Some(serde_json::from_str(s).map_err(|e| {
633 MemoryError::Other(format!("failed to deserialize metadata: {e}"))
634 })?),
635 None => None,
636 };
637 edges.push(GraphEdge {
638 source: row.source,
639 target: row.target,
640 edge_type,
641 weight: row.weight,
642 metadata,
643 });
644 }
645 Ok(edges)
646}
647pub(crate) fn count_graph_edges(conn: &Connection) -> Result<usize, MemoryError> {
649 let count: i64 = conn
650 .query_row(
651 "SELECT COUNT(*) FROM graph_edges WHERE is_invalidated = 0",
652 [],
653 |row| row.get(0),
654 )
655 .map_err(|e| MemoryError::Database(e))?;
656 Ok(count as usize)
657}
658
659fn compute_edge_digest(
660 source: &str,
661 target: &str,
662 edge_type_json: &str,
663 weight: f64,
664 metadata_json: &Option<String>,
665 explicit_valid_time: Option<&str>,
666 explicit_recorded_time: Option<&str>,
667) -> String {
668 let mut builder = stack_ids::DigestBuilder::new();
669 builder.update(source.as_bytes());
670 builder.update(target.as_bytes());
671 builder.update(edge_type_json.as_bytes());
672 builder.update(&weight.to_le_bytes());
673 if let Some(meta) = metadata_json {
674 builder.update(meta.as_bytes());
675 }
676 if let Some(valid_time) = explicit_valid_time {
677 builder.update(valid_time.as_bytes());
678 }
679 if let Some(recorded_time) = explicit_recorded_time {
680 builder.update(recorded_time.as_bytes());
681 }
682 builder.finalize().to_string()
683}
684
685#[cfg(test)]
686mod tests {
687 use super::*;
688 use crate::db::run_migrations;
689 use rusqlite::Connection;
690 use serde_json::json;
691
692 fn migrated_conn() -> Connection {
693 let conn = Connection::open_in_memory().unwrap();
694 run_migrations(&conn).unwrap();
695 conn
696 }
697
698 #[test]
699 fn graph_edges_have_bitemporal_columns_after_migration() {
700 let conn = migrated_conn();
701 let columns: Vec<String> = conn
702 .prepare("PRAGMA table_info(graph_edges)")
703 .unwrap()
704 .query_map([], |row| row.get::<_, String>(1))
705 .unwrap()
706 .collect::<Result<Vec<_>, _>>()
707 .unwrap();
708
709 assert!(columns.contains(&"valid_time".to_string()));
710 assert!(columns.contains(&"recorded_time".to_string()));
711 }
712
713 #[test]
714 fn graph_edges_as_of_filters_valid_and_recorded_time() {
715 let conn = migrated_conn();
716 let old_edge = AddGraphEdgeParams {
717 source: "fact:a".to_string(),
718 target: "fact:b".to_string(),
719 edge_type: GraphEdgeType::Entity {
720 relation: "supports".to_string(),
721 },
722 weight: 1.0,
723 metadata: Some(json!({"version": "old"})),
724 valid_time: Some("2026-01-01T00:00:00Z".to_string()),
725 recorded_time: Some("2026-01-02T00:00:00Z".to_string()),
726 };
727 let future_edge = AddGraphEdgeParams {
728 source: "fact:a".to_string(),
729 target: "fact:c".to_string(),
730 edge_type: GraphEdgeType::Entity {
731 relation: "supports".to_string(),
732 },
733 weight: 1.0,
734 metadata: Some(json!({"version": "future"})),
735 valid_time: Some("2026-03-01T00:00:00Z".to_string()),
736 recorded_time: Some("2026-03-02T00:00:00Z".to_string()),
737 };
738
739 insert_graph_edge(&conn, &old_edge).unwrap();
740 insert_graph_edge(&conn, &future_edge).unwrap();
741
742 let visible = list_graph_edges_for_node_as_of(
743 &conn,
744 "fact:a",
745 "2026-02-01T00:00:00Z",
746 "2026-02-02T00:00:00Z",
747 )
748 .unwrap();
749
750 assert_eq!(visible.len(), 1);
751 assert_eq!(visible[0].target, "fact:b");
752 assert_eq!(
754 visible[0].valid_time.as_deref(),
755 Some("2026-01-01 00:00:00.000000")
756 );
757 assert_eq!(
758 visible[0].recorded_time.as_deref(),
759 Some("2026-01-02 00:00:00.000000")
760 );
761 }
762
763 #[test]
764 fn graph_edges_as_of_handles_mixed_timestamp_formats() {
765 let conn = migrated_conn();
767 let edge = AddGraphEdgeParams {
768 source: "fact:x".to_string(),
769 target: "fact:y".to_string(),
770 edge_type: GraphEdgeType::Semantic {
771 cosine_similarity: 0.9,
772 },
773 weight: 1.0,
774 metadata: None,
775 valid_time: Some("2026-01-01 00:00:00.000000".to_string()),
776 recorded_time: Some("2026-01-01 00:00:00.000000".to_string()),
777 };
778 insert_graph_edge(&conn, &edge).unwrap();
779
780 let visible = list_graph_edges_for_node_as_of(
781 &conn,
782 "fact:x",
783 "2026-01-01T23:59:59Z",
784 "2026-01-01T23:59:59Z",
785 )
786 .unwrap();
787 assert_eq!(visible.len(), 1);
788 assert_eq!(visible[0].target, "fact:y");
789 }
790
791 #[test]
792 fn graph_edges_as_of_rejects_invalid_timestamp() {
793 let conn = migrated_conn();
794 let edge = AddGraphEdgeParams {
795 source: "fact:x".to_string(),
796 target: "fact:y".to_string(),
797 edge_type: GraphEdgeType::Semantic {
798 cosine_similarity: 0.9,
799 },
800 weight: 1.0,
801 metadata: None,
802 valid_time: None,
803 recorded_time: None,
804 };
805 insert_graph_edge(&conn, &edge).unwrap();
806
807 let err = list_graph_edges_for_node_as_of(
808 &conn,
809 "fact:x",
810 "not-a-timestamp",
811 "2026-01-01T00:00:00Z",
812 )
813 .unwrap_err();
814 assert!(err.to_string().contains("invalid as_of_valid_time"));
815 }
816}