···1+-- Generic table for storing AT Protocol records
2+CREATE TABLE atproto_records (
3+ aturi VARCHAR(1024) PRIMARY KEY,
4+ did VARCHAR(256) NOT NULL,
5+ cid VARCHAR(256) NOT NULL,
6+ collection VARCHAR(256) NOT NULL,
7+ indexed_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
8+ record JSONB NOT NULL
9+);
10+11+CREATE INDEX idx_atproto_records_did ON atproto_records (did);
12+CREATE INDEX idx_atproto_records_cid ON atproto_records (cid);
13+CREATE INDEX idx_atproto_records_collection ON atproto_records (collection);
14+CREATE INDEX idx_atproto_records_indexed_at ON atproto_records (indexed_at);
+12-7
src/http/handle_xrpc_search_events.rs
···17#[derive(Debug, Deserialize)]
18pub struct SearchEventsParams {
19 repository: Option<String>,
20- query: String,
21-22- #[allow(dead_code)]
23 limit: Option<u32>,
24}
25···53 State(web_context): State<WebContext>,
54 Query(params): Query<SearchEventsParams>,
55) -> Result<impl IntoResponse, WebError> {
0000056 let did = if let Some(repository) = params.repository.as_ref() {
57 match parse_input(repository) {
58 Ok(InputType::Plc(value)) | Ok(InputType::Web(value)) => Some(value),
···105106 // Perform search using centralized methods
107 let limit = params.limit.unwrap_or(10);
108- let is_upcoming = params.query == "upcoming";
109110 let event_ids = if is_upcoming {
111 // Search for upcoming events
···127 }
128 }
129 } else {
130- // Search by query string
131 match manager
132- .search_events_by_query(¶ms.query, did.as_deref(), limit)
133 .await
134 {
135 Ok(ids) => ids,
136 Err(err) => {
137- tracing::error!(?err, "Failed to search events by query");
138 return Ok((
139 StatusCode::INTERNAL_SERVER_ERROR,
140 Json(ErrorResponse {
···17#[derive(Debug, Deserialize)]
18pub struct SearchEventsParams {
19 repository: Option<String>,
20+ query: Option<String>,
21+ #[serde(default)]
22+ location: Vec<String>,
23 limit: Option<u32>,
24}
25···53 State(web_context): State<WebContext>,
54 Query(params): Query<SearchEventsParams>,
55) -> Result<impl IntoResponse, WebError> {
56+ // Return empty list when no search parameters provided
57+ if params.query.is_none() && params.repository.is_none() && params.location.is_empty() {
58+ return Ok(Json(SearchEventsResponse { results: vec![] }).into_response());
59+ }
60+61 let did = if let Some(repository) = params.repository.as_ref() {
62 match parse_input(repository) {
63 Ok(InputType::Plc(value)) | Ok(InputType::Web(value)) => Some(value),
···110111 // Perform search using centralized methods
112 let limit = params.limit.unwrap_or(10);
113+ let is_upcoming = params.query.as_deref() == Some("upcoming");
114115 let event_ids = if is_upcoming {
116 // Search for upcoming events
···132 }
133 }
134 } else {
135+ // Search with optional query, DID filter, and location CIDs
136 match manager
137+ .search_events(params.query.as_deref(), did.as_deref(), ¶ms.location, limit)
138 .await
139 {
140 Ok(ids) => ids,
141 Err(err) => {
142+ tracing::error!(?err, "Failed to search events");
143 return Ok((
144 StatusCode::INTERNAL_SERVER_ERROR,
145 Json(ErrorResponse {
···1use anyhow::Result;
02use atproto_identity::{model::Document, resolve::IdentityResolver, traits::DidDocumentStorage};
3use atproto_record::lexicon::app::bsky::richtext::facet::{Facet, FacetFeature};
4use atproto_record::lexicon::community::lexicon::calendar::event::NSID as LexiconCommunityEventNSID;
···29 uri
30}
3132-/// A lightweight event struct for search indexing that excludes problematic fields like locations.
33-/// This avoids deserialization errors when event data contains location types not supported
34-/// by the LocationOrRef enum (e.g., new or unknown location formats).
00000000000035#[derive(Deserialize)]
36struct IndexableEvent {
37 name: String,
···44 ends_at: Option<chrono::DateTime<chrono::Utc>>,
45 #[serde(rename = "descriptionFacets")]
46 facets: Option<Vec<Facet>>,
00047}
4849impl IndexableEvent {
···134 "name": { "type": "text" },
135 "description": { "type": "text" },
136 "tags": { "type": "keyword" },
0137 "start_time": { "type": "date" },
138 "end_time": { "type": "date" },
139 "created_at": { "type": "date" },
···235 }
236237 async fn index_event(&self, did: &str, rkey: &str, record: Value) -> Result<()> {
238- // Use IndexableEvent which excludes problematic fields like locations
239 let event: IndexableEvent = serde_json::from_value(record)?;
240241 let document = self.ensure_identity_stored(did).await?;
···253 // Extract hashtags from facets
254 let tags = event.get_hashtags();
2550000000256 let mut doc = json!({
257 "did": did,
258 "handle": handle,
259 "name": name,
260 "description": description,
261 "tags": tags,
0262 "created_at": json!(created_at),
263 "updated_at": json!(chrono::Utc::now())
264 });
···1use anyhow::Result;
2+use atproto_attestation::create_dagbor_cid;
3use atproto_identity::{model::Document, resolve::IdentityResolver, traits::DidDocumentStorage};
4use atproto_record::lexicon::app::bsky::richtext::facet::{Facet, FacetFeature};
5use atproto_record::lexicon::community::lexicon::calendar::event::NSID as LexiconCommunityEventNSID;
···30 uri
31}
3233+/// Generate a DAG-CBOR CID from a JSON value.
34+///
35+/// Creates a CIDv1 with DAG-CBOR codec (0x71) and SHA-256 hash (0x12),
36+/// following the AT Protocol specification for content addressing.
37+fn generate_location_cid(value: &Value) -> Result<String, SearchIndexerError> {
38+ let cid = create_dagbor_cid(value).map_err(|e| SearchIndexerError::CidGenerationFailed {
39+ error: e.to_string(),
40+ })?;
41+ Ok(cid.to_string())
42+}
43+44+/// A lightweight event struct for search indexing.
45+///
46+/// Uses serde_json::Value for locations to avoid deserialization errors
47+/// when event data contains location types not supported by the LocationOrRef enum.
48#[derive(Deserialize)]
49struct IndexableEvent {
50 name: String,
···57 ends_at: Option<chrono::DateTime<chrono::Utc>>,
58 #[serde(rename = "descriptionFacets")]
59 facets: Option<Vec<Facet>>,
60+ /// Locations stored as raw JSON values for CID generation.
61+ #[serde(default)]
62+ locations: Vec<Value>,
63}
6465impl IndexableEvent {
···150 "name": { "type": "text" },
151 "description": { "type": "text" },
152 "tags": { "type": "keyword" },
153+ "location_cids": { "type": "keyword" },
154 "start_time": { "type": "date" },
155 "end_time": { "type": "date" },
156 "created_at": { "type": "date" },
···252 }
253254 async fn index_event(&self, did: &str, rkey: &str, record: Value) -> Result<()> {
0255 let event: IndexableEvent = serde_json::from_value(record)?;
256257 let document = self.ensure_identity_stored(did).await?;
···269 // Extract hashtags from facets
270 let tags = event.get_hashtags();
271272+ // Generate CIDs for each location
273+ let location_cids: Vec<String> = event
274+ .locations
275+ .iter()
276+ .filter_map(|loc| generate_location_cid(loc).ok())
277+ .collect();
278+279 let mut doc = json!({
280 "did": did,
281 "handle": handle,
282 "name": name,
283 "description": description,
284 "tags": tags,
285+ "location_cids": location_cids,
286 "created_at": json!(created_at),
287 "updated_at": json!(chrono::Utc::now())
288 });
+7
src/task_search_indexer_errors.rs
···12 /// and the operation fails with a server error response.
13 #[error("error-smokesignal-search-indexer-1 Failed to create index: {error_body}")]
14 IndexCreationFailed { error_body: String },
000000015}
···12 /// and the operation fails with a server error response.
13 #[error("error-smokesignal-search-indexer-1 Failed to create index: {error_body}")]
14 IndexCreationFailed { error_body: String },
15+16+ /// Error when CID generation fails.
17+ ///
18+ /// This error occurs when serializing location data to DAG-CBOR
19+ /// or generating the multihash for a CID fails.
20+ #[error("error-smokesignal-search-indexer-2 Failed to generate CID: {error}")]
21+ CidGenerationFailed { error: String },
22}