···11+-- Generic table for storing AT Protocol records
22+CREATE TABLE atproto_records (
33+ aturi VARCHAR(1024) PRIMARY KEY,
44+ did VARCHAR(256) NOT NULL,
55+ cid VARCHAR(256) NOT NULL,
66+ collection VARCHAR(256) NOT NULL,
77+ indexed_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
88+ record JSONB NOT NULL
99+);
1010+1111+CREATE INDEX idx_atproto_records_did ON atproto_records (did);
1212+CREATE INDEX idx_atproto_records_cid ON atproto_records (cid);
1313+CREATE INDEX idx_atproto_records_collection ON atproto_records (collection);
1414+CREATE INDEX idx_atproto_records_indexed_at ON atproto_records (indexed_at);
+12-7
src/http/handle_xrpc_search_events.rs
···1717#[derive(Debug, Deserialize)]
1818pub struct SearchEventsParams {
1919 repository: Option<String>,
2020- query: String,
2121-2222- #[allow(dead_code)]
2020+ query: Option<String>,
2121+ #[serde(default)]
2222+ location: Vec<String>,
2323 limit: Option<u32>,
2424}
2525···5353 State(web_context): State<WebContext>,
5454 Query(params): Query<SearchEventsParams>,
5555) -> Result<impl IntoResponse, WebError> {
5656+ // Return empty list when no search parameters provided
5757+ if params.query.is_none() && params.repository.is_none() && params.location.is_empty() {
5858+ return Ok(Json(SearchEventsResponse { results: vec![] }).into_response());
5959+ }
6060+5661 let did = if let Some(repository) = params.repository.as_ref() {
5762 match parse_input(repository) {
5863 Ok(InputType::Plc(value)) | Ok(InputType::Web(value)) => Some(value),
···105110106111 // Perform search using centralized methods
107112 let limit = params.limit.unwrap_or(10);
108108- let is_upcoming = params.query == "upcoming";
113113+ let is_upcoming = params.query.as_deref() == Some("upcoming");
109114110115 let event_ids = if is_upcoming {
111116 // Search for upcoming events
···127132 }
128133 }
129134 } else {
130130- // Search by query string
135135+ // Search with optional query, DID filter, and location CIDs
131136 match manager
132132- .search_events_by_query(¶ms.query, did.as_deref(), limit)
137137+ .search_events(params.query.as_deref(), did.as_deref(), ¶ms.location, limit)
133138 .await
134139 {
135140 Ok(ids) => ids,
136141 Err(err) => {
137137- tracing::error!(?err, "Failed to search events by query");
142142+ tracing::error!(?err, "Failed to search events");
138143 return Ok((
139144 StatusCode::INTERNAL_SERVER_ERROR,
140145 Json(ErrorResponse {
···11use anyhow::Result;
22+use atproto_attestation::create_dagbor_cid;
23use atproto_identity::{model::Document, resolve::IdentityResolver, traits::DidDocumentStorage};
34use atproto_record::lexicon::app::bsky::richtext::facet::{Facet, FacetFeature};
45use atproto_record::lexicon::community::lexicon::calendar::event::NSID as LexiconCommunityEventNSID;
···2930 uri
3031}
31323232-/// A lightweight event struct for search indexing that excludes problematic fields like locations.
3333-/// This avoids deserialization errors when event data contains location types not supported
3434-/// by the LocationOrRef enum (e.g., new or unknown location formats).
3333+/// Generate a DAG-CBOR CID from a JSON value.
3434+///
3535+/// Creates a CIDv1 with DAG-CBOR codec (0x71) and SHA-256 hash (0x12),
3636+/// following the AT Protocol specification for content addressing.
3737+fn generate_location_cid(value: &Value) -> Result<String, SearchIndexerError> {
3838+ let cid = create_dagbor_cid(value).map_err(|e| SearchIndexerError::CidGenerationFailed {
3939+ error: e.to_string(),
4040+ })?;
4141+ Ok(cid.to_string())
4242+}
4343+4444+/// A lightweight event struct for search indexing.
4545+///
4646+/// Uses serde_json::Value for locations to avoid deserialization errors
4747+/// when event data contains location types not supported by the LocationOrRef enum.
3548#[derive(Deserialize)]
3649struct IndexableEvent {
3750 name: String,
···4457 ends_at: Option<chrono::DateTime<chrono::Utc>>,
4558 #[serde(rename = "descriptionFacets")]
4659 facets: Option<Vec<Facet>>,
6060+ /// Locations stored as raw JSON values for CID generation.
6161+ #[serde(default)]
6262+ locations: Vec<Value>,
4763}
48644965impl IndexableEvent {
···134150 "name": { "type": "text" },
135151 "description": { "type": "text" },
136152 "tags": { "type": "keyword" },
153153+ "location_cids": { "type": "keyword" },
137154 "start_time": { "type": "date" },
138155 "end_time": { "type": "date" },
139156 "created_at": { "type": "date" },
···235252 }
236253237254 async fn index_event(&self, did: &str, rkey: &str, record: Value) -> Result<()> {
238238- // Use IndexableEvent which excludes problematic fields like locations
239255 let event: IndexableEvent = serde_json::from_value(record)?;
240256241257 let document = self.ensure_identity_stored(did).await?;
···253269 // Extract hashtags from facets
254270 let tags = event.get_hashtags();
255271272272+ // Generate CIDs for each location
273273+ let location_cids: Vec<String> = event
274274+ .locations
275275+ .iter()
276276+ .filter_map(|loc| generate_location_cid(loc).ok())
277277+ .collect();
278278+256279 let mut doc = json!({
257280 "did": did,
258281 "handle": handle,
259282 "name": name,
260283 "description": description,
261284 "tags": tags,
285285+ "location_cids": location_cids,
262286 "created_at": json!(created_at),
263287 "updated_at": json!(chrono::Utc::now())
264288 });
+7
src/task_search_indexer_errors.rs
···1212 /// and the operation fails with a server error response.
1313 #[error("error-smokesignal-search-indexer-1 Failed to create index: {error_body}")]
1414 IndexCreationFailed { error_body: String },
1515+1616+ /// Error when CID generation fails.
1717+ ///
1818+ /// This error occurs when serializing location data to DAG-CBOR
1919+ /// or generating the multihash for a CID fails.
2020+ #[error("error-smokesignal-search-indexer-2 Failed to generate CID: {error}")]
2121+ CidGenerationFailed { error: String },
1522}