# NSID Autocomplete Implementation Plan Redis-based autocomplete for lexicon NSIDs using `FT.SUGADD`/`FT.SUGGET` commands. ## Overview - **Index command**: `FT.SUGADD autocomplete {nsid} 1.0 PAYLOAD "{did} {nsid}"` - **Query command**: `FT.SUGGET autocomplete "{query}" FUZZY WITHPAYLOADS MAX 10` - **API endpoint**: `GET /api/autocomplete-nsid?q={query}` - **UI**: Search input in navbar with dropdown autocomplete ## Prerequisites - **Redis Stack** or **RediSearch module** required (standard Redis does not support `FT.SUG*` commands) - Existing `REDIS_CACHE_ENABLED` and `REDIS_URL` configuration will be reused --- ## Implementation Steps ### 1. Extend Redis Cache Module **File**: `src/cache/redis.rs` Add autocomplete methods to `RedisCache`: ```rust // Constants const AUTOCOMPLETE_KEY: &str = "lexicon:autocomplete"; // New struct for results #[derive(Debug, Clone, Serialize, Deserialize)] pub struct AutocompleteSuggestion { pub nsid: String, pub did: String, } impl RedisCache { /// FT.SUGADD autocomplete {nsid} 1.0 PAYLOAD "{did} {nsid}" pub async fn add_nsid_suggestion(&self, nsid: &str, did: &str) { ... } /// FT.SUGDEL autocomplete {nsid} pub async fn remove_nsid_suggestion(&self, nsid: &str) { ... } /// FT.SUGGET autocomplete "{query}" FUZZY WITHPAYLOADS MAX {limit} pub async fn get_nsid_suggestions(&self, query: &str, limit: Option) -> Vec { ... } /// FT.SUGLEN autocomplete pub async fn get_suggestion_count(&self) -> Option { ... } } ``` Use `deadpool_redis::redis::cmd()` for raw commands since `FT.SUG*` aren't in `AsyncCommands`. **Update**: `src/cache/mod.rs` - export `AutocompleteSuggestion` --- ### 2. TAP Consumer Integration **File**: `src/tap_consumer.rs` Add autocomplete indexing inside the `if is_authoritative` block (after graph edge insertion, around line ~260): ```rust // Inside the `if is_authoritative { ... }` block, after graph edge processing: // Add NSID to autocomplete index (best-effort, authoritative only) if let Some(ref cache) = self.redis_cache { cache.add_nsid_suggestion(nsid, did).await; } ``` This ensures only authoritative schemas are indexed for autocomplete, matching the behavior of `handle_admin_autocomplete_rebuild`. Note: No need to remove on delete since the rebuild can clean up stale entries. --- ### 3. Admin Backfill Endpoint (Authoritative Only) **File**: `src/http/handle_admin.rs` Add handler following the same pattern as `handle_admin_graph_rebuild` - only indexes authoritative, non-deleted schemas: ```rust /// POST /admin/autocomplete/rebuild /// Only indexes authoritative schemas (where the NSID authority matches the record DID) pub async fn handle_admin_autocomplete_rebuild( State(web_context): State, ) -> Result { let redis_cache = web_context.redis_cache.as_ref() .ok_or_else(|| WebError::Internal(anyhow::anyhow!("Redis not configured")))?; // Get all distinct NSIDs (reuse existing function) let nsids = schema_list_distinct_nsids(&web_context.pool).await?; tracing::info!(nsid_count = nsids.len(), "Rebuilding autocomplete index from authoritative schemas"); let mut indexed = 0usize; let mut authority_failures = 0usize; // For each NSID, resolve authority and index only if authoritative schema exists for nsid in &nsids { // Resolve the authority DID for this NSID let authority_did = match resolve_schema_authority( &web_context.pool, web_context.dns_resolver.as_ref(), nsid, web_context.redis_cache.as_ref(), ) .await { Ok(did) => did, Err(e) => { tracing::debug!(nsid = %nsid, error = %e, "Failed to resolve authority during autocomplete rebuild"); authority_failures += 1; continue; } }; // Construct the AT-URI for the authoritative schema let aturi = format!("at://{}/com.atproto.lexicon.schema/{}", authority_did, nsid); // Get the latest schema from the authoritative source let schema = match schema_get_latest(&web_context.pool, &aturi).await? { Some(s) if s.deleted_at.is_none() => s, _ => { tracing::debug!( nsid = %nsid, authority_did = %authority_did, "No authoritative schema found during autocomplete rebuild" ); continue; } }; // Add to autocomplete index redis_cache.add_nsid_suggestion(&schema.nsid, &schema.did).await; indexed += 1; } tracing::info!( indexed = indexed, authority_failures = authority_failures, "Autocomplete index rebuild complete" ); Ok(Redirect::to("/admin")) } ``` **File**: `src/http/server.rs` - Add routes under admin nest: ```rust .route("/autocomplete", get(handle_admin_autocomplete)) .route("/autocomplete/rebuild", post(handle_admin_autocomplete_rebuild)) ``` **File**: `templates/admin/autocomplete.html` - Admin page template showing: - Indexed NSIDs count - Redis connection status - Rebuild Index button **Note**: No new storage function needed - reuses existing `schema_list_distinct_nsids` and `schema_get_latest`. --- ### 4. Autocomplete API Endpoint **File**: `src/http/handle_autocomplete.rs` (new) ```rust #[derive(Debug, Deserialize)] pub struct AutocompleteQuery { pub q: String, pub limit: Option, } #[derive(Debug, Serialize)] pub struct AutocompleteResponse { pub suggestions: Vec, } #[derive(Debug, Serialize)] pub struct AutocompleteSuggestionItem { pub nsid: String, pub did: String, pub url: String, // e.g., "/lexicon/{did}/{nsid}" } /// GET /api/autocomplete-nsid?q={query}&limit={limit} pub async fn handle_autocomplete_nsid( State(web_context): State, Query(query): Query, ) -> impl IntoResponse { // Validate: return empty if q < 2 chars // Cap limit at 20 // Call redis_cache.get_nsid_suggestions() if available // Return JSON response } ``` **File**: `src/http/mod.rs` - Add `pub mod handle_autocomplete;` **File**: `src/http/server.rs` - Add route: ```rust .route("/api/autocomplete-nsid", get(handle_autocomplete_nsid)) ``` --- ### 5. Navbar Search UI **File**: `templates/base.html` Replace nav section (lines 100-108): ```html
``` Add CSS for dropdown (in `