A lexicon-driven AppView for ATProto.
1use crate::db::{DatabaseBackend, adapt_sql};
2use serde_json::Value;
3use std::collections::HashSet;
4
5/// Recursively walk a JSON value and collect all string values starting with "at://".
6pub fn extract_at_uris(value: &Value) -> HashSet<String> {
7 let mut uris = HashSet::new();
8 collect_at_uris(value, &mut uris);
9 uris
10}
11
12fn collect_at_uris(value: &Value, uris: &mut HashSet<String>) {
13 match value {
14 Value::String(s) if s.starts_with("at://") => {
15 uris.insert(s.clone());
16 }
17 Value::Array(arr) => {
18 for item in arr {
19 collect_at_uris(item, uris);
20 }
21 }
22 Value::Object(obj) => {
23 for v in obj.values() {
24 collect_at_uris(v, uris);
25 }
26 }
27 _ => {}
28 }
29}
30
31/// Update record_refs for a given source record.
32/// Deletes old refs and inserts new ones.
33pub async fn sync_refs(
34 db: &sqlx::AnyPool,
35 source_uri: &str,
36 collection: &str,
37 record: &Value,
38 backend: DatabaseBackend,
39) -> Result<(), sqlx::Error> {
40 let uris = extract_at_uris(record);
41
42 // Delete existing refs for this source
43 let delete_sql = adapt_sql(
44 "DELETE FROM happyview_record_refs WHERE source_uri = ?",
45 backend,
46 );
47 crate::db::query(&delete_sql)
48 .bind(source_uri)
49 .execute(db)
50 .await?;
51
52 // Insert new refs
53 let insert_sql = adapt_sql(
54 "INSERT INTO happyview_record_refs (source_uri, target_uri, collection) VALUES (?, ?, ?) ON CONFLICT DO NOTHING",
55 backend,
56 );
57 for target_uri in &uris {
58 crate::db::query(&insert_sql)
59 .bind(source_uri)
60 .bind(target_uri)
61 .bind(collection)
62 .execute(db)
63 .await?;
64 }
65
66 Ok(())
67}
68
69#[cfg(test)]
70mod tests {
71 use super::*;
72 use serde_json::json;
73
74 #[test]
75 fn extracts_top_level_uri() {
76 let val = json!({"subject": "at://did:plc:abc/com.example/123"});
77 let uris = extract_at_uris(&val);
78 assert_eq!(uris.len(), 1);
79 assert!(uris.contains("at://did:plc:abc/com.example/123"));
80 }
81
82 #[test]
83 fn extracts_nested_uri() {
84 let val = json!({"outer": {"inner": "at://did:plc:abc/col/rkey"}});
85 let uris = extract_at_uris(&val);
86 assert_eq!(uris.len(), 1);
87 assert!(uris.contains("at://did:plc:abc/col/rkey"));
88 }
89
90 #[test]
91 fn extracts_uris_from_arrays() {
92 let val = json!({"refs": ["at://did:plc:a/col/1", "at://did:plc:b/col/2"]});
93 let uris = extract_at_uris(&val);
94 assert_eq!(uris.len(), 2);
95 }
96
97 #[test]
98 fn ignores_non_at_strings() {
99 let val = json!({"url": "https://example.com", "name": "test"});
100 let uris = extract_at_uris(&val);
101 assert!(uris.is_empty());
102 }
103
104 #[test]
105 fn empty_object_returns_empty() {
106 let uris = extract_at_uris(&json!({}));
107 assert!(uris.is_empty());
108 }
109
110 #[test]
111 fn deduplicates_repeated_uris() {
112 let val = json!({"a": "at://did:plc:x/c/1", "b": "at://did:plc:x/c/1"});
113 let uris = extract_at_uris(&val);
114 assert_eq!(uris.len(), 1);
115 }
116}