QuickDID is a high-performance AT Protocol identity resolution service written in Rust. It provides handle-to-DID resolution with Redis-backed caching and queue processing.

feature: jetstream purge and set

+115
Cargo.lock
··· 97 97 ] 98 98 99 99 [[package]] 100 + name = "atproto-jetstream" 101 + version = "0.11.3" 102 + source = "registry+https://github.com/rust-lang/crates.io-index" 103 + checksum = "9e55d1140fcf5c6be0c03e7db773c6a9258986340118015d9bb536bf22689de2" 104 + dependencies = [ 105 + "anyhow", 106 + "async-trait", 107 + "atproto-identity", 108 + "futures", 109 + "http", 110 + "serde", 111 + "serde_json", 112 + "thiserror 2.0.16", 113 + "tokio", 114 + "tokio-util", 115 + "tokio-websockets", 116 + "tracing", 117 + "tracing-subscriber", 118 + "urlencoding", 119 + "zstd", 120 + ] 121 + 122 + [[package]] 100 123 name = "autocfg" 101 124 version = "1.5.0" 102 125 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 285 308 checksum = "5252b3d2648e5eedbc1a6f501e3c795e07025c1e93bbf8bbdd6eef7f447a6d54" 286 309 dependencies = [ 287 310 "find-msvc-tools", 311 + "jobserver", 312 + "libc", 288 313 "shlex", 289 314 ] 290 315 ··· 736 761 ] 737 762 738 763 [[package]] 764 + name = "futures" 765 + version = "0.3.31" 766 + source = "registry+https://github.com/rust-lang/crates.io-index" 767 + checksum = "65bc07b1a8bc7c85c5f2e110c476c7389b4554ba72af57d8445ea63a576b0876" 768 + dependencies = [ 769 + "futures-channel", 770 + "futures-core", 771 + "futures-executor", 772 + "futures-io", 773 + "futures-sink", 774 + "futures-task", 775 + "futures-util", 776 + ] 777 + 778 + [[package]] 739 779 name = "futures-channel" 740 780 version = "0.3.31" 741 781 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 808 848 source = "registry+https://github.com/rust-lang/crates.io-index" 809 849 checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81" 810 850 dependencies = [ 851 + "futures-channel", 811 852 "futures-core", 812 853 "futures-io", 813 854 "futures-macro", ··· 1326 1367 checksum = "4a5f13b858c8d314ee3e8f639011f7ccefe71f97f96e50151fb991f267928e2c" 1327 1368 1328 1369 [[package]] 1370 + name = "jobserver" 1371 + version = "0.1.34" 1372 + source = "registry+https://github.com/rust-lang/crates.io-index" 1373 + checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" 1374 + dependencies = [ 1375 + "getrandom 0.3.3", 1376 + "libc", 1377 + ] 1378 + 1379 + [[package]] 1329 1380 name = "js-sys" 1330 1381 version = "0.3.78" 1331 1382 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 1874 1925 "anyhow", 1875 1926 "async-trait", 1876 1927 "atproto-identity", 1928 + "atproto-jetstream", 1877 1929 "axum", 1878 1930 "bincode", 1879 1931 "cadence", ··· 2480 2532 "digest", 2481 2533 "rand_core 0.6.4", 2482 2534 ] 2535 + 2536 + [[package]] 2537 + name = "simdutf8" 2538 + version = "0.1.5" 2539 + source = "registry+https://github.com/rust-lang/crates.io-index" 2540 + checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" 2483 2541 2484 2542 [[package]] 2485 2543 name = "slab" ··· 2902 2960 "io-uring", 2903 2961 "libc", 2904 2962 "mio", 2963 + "parking_lot", 2905 2964 "pin-project-lite", 2906 2965 "signal-hook-registry", 2907 2966 "slab", ··· 2964 3023 "futures-util", 2965 3024 "pin-project-lite", 2966 3025 "tokio", 3026 + ] 3027 + 3028 + [[package]] 3029 + name = "tokio-websockets" 3030 + version = "0.11.4" 3031 + source = "registry+https://github.com/rust-lang/crates.io-index" 3032 + checksum = "9fcaf159b4e7a376b05b5bfd77bfd38f3324f5fce751b4213bfc7eaa47affb4e" 3033 + dependencies = [ 3034 + "base64", 3035 + "bytes", 3036 + "futures-core", 3037 + "futures-sink", 3038 + "http", 3039 + "httparse", 3040 + "rand 0.9.2", 3041 + "ring", 3042 + "rustls-native-certs", 3043 + "rustls-pki-types", 3044 + "simdutf8", 3045 + "tokio", 3046 + "tokio-rustls", 3047 + "tokio-util", 2967 3048 ] 2968 3049 2969 3050 [[package]] ··· 3158 3239 "percent-encoding", 3159 3240 "serde", 3160 3241 ] 3242 + 3243 + [[package]] 3244 + name = "urlencoding" 3245 + version = "2.1.3" 3246 + source = "registry+https://github.com/rust-lang/crates.io-index" 3247 + checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da" 3161 3248 3162 3249 [[package]] 3163 3250 name = "utf8_iter" ··· 3816 3903 "quote", 3817 3904 "syn", 3818 3905 ] 3906 + 3907 + [[package]] 3908 + name = "zstd" 3909 + version = "0.13.3" 3910 + source = "registry+https://github.com/rust-lang/crates.io-index" 3911 + checksum = "e91ee311a569c327171651566e07972200e76fcfe2242a4fa446149a3881c08a" 3912 + dependencies = [ 3913 + "zstd-safe", 3914 + ] 3915 + 3916 + [[package]] 3917 + name = "zstd-safe" 3918 + version = "7.2.4" 3919 + source = "registry+https://github.com/rust-lang/crates.io-index" 3920 + checksum = "8f49c4d5f0abb602a93fb8736af2a4f4dd9512e36f7f570d66e65ff867ed3b9d" 3921 + dependencies = [ 3922 + "zstd-sys", 3923 + ] 3924 + 3925 + [[package]] 3926 + name = "zstd-sys" 3927 + version = "2.0.16+zstd.1.5.7" 3928 + source = "registry+https://github.com/rust-lang/crates.io-index" 3929 + checksum = "91e19ebc2adc8f83e43039e79776e3fda8ca919132d68a1fed6a5faca2683748" 3930 + dependencies = [ 3931 + "cc", 3932 + "pkg-config", 3933 + ]
+1
Cargo.toml
··· 17 17 anyhow = "1.0" 18 18 async-trait = "0.1" 19 19 atproto-identity = { version = "0.11.3" } 20 + atproto-jetstream = { version = "0.11.3" } 20 21 axum = { version = "0.8" } 21 22 bincode = { version = "2.0.1", features = ["serde"] } 22 23 cadence = "1.6.0"
+106 -1
src/bin/quickdid.rs
··· 3 3 config::{CertificateBundles, DnsNameservers}, 4 4 resolve::HickoryDnsResolver, 5 5 }; 6 + use atproto_jetstream::{Consumer as JetstreamConsumer, ConsumerTaskConfig}; 6 7 use quickdid::{ 7 8 cache::create_redis_pool, 8 9 config::Config, ··· 13 14 }, 14 15 handle_resolver_task::{HandleResolverTaskConfig, create_handle_resolver_task_with_config}, 15 16 http::{AppContext, create_router}, 17 + jetstream_handler::QuickDidEventHandler, 16 18 metrics::create_metrics_publisher, 17 19 queue::{ 18 20 HandleResolutionWork, QueueAdapter, create_mpsc_queue_from_channel, create_noop_queue, ··· 151 153 ); 152 154 println!( 153 155 " PROACTIVE_REFRESH_THRESHOLD Threshold as percentage of TTL (0.0-1.0, default: 0.8)" 156 + ); 157 + println!(); 158 + println!(" JETSTREAM:"); 159 + println!( 160 + " JETSTREAM_ENABLED Enable Jetstream consumer (default: false)" 161 + ); 162 + println!( 163 + " JETSTREAM_HOSTNAME Jetstream hostname (default: jetstream.atproto.tools)" 154 164 ); 155 165 println!(); 156 166 println!( ··· 523 533 let app_context = AppContext::new( 524 534 handle_resolver.clone(), 525 535 handle_queue, 526 - metrics_publisher, 536 + metrics_publisher.clone(), 527 537 config.etag_seed.clone(), 528 538 config.cache_control_header.clone(), 529 539 config.static_files_dir.clone(), ··· 573 583 signal_token.cancel(); 574 584 tracing::info!("Signal handler task completed"); 575 585 }); 586 + } 587 + 588 + // Start Jetstream consumer if enabled 589 + if config.jetstream_enabled { 590 + let jetstream_resolver = handle_resolver.clone(); 591 + let jetstream_metrics = metrics_publisher.clone(); 592 + let jetstream_hostname = config.jetstream_hostname.clone(); 593 + let jetstream_user_agent = config.user_agent.clone(); 594 + 595 + spawn_cancellable_task( 596 + &tracker, 597 + token.clone(), 598 + "jetstream_consumer", 599 + move |cancel_token| async move { 600 + tracing::info!(hostname = %jetstream_hostname, "Starting Jetstream consumer"); 601 + 602 + // Create event handler 603 + let event_handler = Arc::new(QuickDidEventHandler::new( 604 + jetstream_resolver, 605 + jetstream_metrics.clone(), 606 + )); 607 + 608 + // Reconnection loop 609 + let mut reconnect_count = 0u32; 610 + let max_reconnects_per_minute = 5; 611 + let reconnect_window = std::time::Duration::from_secs(60); 612 + let mut last_disconnect = std::time::Instant::now() - reconnect_window; 613 + 614 + while !cancel_token.is_cancelled() { 615 + let now = std::time::Instant::now(); 616 + if now.duration_since(last_disconnect) < reconnect_window { 617 + reconnect_count += 1; 618 + if reconnect_count > max_reconnects_per_minute { 619 + tracing::warn!( 620 + count = reconnect_count, 621 + "Too many Jetstream reconnects, waiting 60 seconds" 622 + ); 623 + tokio::time::sleep(reconnect_window).await; 624 + reconnect_count = 0; 625 + last_disconnect = now; 626 + continue; 627 + } 628 + } else { 629 + reconnect_count = 0; 630 + } 631 + 632 + // Create consumer configuration 633 + let consumer_config = ConsumerTaskConfig { 634 + user_agent: jetstream_user_agent.clone(), 635 + compression: false, 636 + zstd_dictionary_location: String::new(), 637 + jetstream_hostname: jetstream_hostname.clone(), 638 + collections: vec![], // Listen to all collections 639 + dids: vec![], 640 + max_message_size_bytes: None, 641 + cursor: None, 642 + require_hello: true, 643 + }; 644 + 645 + let consumer = JetstreamConsumer::new(consumer_config); 646 + 647 + // Register event handler 648 + if let Err(e) = consumer.register_handler(event_handler.clone()).await { 649 + tracing::error!(error = ?e, "Failed to register Jetstream event handler"); 650 + continue; 651 + } 652 + 653 + // Run consumer with cancellation support 654 + match consumer.run_background(cancel_token.clone()).await { 655 + Ok(()) => { 656 + tracing::info!("Jetstream consumer stopped normally"); 657 + if cancel_token.is_cancelled() { 658 + break; 659 + } 660 + last_disconnect = std::time::Instant::now(); 661 + tokio::time::sleep(std::time::Duration::from_secs(5)).await; 662 + } 663 + Err(e) => { 664 + tracing::error!(error = ?e, "Jetstream consumer connection failed, will reconnect"); 665 + jetstream_metrics.incr("jetstream.connection.error").await; 666 + last_disconnect = std::time::Instant::now(); 667 + 668 + if !cancel_token.is_cancelled() { 669 + tokio::time::sleep(std::time::Duration::from_secs(5)).await; 670 + } 671 + } 672 + } 673 + } 674 + 675 + tracing::info!("Jetstream consumer task shutting down"); 676 + Ok(()) 677 + }, 678 + ); 679 + } else { 680 + tracing::info!("Jetstream consumer disabled"); 576 681 } 577 682 578 683 // Start HTTP server with cancellation support
+13
src/config.rs
··· 245 245 /// When set, the root handler will serve files from this directory. 246 246 /// Default: "www" (relative to working directory) 247 247 pub static_files_dir: String, 248 + 249 + /// Enable Jetstream consumer for AT Protocol events. 250 + /// When enabled, the service will consume Account and Identity events 251 + /// to maintain cache consistency. 252 + /// Default: false 253 + pub jetstream_enabled: bool, 254 + 255 + /// Jetstream WebSocket hostname for consuming AT Protocol events. 256 + /// Example: "jetstream.atproto.tools" or "jetstream1.us-west.bsky.network" 257 + /// Default: "jetstream.atproto.tools" 258 + pub jetstream_hostname: String, 248 259 } 249 260 250 261 impl Config { ··· 327 338 proactive_refresh_enabled: parse_env("PROACTIVE_REFRESH_ENABLED", false)?, 328 339 proactive_refresh_threshold: parse_env("PROACTIVE_REFRESH_THRESHOLD", 0.8)?, 329 340 static_files_dir: get_env_or_default("STATIC_FILES_DIR", Some("www")).unwrap(), 341 + jetstream_enabled: parse_env("JETSTREAM_ENABLED", false)?, 342 + jetstream_hostname: get_env_or_default("JETSTREAM_HOSTNAME", Some("jetstream.atproto.tools")).unwrap(), 330 343 }; 331 344 332 345 // Calculate the Cache-Control header value if enabled
+354
src/jetstream_handler.rs
··· 1 + //! Jetstream event handler for QuickDID 2 + //! 3 + //! This module provides the event handler for processing AT Protocol Jetstream events, 4 + //! specifically handling Account and Identity events to maintain cache consistency. 5 + 6 + use crate::handle_resolver::HandleResolver; 7 + use crate::metrics::MetricsPublisher; 8 + use anyhow::Result; 9 + use atproto_jetstream::{EventHandler, JetstreamEvent}; 10 + use std::sync::Arc; 11 + use tracing::{debug, info, warn}; 12 + 13 + /// Jetstream event handler for QuickDID 14 + /// 15 + /// This handler processes AT Protocol events from the Jetstream firehose to keep 16 + /// the handle resolver cache in sync with the network state. 17 + /// 18 + /// # Event Processing 19 + /// 20 + /// ## Account Events 21 + /// - When an account is marked as "deleted" or "deactivated", the DID is purged from the cache 22 + /// - Metrics are tracked for successful and failed purge operations 23 + /// 24 + /// ## Identity Events 25 + /// - When an identity event contains a handle, the handle-to-DID mapping is updated 26 + /// - When an identity event lacks a handle (indicating removal), the DID is purged 27 + /// - Metrics are tracked for successful and failed update/purge operations 28 + /// 29 + /// # Example 30 + /// 31 + /// ```no_run 32 + /// use quickdid::jetstream_handler::QuickDidEventHandler; 33 + /// use quickdid::handle_resolver::HandleResolver; 34 + /// use quickdid::metrics::MetricsPublisher; 35 + /// use std::sync::Arc; 36 + /// 37 + /// # async fn example(resolver: Arc<dyn HandleResolver>, metrics: Arc<dyn MetricsPublisher>) { 38 + /// let handler = QuickDidEventHandler::new(resolver, metrics); 39 + /// // Register with a JetstreamConsumer 40 + /// # } 41 + /// ``` 42 + pub struct QuickDidEventHandler { 43 + resolver: Arc<dyn HandleResolver>, 44 + metrics: Arc<dyn MetricsPublisher>, 45 + } 46 + 47 + impl QuickDidEventHandler { 48 + /// Create a new Jetstream event handler 49 + /// 50 + /// # Arguments 51 + /// 52 + /// * `resolver` - The handle resolver to use for cache operations 53 + /// * `metrics` - The metrics publisher for tracking event processing 54 + pub fn new(resolver: Arc<dyn HandleResolver>, metrics: Arc<dyn MetricsPublisher>) -> Self { 55 + Self { resolver, metrics } 56 + } 57 + } 58 + 59 + #[async_trait::async_trait] 60 + impl EventHandler for QuickDidEventHandler { 61 + fn handler_id(&self) -> String { 62 + "quickdid_handler".to_string() 63 + } 64 + 65 + async fn handle_event(&self, event: JetstreamEvent) -> Result<()> { 66 + match event { 67 + JetstreamEvent::Account { did, kind, .. } => { 68 + // If account kind is "deleted" or "deactivated", purge the DID 69 + if kind == "deleted" || kind == "deactivated" { 70 + info!(did = %did, kind = %kind, "Purging account"); 71 + match self.resolver.purge(&did).await { 72 + Ok(()) => { 73 + self.metrics.incr("jetstream.account.purged").await; 74 + } 75 + Err(e) => { 76 + warn!(did = %did, error = ?e, "Failed to purge DID"); 77 + self.metrics.incr("jetstream.account.purge_error").await; 78 + } 79 + } 80 + } 81 + self.metrics.incr("jetstream.account.processed").await; 82 + } 83 + JetstreamEvent::Identity { did, identity, .. } => { 84 + // Extract handle from identity JSON if available 85 + if !identity.is_null() { 86 + if let Some(handle_value) = identity.get("handle") { 87 + if let Some(handle) = handle_value.as_str() { 88 + info!(handle = %handle, did = %did, "Updating identity mapping"); 89 + match self.resolver.set(handle, &did).await { 90 + Ok(()) => { 91 + self.metrics.incr("jetstream.identity.updated").await; 92 + } 93 + Err(e) => { 94 + warn!(handle = %handle, did = %did, error = ?e, "Failed to update mapping"); 95 + self.metrics.incr("jetstream.identity.update_error").await; 96 + } 97 + } 98 + } else { 99 + // No handle or invalid handle, purge the DID 100 + info!(did = %did, "Purging identity without valid handle"); 101 + match self.resolver.purge(&did).await { 102 + Ok(()) => { 103 + self.metrics.incr("jetstream.identity.purged").await; 104 + } 105 + Err(e) => { 106 + warn!(did = %did, error = ?e, "Failed to purge DID"); 107 + self.metrics.incr("jetstream.identity.purge_error").await; 108 + } 109 + } 110 + } 111 + } else { 112 + // No handle field, purge the DID 113 + info!(did = %did, "Purging identity without handle field"); 114 + match self.resolver.purge(&did).await { 115 + Ok(()) => { 116 + self.metrics.incr("jetstream.identity.purged").await; 117 + } 118 + Err(e) => { 119 + warn!(did = %did, error = ?e, "Failed to purge DID"); 120 + self.metrics.incr("jetstream.identity.purge_error").await; 121 + } 122 + } 123 + } 124 + } else { 125 + // Null identity means removed, purge the DID 126 + info!(did = %did, "Purging identity with null info"); 127 + match self.resolver.purge(&did).await { 128 + Ok(()) => { 129 + self.metrics.incr("jetstream.identity.purged").await; 130 + } 131 + Err(e) => { 132 + warn!(did = %did, error = ?e, "Failed to purge DID"); 133 + self.metrics.incr("jetstream.identity.purge_error").await; 134 + } 135 + } 136 + } 137 + self.metrics.incr("jetstream.identity.processed").await; 138 + } 139 + _ => { 140 + // Other event types we don't care about 141 + debug!("Ignoring unhandled Jetstream event type"); 142 + } 143 + } 144 + Ok(()) 145 + } 146 + } 147 + 148 + #[cfg(test)] 149 + mod tests { 150 + use super::*; 151 + use crate::handle_resolver::HandleResolverError; 152 + use crate::metrics::NoOpMetricsPublisher; 153 + use async_trait::async_trait; 154 + use serde_json::json; 155 + 156 + /// Mock resolver for testing 157 + struct MockResolver { 158 + purge_called: std::sync::Arc<std::sync::Mutex<Vec<String>>>, 159 + set_called: std::sync::Arc<std::sync::Mutex<Vec<(String, String)>>>, 160 + } 161 + 162 + impl MockResolver { 163 + fn new() -> Self { 164 + Self { 165 + purge_called: std::sync::Arc::new(std::sync::Mutex::new(Vec::new())), 166 + set_called: std::sync::Arc::new(std::sync::Mutex::new(Vec::new())), 167 + } 168 + } 169 + 170 + fn get_purge_calls(&self) -> Vec<String> { 171 + self.purge_called.lock().unwrap().clone() 172 + } 173 + 174 + fn get_set_calls(&self) -> Vec<(String, String)> { 175 + self.set_called.lock().unwrap().clone() 176 + } 177 + } 178 + 179 + #[async_trait] 180 + impl HandleResolver for MockResolver { 181 + async fn resolve(&self, _handle: &str) -> Result<(String, u64), HandleResolverError> { 182 + unimplemented!("Not needed for tests") 183 + } 184 + 185 + async fn purge(&self, subject: &str) -> Result<(), HandleResolverError> { 186 + self.purge_called.lock().unwrap().push(subject.to_string()); 187 + Ok(()) 188 + } 189 + 190 + async fn set(&self, handle: &str, did: &str) -> Result<(), HandleResolverError> { 191 + self.set_called 192 + .lock() 193 + .unwrap() 194 + .push((handle.to_string(), did.to_string())); 195 + Ok(()) 196 + } 197 + } 198 + 199 + #[tokio::test] 200 + async fn test_account_deleted_event() { 201 + let resolver = Arc::new(MockResolver::new()); 202 + let metrics = Arc::new(NoOpMetricsPublisher::new()); 203 + let handler = QuickDidEventHandler::new(resolver.clone(), metrics); 204 + 205 + // Create a deleted account event 206 + let event = JetstreamEvent::Account { 207 + did: "did:plc:test123".to_string(), 208 + kind: "deleted".to_string(), 209 + time_us: 0, 210 + identity: json!(null), 211 + }; 212 + 213 + handler.handle_event(event).await.unwrap(); 214 + 215 + // Verify the DID was purged 216 + let purge_calls = resolver.get_purge_calls(); 217 + assert_eq!(purge_calls.len(), 1); 218 + assert_eq!(purge_calls[0], "did:plc:test123"); 219 + } 220 + 221 + #[tokio::test] 222 + async fn test_account_deactivated_event() { 223 + let resolver = Arc::new(MockResolver::new()); 224 + let metrics = Arc::new(NoOpMetricsPublisher::new()); 225 + let handler = QuickDidEventHandler::new(resolver.clone(), metrics); 226 + 227 + // Create a deactivated account event 228 + let event = JetstreamEvent::Account { 229 + did: "did:plc:test456".to_string(), 230 + kind: "deactivated".to_string(), 231 + time_us: 0, 232 + identity: json!(null), 233 + }; 234 + 235 + handler.handle_event(event).await.unwrap(); 236 + 237 + // Verify the DID was purged 238 + let purge_calls = resolver.get_purge_calls(); 239 + assert_eq!(purge_calls.len(), 1); 240 + assert_eq!(purge_calls[0], "did:plc:test456"); 241 + } 242 + 243 + #[tokio::test] 244 + async fn test_account_active_event() { 245 + let resolver = Arc::new(MockResolver::new()); 246 + let metrics = Arc::new(NoOpMetricsPublisher::new()); 247 + let handler = QuickDidEventHandler::new(resolver.clone(), metrics); 248 + 249 + // Create an active account event (should not purge) 250 + let event = JetstreamEvent::Account { 251 + did: "did:plc:test789".to_string(), 252 + kind: "active".to_string(), 253 + time_us: 0, 254 + identity: json!(null), 255 + }; 256 + 257 + handler.handle_event(event).await.unwrap(); 258 + 259 + // Verify the DID was NOT purged 260 + let purge_calls = resolver.get_purge_calls(); 261 + assert_eq!(purge_calls.len(), 0); 262 + } 263 + 264 + #[tokio::test] 265 + async fn test_identity_with_handle_event() { 266 + let resolver = Arc::new(MockResolver::new()); 267 + let metrics = Arc::new(NoOpMetricsPublisher::new()); 268 + let handler = QuickDidEventHandler::new(resolver.clone(), metrics); 269 + 270 + // Create an identity event with a handle 271 + let event = JetstreamEvent::Identity { 272 + did: "did:plc:testuser".to_string(), 273 + kind: "update".to_string(), 274 + time_us: 0, 275 + identity: json!({ 276 + "handle": "alice.bsky.social" 277 + }), 278 + }; 279 + 280 + handler.handle_event(event).await.unwrap(); 281 + 282 + // Verify the set method was called 283 + let set_calls = resolver.get_set_calls(); 284 + assert_eq!(set_calls.len(), 1); 285 + assert_eq!(set_calls[0], ("alice.bsky.social".to_string(), "did:plc:testuser".to_string())); 286 + 287 + // Verify no purge was called 288 + let purge_calls = resolver.get_purge_calls(); 289 + assert_eq!(purge_calls.len(), 0); 290 + } 291 + 292 + #[tokio::test] 293 + async fn test_identity_without_handle_event() { 294 + let resolver = Arc::new(MockResolver::new()); 295 + let metrics = Arc::new(NoOpMetricsPublisher::new()); 296 + let handler = QuickDidEventHandler::new(resolver.clone(), metrics); 297 + 298 + // Create an identity event without a handle field 299 + let event = JetstreamEvent::Identity { 300 + did: "did:plc:nohandle".to_string(), 301 + kind: "update".to_string(), 302 + time_us: 0, 303 + identity: json!({ 304 + "other_field": "value" 305 + }), 306 + }; 307 + 308 + handler.handle_event(event).await.unwrap(); 309 + 310 + // Verify the DID was purged 311 + let purge_calls = resolver.get_purge_calls(); 312 + assert_eq!(purge_calls.len(), 1); 313 + assert_eq!(purge_calls[0], "did:plc:nohandle"); 314 + 315 + // Verify set was not called 316 + let set_calls = resolver.get_set_calls(); 317 + assert_eq!(set_calls.len(), 0); 318 + } 319 + 320 + #[tokio::test] 321 + async fn test_identity_with_null_identity() { 322 + let resolver = Arc::new(MockResolver::new()); 323 + let metrics = Arc::new(NoOpMetricsPublisher::new()); 324 + let handler = QuickDidEventHandler::new(resolver.clone(), metrics); 325 + 326 + // Create an identity event with null identity 327 + let event = JetstreamEvent::Identity { 328 + did: "did:plc:nullidentity".to_string(), 329 + kind: "delete".to_string(), 330 + time_us: 0, 331 + identity: json!(null), 332 + }; 333 + 334 + handler.handle_event(event).await.unwrap(); 335 + 336 + // Verify the DID was purged 337 + let purge_calls = resolver.get_purge_calls(); 338 + assert_eq!(purge_calls.len(), 1); 339 + assert_eq!(purge_calls[0], "did:plc:nullidentity"); 340 + 341 + // Verify set was not called 342 + let set_calls = resolver.get_set_calls(); 343 + assert_eq!(set_calls.len(), 0); 344 + } 345 + 346 + #[tokio::test] 347 + async fn test_handler_id() { 348 + let resolver = Arc::new(MockResolver::new()); 349 + let metrics = Arc::new(NoOpMetricsPublisher::new()); 350 + let handler = QuickDidEventHandler::new(resolver, metrics); 351 + 352 + assert_eq!(handler.handler_id(), "quickdid_handler"); 353 + } 354 + }
+1
src/lib.rs
··· 2 2 pub mod config; // Config and Args needed by binary 3 3 pub mod handle_resolver; // Only traits and factory functions exposed 4 4 pub mod http; // Only create_router exposed 5 + pub mod jetstream_handler; // Jetstream event handler for AT Protocol events 5 6 6 7 // Semi-public modules - needed by binary but with limited exposure 7 8 pub mod cache; // Only create_redis_pool exposed