Skip to main content

tuitbot_core/storage/watchtower/
edges.rs

1//! CRUD operations for note_edges (graph link storage).
2
3use crate::error::StorageError;
4use crate::storage::DbPool;
5
6/// Input for inserting a new edge.
7#[derive(Debug, Clone)]
8pub struct NewEdge {
9    pub source_node_id: i64,
10    pub target_node_id: i64,
11    pub edge_type: String,
12    pub edge_label: Option<String>,
13    pub source_chunk_id: Option<i64>,
14}
15
16/// A stored note edge row.
17#[derive(Debug, Clone, serde::Serialize)]
18pub struct NoteEdge {
19    pub id: i64,
20    pub account_id: String,
21    pub source_node_id: i64,
22    pub target_node_id: i64,
23    pub edge_type: String,
24    pub edge_label: Option<String>,
25    pub source_chunk_id: Option<i64>,
26    pub created_at: String,
27}
28
29/// Row type for sqlx tuple decoding.
30type NoteEdgeRow = (
31    i64,
32    String,
33    i64,
34    i64,
35    String,
36    Option<String>,
37    Option<i64>,
38    String,
39);
40
41impl NoteEdge {
42    fn from_row(r: NoteEdgeRow) -> Self {
43        Self {
44            id: r.0,
45            account_id: r.1,
46            source_node_id: r.2,
47            target_node_id: r.3,
48            edge_type: r.4,
49            edge_label: r.5,
50            source_chunk_id: r.6,
51            created_at: r.7,
52        }
53    }
54}
55
56// ============================================================================
57// Delete
58// ============================================================================
59
60/// Delete all edges originating from a source node (forward links, shared-tag
61/// edges, and backlinks created by this node's forward links).
62///
63/// Idempotency: safe to call even if no edges exist.
64pub async fn delete_edges_for_source(
65    pool: &DbPool,
66    account_id: &str,
67    source_node_id: i64,
68) -> Result<u64, StorageError> {
69    let result = sqlx::query("DELETE FROM note_edges WHERE account_id = ? AND source_node_id = ?")
70        .bind(account_id)
71        .bind(source_node_id)
72        .execute(pool)
73        .await
74        .map_err(|e| StorageError::Query { source: e })?;
75
76    Ok(result.rows_affected())
77}
78
79// ============================================================================
80// Insert
81// ============================================================================
82
83/// Insert a single edge, ignoring duplicates (UNIQUE constraint).
84pub async fn insert_edge(
85    pool: &DbPool,
86    account_id: &str,
87    edge: &NewEdge,
88) -> Result<(), StorageError> {
89    sqlx::query(
90        "INSERT OR IGNORE INTO note_edges \
91         (account_id, source_node_id, target_node_id, edge_type, edge_label, source_chunk_id) \
92         VALUES (?, ?, ?, ?, ?, ?)",
93    )
94    .bind(account_id)
95    .bind(edge.source_node_id)
96    .bind(edge.target_node_id)
97    .bind(&edge.edge_type)
98    .bind(&edge.edge_label)
99    .bind(edge.source_chunk_id)
100    .execute(pool)
101    .await
102    .map_err(|e| StorageError::Query { source: e })?;
103
104    Ok(())
105}
106
107/// Insert multiple edges, ignoring duplicates.
108pub async fn insert_edges(
109    pool: &DbPool,
110    account_id: &str,
111    edges: &[NewEdge],
112) -> Result<(), StorageError> {
113    for edge in edges {
114        insert_edge(pool, account_id, edge).await?;
115    }
116    Ok(())
117}
118
119// ============================================================================
120// Query
121// ============================================================================
122
123/// Get all edges originating from a source node.
124pub async fn get_edges_for_source(
125    pool: &DbPool,
126    account_id: &str,
127    source_node_id: i64,
128) -> Result<Vec<NoteEdge>, StorageError> {
129    let rows: Vec<NoteEdgeRow> = sqlx::query_as(
130        "SELECT id, account_id, source_node_id, target_node_id, \
131                edge_type, edge_label, source_chunk_id, created_at \
132         FROM note_edges \
133         WHERE account_id = ? AND source_node_id = ? \
134         ORDER BY id",
135    )
136    .bind(account_id)
137    .bind(source_node_id)
138    .fetch_all(pool)
139    .await
140    .map_err(|e| StorageError::Query { source: e })?;
141
142    Ok(rows.into_iter().map(NoteEdge::from_row).collect())
143}
144
145/// Get all edges pointing to a target node (backlinks and shared-tag edges).
146pub async fn get_edges_for_target(
147    pool: &DbPool,
148    account_id: &str,
149    target_node_id: i64,
150) -> Result<Vec<NoteEdge>, StorageError> {
151    let rows: Vec<NoteEdgeRow> = sqlx::query_as(
152        "SELECT id, account_id, source_node_id, target_node_id, \
153                edge_type, edge_label, source_chunk_id, created_at \
154         FROM note_edges \
155         WHERE account_id = ? AND target_node_id = ? \
156         ORDER BY id",
157    )
158    .bind(account_id)
159    .bind(target_node_id)
160    .fetch_all(pool)
161    .await
162    .map_err(|e| StorageError::Query { source: e })?;
163
164    Ok(rows.into_iter().map(NoteEdge::from_row).collect())
165}