Highly ambitious ATProtocol AppView service and sdks

grapql query improvements, added dataloader, cleaned up forward/reverse join types, added more where clauses (gt, lt, gte, lte), updated docs

+2886 -162
+28
api/src/api/xrpc_dynamic.rs
··· 132 132 where_conditions.insert( 133 133 "did".to_string(), 134 134 WhereCondition { 135 + gt: None, 136 + gte: None, 137 + lt: None, 138 + lte: None, 135 139 eq: Some(serde_json::Value::String(author_str.to_string())), 136 140 in_values: None, 137 141 contains: None, ··· 145 149 where_conditions.insert( 146 150 "did".to_string(), 147 151 WhereCondition { 152 + gt: None, 153 + gte: None, 154 + lt: None, 155 + lte: None, 148 156 eq: None, 149 157 in_values: Some(authors), 150 158 contains: None, ··· 161 169 where_conditions.insert( 162 170 field.to_string(), 163 171 WhereCondition { 172 + gt: None, 173 + gte: None, 174 + lt: None, 175 + lte: None, 164 176 eq: None, 165 177 in_values: None, 166 178 contains: Some(query_str.to_string()), ··· 172 184 where_conditions.insert( 173 185 "collection".to_string(), 174 186 WhereCondition { 187 + gt: None, 188 + gte: None, 189 + lt: None, 190 + lte: None, 175 191 eq: Some(serde_json::Value::String(collection.clone())), 176 192 in_values: None, 177 193 contains: None, ··· 307 323 where_clause.conditions.insert( 308 324 "collection".to_string(), 309 325 WhereCondition { 326 + gt: None, 327 + gte: None, 328 + lt: None, 329 + lte: None, 310 330 eq: Some(serde_json::Value::String(collection.clone())), 311 331 in_values: None, 312 332 contains: None, ··· 385 405 where_clause.conditions.insert( 386 406 "collection".to_string(), 387 407 WhereCondition { 408 + gt: None, 409 + gte: None, 410 + lt: None, 411 + lte: None, 388 412 eq: Some(collection.clone().into()), 389 413 contains: None, 390 414 in_values: None, ··· 444 468 where_clause.conditions.insert( 445 469 "collection".to_string(), 446 470 WhereCondition { 471 + gt: None, 472 + gte: None, 473 + lt: None, 474 + lte: None, 447 475 eq: Some(collection.clone().into()), 448 476 in_values: None, 449 477 contains: None,
+790
api/src/api/xrpc_dynamic.rs.bak
··· 1 + use atproto_client::com::atproto::repo::{ 2 + CreateRecordRequest, CreateRecordResponse, DeleteRecordRequest, PutRecordRequest, 3 + PutRecordResponse, create_record, delete_record, put_record, 4 + }; 5 + use axum::{ 6 + extract::{Path, Query, State}, 7 + http::HeaderMap, 8 + response::Json, 9 + }; 10 + use chrono::Utc; 11 + use serde::Deserialize; 12 + 13 + use crate::AppState; 14 + use crate::auth::{ 15 + extract_bearer_token, get_atproto_auth_for_user_cached, verify_oauth_token_cached, 16 + }; 17 + use crate::errors::AppError; 18 + use crate::models::{ 19 + IndexedRecord, Record, SliceRecordsOutput, SliceRecordsParams, SortField, WhereCondition, 20 + }; 21 + use std::collections::HashMap; 22 + 23 + 24 + #[derive(Deserialize)] 25 + pub struct GetRecordParams { 26 + pub uri: String, 27 + pub slice: String, 28 + } 29 + 30 + // Dynamic XRPC handler that routes based on method name (for GET requests) 31 + pub async fn dynamic_xrpc_handler( 32 + Path(method): Path<String>, 33 + State(state): State<AppState>, 34 + Query(params): Query<serde_json::Value>, 35 + ) -> Result<Json<serde_json::Value>, AppError> { 36 + // Parse the XRPC method (e.g., "social.grain.gallery.getRecords") 37 + if method.ends_with(".getRecords") { 38 + let collection = method.trim_end_matches(".getRecords").to_string(); 39 + dynamic_get_records_handler(collection, state, params).await 40 + } else if method.ends_with(".countRecords") { 41 + let collection = method.trim_end_matches(".countRecords").to_string(); 42 + dynamic_count_records_handler(collection, state, params).await 43 + } else if method.ends_with(".getRecord") { 44 + let collection = method.trim_end_matches(".getRecord").to_string(); 45 + dynamic_get_record_impl(collection, state, params).await 46 + } else { 47 + Err(AppError::NotFound("Unknown XRPC method".to_string())) 48 + } 49 + } 50 + 51 + // Dynamic XRPC handler for POST requests (create, update, delete) 52 + pub async fn dynamic_xrpc_post_handler( 53 + Path(method): Path<String>, 54 + State(state): State<AppState>, 55 + headers: HeaderMap, 56 + Json(body): Json<serde_json::Value>, 57 + ) -> Result<Json<serde_json::Value>, AppError> { 58 + // Handle dynamic collection methods (e.g., social.grain.gallery.createRecord) 59 + if method.ends_with(".getRecords") { 60 + let collection = method.trim_end_matches(".getRecords").to_string(); 61 + dynamic_get_records_post_handler(collection, state, body).await 62 + } else if method.ends_with(".countRecords") { 63 + let collection = method.trim_end_matches(".countRecords").to_string(); 64 + dynamic_count_records_post_handler(collection, state, body).await 65 + } else if method.ends_with(".createRecord") { 66 + let collection = method.trim_end_matches(".createRecord").to_string(); 67 + dynamic_collection_create_impl(state, headers, body, collection).await 68 + } else if method.ends_with(".updateRecord") { 69 + let collection = method.trim_end_matches(".updateRecord").to_string(); 70 + dynamic_collection_update_impl(state, headers, body, collection).await 71 + } else if method.ends_with(".deleteRecord") { 72 + let collection = method.trim_end_matches(".deleteRecord").to_string(); 73 + dynamic_collection_delete_impl(state, headers, body, collection).await 74 + } else { 75 + Err(AppError::NotFound("Method not found".to_string())) 76 + } 77 + } 78 + 79 + // Handler for get records using unified where clause approach 80 + async fn dynamic_get_records_handler( 81 + collection: String, 82 + state: AppState, 83 + params: serde_json::Value, 84 + ) -> Result<Json<serde_json::Value>, AppError> { 85 + // Parse parameters into SliceRecordsParams format 86 + let slice = params 87 + .get("slice") 88 + .and_then(|v| v.as_str()) 89 + .ok_or(AppError::BadRequest("Missing slice parameter".to_string()))? 90 + .to_string(); 91 + 92 + let limit = params.get("limit").and_then(|v| { 93 + if let Some(s) = v.as_str() { 94 + s.parse::<i32>().ok() 95 + } else { 96 + v.as_i64().map(|i| i as i32) 97 + } 98 + }); 99 + 100 + let cursor = params 101 + .get("cursor") 102 + .and_then(|v| v.as_str()) 103 + .map(|s| s.to_string()); 104 + 105 + // Parse sortBy from params - convert legacy sort string to new array format if present 106 + let sort_by = params.get("sort").and_then(|v| v.as_str()).map(|sort_str| { 107 + // Convert legacy "field:direction" format to new array format 108 + let mut sort_fields = Vec::new(); 109 + for sort_item in sort_str.split(',') { 110 + let parts: Vec<&str> = sort_item.trim().split(':').collect(); 111 + if parts.len() == 2 { 112 + sort_fields.push(SortField { 113 + field: parts[0].trim().to_string(), 114 + direction: parts[1].trim().to_string(), 115 + }); 116 + } else if parts.len() == 1 && !parts[0].is_empty() { 117 + // Default to ascending if no direction specified 118 + sort_fields.push(SortField { 119 + field: parts[0].trim().to_string(), 120 + direction: "asc".to_string(), 121 + }); 122 + } 123 + } 124 + sort_fields 125 + }); 126 + 127 + // Parse where conditions from query params if present 128 + let mut where_conditions = HashMap::new(); 129 + 130 + // Handle legacy author/authors params by converting to where clause 131 + if let Some(author_str) = params.get("author").and_then(|v| v.as_str()) { 132 + where_conditions.insert( 133 + "did".to_string(), 134 + WhereCondition { 135 + eq: Some(serde_json::Value::String(author_str.to_string())), 136 + in_values: None, 137 + contains: None, 138 + }, 139 + ); 140 + } else if let Some(authors_str) = params.get("authors").and_then(|v| v.as_str()) { 141 + let authors: Vec<serde_json::Value> = authors_str 142 + .split(',') 143 + .map(|s| serde_json::Value::String(s.trim().to_string())) 144 + .collect(); 145 + where_conditions.insert( 146 + "did".to_string(), 147 + WhereCondition { 148 + eq: None, 149 + in_values: Some(authors), 150 + contains: None, 151 + }, 152 + ); 153 + } 154 + 155 + // Handle legacy query param by converting to where clause with contains 156 + if let Some(query_str) = params.get("query").and_then(|v| v.as_str()) { 157 + let field = params 158 + .get("field") 159 + .and_then(|v| v.as_str()) 160 + .unwrap_or("text"); // Default to text field for search 161 + where_conditions.insert( 162 + field.to_string(), 163 + WhereCondition { 164 + eq: None, 165 + in_values: None, 166 + contains: Some(query_str.to_string()), 167 + }, 168 + ); 169 + } 170 + 171 + // Add collection filter to where conditions 172 + where_conditions.insert( 173 + "collection".to_string(), 174 + WhereCondition { 175 + eq: Some(serde_json::Value::String(collection.clone())), 176 + in_values: None, 177 + contains: None, 178 + }, 179 + ); 180 + 181 + let where_clause = Some(crate::models::WhereClause { 182 + conditions: where_conditions, 183 + or_conditions: None, 184 + }); 185 + 186 + let records_params = SliceRecordsParams { 187 + slice, 188 + limit, 189 + cursor, 190 + where_clause, 191 + sort_by: sort_by.clone(), 192 + }; 193 + 194 + // First verify the collection belongs to this slice 195 + let slice_collections = state 196 + .database 197 + .get_slice_collections_list(&records_params.slice) 198 + .await 199 + .map_err(|_| AppError::Internal("Database error".to_string()))?; 200 + 201 + // Special handling: network.slices.lexicon is always allowed as it defines the schema 202 + if collection != "network.slices.lexicon" && !slice_collections.contains(&collection) { 203 + return Err(AppError::NotFound("Collection not found".to_string())); 204 + } 205 + 206 + // Use the unified database method 207 + match state 208 + .database 209 + .get_slice_collections_records( 210 + &records_params.slice, 211 + records_params.limit, 212 + records_params.cursor.as_deref(), 213 + sort_by.as_ref(), 214 + records_params.where_clause.as_ref(), 215 + ) 216 + .await 217 + { 218 + Ok((records, cursor)) => { 219 + // No need to filter - collection filter is in the SQL query now 220 + 221 + let indexed_records: Vec<IndexedRecord> = records 222 + .into_iter() 223 + .map(|record| IndexedRecord { 224 + uri: record.uri, 225 + cid: record.cid, 226 + did: record.did, 227 + collection: record.collection, 228 + value: record.json, 229 + indexed_at: record.indexed_at.to_rfc3339(), 230 + }) 231 + .collect(); 232 + 233 + let output = SliceRecordsOutput { 234 + records: indexed_records, 235 + cursor, 236 + }; 237 + 238 + Ok(Json( 239 + serde_json::to_value(output).map_err(|_| AppError::Internal("Serialization error".to_string()))?, 240 + )) 241 + } 242 + Err(_) => Err(AppError::Internal("Database error".to_string())), 243 + } 244 + } 245 + 246 + // Implementation for get record 247 + async fn dynamic_get_record_impl( 248 + collection: String, 249 + state: AppState, 250 + params: serde_json::Value, 251 + ) -> Result<Json<serde_json::Value>, AppError> { 252 + let get_params: GetRecordParams = 253 + serde_json::from_value(params).map_err(|_| AppError::BadRequest("Invalid parameters".to_string()))?; 254 + 255 + // First verify the collection belongs to this slice 256 + let slice_collections = state 257 + .database 258 + .get_slice_collections_list(&get_params.slice) 259 + .await 260 + .map_err(|_| AppError::Internal("Database error".to_string()))?; 261 + 262 + // Special handling: network.slices.lexicon is always allowed as it defines the schema 263 + if collection != "network.slices.lexicon" && !slice_collections.contains(&collection) { 264 + return Err(AppError::NotFound("Collection not found".to_string())); 265 + } 266 + 267 + // Use direct database query by URI for efficiency 268 + match state.database.get_record(&get_params.uri).await { 269 + Ok(Some(record)) => { 270 + let json_value = 271 + serde_json::to_value(record).map_err(|_| AppError::Internal("Serialization error".to_string()))?; 272 + Ok(Json(json_value)) 273 + } 274 + Ok(None) => Err(AppError::NotFound("Record not found".to_string())), 275 + Err(_e) => Err(AppError::Internal("Database error".to_string())), 276 + } 277 + } 278 + 279 + // Handler for get records via POST with JSON body 280 + async fn dynamic_get_records_post_handler( 281 + collection: String, 282 + state: AppState, 283 + body: serde_json::Value, 284 + ) -> Result<Json<serde_json::Value>, AppError> { 285 + // Parse the JSON body into SliceRecordsParams 286 + let mut records_params: SliceRecordsParams = serde_json::from_value(body).map_err(|_| AppError::BadRequest("Invalid request body".to_string()))?; 287 + 288 + // First verify the collection belongs to this slice 289 + let slice_collections = state 290 + .database 291 + .get_slice_collections_list(&records_params.slice) 292 + .await 293 + .map_err(|_| AppError::Internal("Database error".to_string()))?; 294 + 295 + // Special handling: network.slices.lexicon is always allowed as it defines the schema 296 + if collection != "network.slices.lexicon" && !slice_collections.contains(&collection) { 297 + return Err(AppError::NotFound("Collection not found".to_string())); 298 + } 299 + 300 + // Add collection filter to where conditions 301 + let mut where_clause = records_params 302 + .where_clause 303 + .unwrap_or(crate::models::WhereClause { 304 + conditions: HashMap::new(), 305 + or_conditions: None, 306 + }); 307 + where_clause.conditions.insert( 308 + "collection".to_string(), 309 + WhereCondition { 310 + eq: Some(serde_json::Value::String(collection.clone())), 311 + in_values: None, 312 + contains: None, 313 + }, 314 + ); 315 + records_params.where_clause = Some(where_clause); 316 + 317 + // Use the unified database method 318 + match state 319 + .database 320 + .get_slice_collections_records( 321 + &records_params.slice, 322 + records_params.limit, 323 + records_params.cursor.as_deref(), 324 + records_params.sort_by.as_ref(), 325 + records_params.where_clause.as_ref(), 326 + ) 327 + .await 328 + { 329 + Ok((records, cursor)) => { 330 + // No need to filter - collection filter is in the SQL query now 331 + 332 + // Transform Record to IndexedRecord for the response 333 + let indexed_records: Vec<IndexedRecord> = records 334 + .into_iter() 335 + .map(|record| IndexedRecord { 336 + uri: record.uri, 337 + cid: record.cid, 338 + did: record.did, 339 + collection: record.collection, 340 + value: record.json, 341 + indexed_at: record.indexed_at.to_rfc3339(), 342 + }) 343 + .collect(); 344 + 345 + let output = SliceRecordsOutput { 346 + records: indexed_records, 347 + cursor, 348 + }; 349 + 350 + Ok(Json(serde_json::to_value(output).map_err(|e| AppError::Internal(format!("Serialization error: {}", e)))?)) 351 + } 352 + Err(e) => Err(AppError::Internal(format!("Database error: {}", e))), 353 + } 354 + } 355 + 356 + // Dynamic count records handler for GET requests 357 + async fn dynamic_count_records_handler( 358 + collection: String, 359 + state: AppState, 360 + params: serde_json::Value, 361 + ) -> Result<Json<serde_json::Value>, AppError> { 362 + // Convert query parameters to SliceRecordsParams 363 + let mut records_params: SliceRecordsParams = 364 + serde_json::from_value(params).map_err(|_| AppError::BadRequest("Invalid parameters".to_string()))?; 365 + 366 + // First verify the collection belongs to this slice 367 + let slice_collections = state 368 + .database 369 + .get_slice_collections_list(&records_params.slice) 370 + .await 371 + .map_err(|_| AppError::Internal("Database error".to_string()))?; 372 + 373 + // Special handling: network.slices.lexicon is always allowed as it defines the schema 374 + if collection != "network.slices.lexicon" && !slice_collections.contains(&collection) { 375 + return Err(AppError::NotFound("Collection not found".to_string())); 376 + } 377 + 378 + // Add collection filter to where conditions 379 + let mut where_clause = records_params 380 + .where_clause 381 + .unwrap_or(crate::models::WhereClause { 382 + conditions: HashMap::new(), 383 + or_conditions: None, 384 + }); 385 + where_clause.conditions.insert( 386 + "collection".to_string(), 387 + WhereCondition { 388 + eq: Some(collection.clone().into()), 389 + contains: None, 390 + in_values: None, 391 + }, 392 + ); 393 + records_params.where_clause = Some(where_clause); 394 + 395 + match state 396 + .database 397 + .count_slice_collections_records( 398 + &records_params.slice, 399 + records_params.where_clause.as_ref(), 400 + ) 401 + .await 402 + { 403 + Ok(count) => Ok(Json(serde_json::json!({ 404 + "success": true, 405 + "count": count, 406 + "message": null 407 + }))), 408 + Err(_) => Ok(Json(serde_json::json!({ 409 + "success": false, 410 + "count": 0, 411 + "message": "Failed to count records" 412 + }))), 413 + } 414 + } 415 + 416 + // Dynamic count records handler for POST requests 417 + async fn dynamic_count_records_post_handler( 418 + collection: String, 419 + state: AppState, 420 + body: serde_json::Value, 421 + ) -> Result<Json<serde_json::Value>, AppError> { 422 + // Parse the JSON body into SliceRecordsParams 423 + let mut records_params: SliceRecordsParams = serde_json::from_value(body).map_err(|_| AppError::BadRequest("Invalid request body".to_string()))?; 424 + 425 + // First verify the collection belongs to this slice 426 + let slice_collections = state 427 + .database 428 + .get_slice_collections_list(&records_params.slice) 429 + .await 430 + .map_err(|_| AppError::Internal("Database error".to_string()))?; 431 + 432 + // Special handling: network.slices.lexicon is always allowed as it defines the schema 433 + if collection != "network.slices.lexicon" && !slice_collections.contains(&collection) { 434 + return Err(AppError::NotFound("Collection not found".to_string())); 435 + } 436 + 437 + // Add collection filter to where conditions 438 + let mut where_clause = records_params 439 + .where_clause 440 + .unwrap_or(crate::models::WhereClause { 441 + conditions: HashMap::new(), 442 + or_conditions: None, 443 + }); 444 + where_clause.conditions.insert( 445 + "collection".to_string(), 446 + WhereCondition { 447 + eq: Some(collection.clone().into()), 448 + in_values: None, 449 + contains: None, 450 + }, 451 + ); 452 + records_params.where_clause = Some(where_clause); 453 + 454 + match state 455 + .database 456 + .count_slice_collections_records( 457 + &records_params.slice, 458 + records_params.where_clause.as_ref(), 459 + ) 460 + .await 461 + { 462 + Ok(count) => Ok(Json(serde_json::json!({ 463 + "success": true, 464 + "count": count, 465 + "message": null 466 + }))), 467 + Err(_) => Ok(Json(serde_json::json!({ 468 + "success": false, 469 + "count": 0, 470 + "message": "Failed to count records" 471 + }))), 472 + } 473 + } 474 + 475 + // Dynamic collection create (e.g., social.grain.gallery.createRecord) 476 + async fn dynamic_collection_create_impl( 477 + state: AppState, 478 + headers: HeaderMap, 479 + body: serde_json::Value, 480 + collection: String, 481 + ) -> Result<Json<serde_json::Value>, AppError> { 482 + // Extract and verify OAuth token 483 + let token = extract_bearer_token(&headers).map_err(|_| AppError::AuthRequired("Missing bearer token".to_string()))?; 484 + let user_info = verify_oauth_token_cached( 485 + &token, 486 + &state.config.auth_base_url, 487 + Some(state.auth_cache.clone()), 488 + ) 489 + .await 490 + .map_err(|_| AppError::AuthRequired("Invalid token".to_string()))?; 491 + 492 + // Get AT Protocol DPoP auth and PDS URL (with caching) 493 + let (dpop_auth, pds_url) = get_atproto_auth_for_user_cached( 494 + &token, 495 + &state.config.auth_base_url, 496 + Some(state.auth_cache.clone()), 497 + ) 498 + .await 499 + .map_err(|_| AppError::AuthRequired("Invalid token".to_string()))?; 500 + 501 + // Extract the repo DID from user info 502 + let repo = user_info.did.unwrap_or(user_info.sub); 503 + 504 + // Create HTTP client 505 + let http_client = reqwest::Client::new(); 506 + 507 + // Extract slice URI, rkey, and record value from structured body 508 + let slice_uri = body 509 + .get("slice") 510 + .and_then(|v| v.as_str()) 511 + .ok_or_else(|| AppError::BadRequest("Missing parameter".to_string()))? 512 + .to_string(); 513 + 514 + let record_key = body 515 + .get("rkey") 516 + .and_then(|v| v.as_str()) 517 + .filter(|s| !s.is_empty()) // Filter out empty strings 518 + .map(|s| s.to_string()); 519 + 520 + let record_data = body 521 + .get("record") 522 + .ok_or_else(|| AppError::BadRequest("Missing parameter".to_string()))? 523 + .clone(); 524 + 525 + // Validate the record against its lexicon 526 + 527 + // For network.slices.lexicon collection, validate against the system slice 528 + let validation_slice_uri = if collection == "network.slices.lexicon" { 529 + &state.config.system_slice_uri 530 + } else { 531 + &slice_uri 532 + }; 533 + 534 + // Get lexicons for validation 535 + match state 536 + .database 537 + .get_lexicons_by_slice(validation_slice_uri) 538 + .await 539 + { 540 + Ok(lexicons) if !lexicons.is_empty() => { 541 + if let Err(e) = 542 + slices_lexicon::validate_record(lexicons, &collection, record_data.clone()) 543 + { 544 + return Err(AppError::BadRequest(format!("ValidationError: {}", e))); 545 + } 546 + } 547 + _ => { 548 + // If no lexicons found, continue without validation (backwards compatibility) 549 + } 550 + } 551 + 552 + // Create record using AT Protocol functions with DPoP 553 + 554 + let create_request = CreateRecordRequest { 555 + repo: repo.clone(), 556 + collection: collection.clone(), 557 + record_key, 558 + record: record_data.clone(), 559 + swap_commit: None, 560 + validate: false, 561 + }; 562 + 563 + let result = create_record( 564 + &http_client, 565 + &atproto_client::client::Auth::DPoP(dpop_auth), 566 + &pds_url, 567 + create_request, 568 + ) 569 + .await 570 + .map_err(|_e| AppError::Internal("AT Protocol request failed".to_string()))?; 571 + 572 + // Extract URI and CID from the response enum 573 + let (uri, cid) = match result { 574 + CreateRecordResponse::StrongRef { uri, cid, .. } => (uri, cid), 575 + CreateRecordResponse::Error(_e) => { 576 + return Err(AppError::Internal("AT Protocol response error".to_string())); 577 + } 578 + }; 579 + 580 + // Also store in local database for indexing 581 + let record = Record { 582 + uri: uri.clone(), 583 + cid: cid.clone(), 584 + did: repo, 585 + collection, 586 + json: record_data, 587 + indexed_at: Utc::now(), 588 + slice_uri: Some(slice_uri), 589 + }; 590 + 591 + // Store in local database (ignore errors as AT Protocol operation succeeded) 592 + let _insert_result = state.database.insert_record(&record).await; 593 + 594 + Ok(Json(serde_json::json!({ 595 + "uri": uri, 596 + "cid": cid, 597 + }))) 598 + } 599 + 600 + // Dynamic collection update (e.g., social.grain.gallery.updateRecord) 601 + async fn dynamic_collection_update_impl( 602 + state: AppState, 603 + headers: HeaderMap, 604 + body: serde_json::Value, 605 + collection: String, 606 + ) -> Result<Json<serde_json::Value>, AppError> { 607 + // Extract and verify OAuth token 608 + let token = extract_bearer_token(&headers).map_err(|_| AppError::AuthRequired("Missing bearer token".to_string()))?; 609 + let user_info = verify_oauth_token_cached( 610 + &token, 611 + &state.config.auth_base_url, 612 + Some(state.auth_cache.clone()), 613 + ) 614 + .await 615 + .map_err(|_| AppError::AuthRequired("Invalid token".to_string()))?; 616 + 617 + // Get AT Protocol DPoP auth and PDS URL (with caching) 618 + let (dpop_auth, pds_url) = get_atproto_auth_for_user_cached( 619 + &token, 620 + &state.config.auth_base_url, 621 + Some(state.auth_cache.clone()), 622 + ) 623 + .await 624 + .map_err(|_| AppError::AuthRequired("Invalid token".to_string()))?; 625 + 626 + // Extract slice URI, rkey, and record value from structured body 627 + let slice_uri = body 628 + .get("slice") 629 + .and_then(|v| v.as_str()) 630 + .ok_or_else(|| AppError::BadRequest("Missing parameter".to_string()))? 631 + .to_string(); 632 + 633 + let rkey = body 634 + .get("rkey") 635 + .and_then(|v| v.as_str()) 636 + .ok_or_else(|| AppError::BadRequest("Missing parameter".to_string()))? 637 + .to_string(); 638 + 639 + let record_data = body 640 + .get("record") 641 + .ok_or_else(|| AppError::BadRequest("Missing parameter".to_string()))? 642 + .clone(); 643 + 644 + // Extract repo from user info 645 + let repo = user_info.did.unwrap_or(user_info.sub); 646 + 647 + // Validate the record against its lexicon 648 + 649 + // For network.slices.lexicon collection, validate against the system slice 650 + let validation_slice_uri = if collection == "network.slices.lexicon" { 651 + &state.config.system_slice_uri 652 + } else { 653 + &slice_uri 654 + }; 655 + 656 + // Get lexicons for validation 657 + match state 658 + .database 659 + .get_lexicons_by_slice(validation_slice_uri) 660 + .await 661 + { 662 + Ok(lexicons) if !lexicons.is_empty() => { 663 + if let Err(e) = 664 + slices_lexicon::validate_record(lexicons, &collection, record_data.clone()) 665 + { 666 + return Err(AppError::BadRequest(format!("ValidationError: {}", e))); 667 + } 668 + } 669 + _ => { 670 + // If no lexicons found, continue without validation (backwards compatibility) 671 + } 672 + } 673 + 674 + // Create HTTP client 675 + let http_client = reqwest::Client::new(); 676 + 677 + // Update record using AT Protocol functions with DPoP 678 + let put_request = PutRecordRequest { 679 + repo: repo.clone(), 680 + collection: collection.clone(), 681 + record_key: rkey, 682 + record: record_data.clone(), 683 + swap_record: None, 684 + swap_commit: None, 685 + validate: false, 686 + }; 687 + 688 + let result = put_record( 689 + &http_client, 690 + &atproto_client::client::Auth::DPoP(dpop_auth), 691 + &pds_url, 692 + put_request, 693 + ) 694 + .await 695 + .map_err(|_| AppError::Internal("AT Protocol request failed".to_string()))?; 696 + 697 + // Extract URI and CID from the response enum 698 + let (uri, cid) = match result { 699 + PutRecordResponse::StrongRef { uri, cid, .. } => (uri, cid), 700 + PutRecordResponse::Error(_) => { 701 + return Err(AppError::Internal("AT Protocol response error".to_string())); 702 + } 703 + }; 704 + 705 + // Also update in local database for indexing 706 + let record = Record { 707 + uri: uri.clone(), 708 + cid: cid.clone(), 709 + did: repo, 710 + collection, 711 + json: record_data, 712 + indexed_at: Utc::now(), 713 + slice_uri: Some(slice_uri), 714 + }; 715 + 716 + // Update in local database (ignore errors as AT Protocol operation succeeded) 717 + let _ = state.database.update_record(&record).await; 718 + 719 + Ok(Json(serde_json::json!({ 720 + "uri": uri, 721 + "cid": cid, 722 + }))) 723 + } 724 + 725 + // Dynamic collection delete (e.g., social.grain.gallery.delete) 726 + async fn dynamic_collection_delete_impl( 727 + state: AppState, 728 + headers: HeaderMap, 729 + body: serde_json::Value, 730 + collection: String, 731 + ) -> Result<Json<serde_json::Value>, AppError> { 732 + // Extract and verify OAuth token 733 + let token = extract_bearer_token(&headers).map_err(|_| AppError::AuthRequired("Missing bearer token".to_string()))?; 734 + let user_info = verify_oauth_token_cached( 735 + &token, 736 + &state.config.auth_base_url, 737 + Some(state.auth_cache.clone()), 738 + ) 739 + .await 740 + .map_err(|_| AppError::AuthRequired("Invalid token".to_string()))?; 741 + 742 + // Get AT Protocol DPoP auth and PDS URL (with caching) 743 + let (dpop_auth, pds_url) = get_atproto_auth_for_user_cached( 744 + &token, 745 + &state.config.auth_base_url, 746 + Some(state.auth_cache.clone()), 747 + ) 748 + .await 749 + .map_err(|_| AppError::AuthRequired("Invalid token".to_string()))?; 750 + 751 + // Extract repo and rkey from body 752 + let repo = user_info.did.unwrap_or(user_info.sub); 753 + let rkey = body["rkey"] 754 + .as_str() 755 + .ok_or_else(|| AppError::BadRequest("Missing parameter".to_string()))? 756 + .to_string(); 757 + 758 + // Create HTTP client 759 + let http_client = reqwest::Client::new(); 760 + 761 + // Delete record using AT Protocol functions with DPoP 762 + let delete_request = DeleteRecordRequest { 763 + repo: repo.clone(), 764 + collection: collection.clone(), 765 + record_key: rkey.clone(), 766 + swap_record: None, 767 + swap_commit: None, 768 + }; 769 + 770 + delete_record( 771 + &http_client, 772 + &atproto_client::client::Auth::DPoP(dpop_auth), 773 + &pds_url, 774 + delete_request, 775 + ) 776 + .await 777 + .map_err(|_| AppError::Internal("AT Protocol request failed".to_string()))?; 778 + 779 + // Also delete from local database (from all slices) 780 + let uri = format!("at://{}/{}/{}", repo, collection, rkey); 781 + 782 + // Handle cascade deletion before deleting the record 783 + if let Err(e) = state.database.handle_cascade_deletion(&uri, &collection).await { 784 + tracing::warn!("Cascade deletion failed for {}: {}", uri, e); 785 + } 786 + 787 + let _ = state.database.delete_record_by_uri(&uri, None).await; 788 + 789 + Ok(Json(serde_json::json!({}))) 790 + }
+92
api/src/database/query_builder.rs
··· 147 147 }; 148 148 *param_count += 1; 149 149 clause 150 + } else if let Some(_gt_value) = &condition.gt { 151 + let clause = match field { 152 + "indexed_at" => { 153 + format!("{} > ${}::timestamptz", field, param_count) 154 + } 155 + "did" | "collection" | "uri" | "cid" => { 156 + format!("{} > ${}", field, param_count) 157 + } 158 + _ => { 159 + let json_path = build_json_path(field); 160 + format!("{} > ${}", json_path, param_count) 161 + } 162 + }; 163 + *param_count += 1; 164 + clause 165 + } else if let Some(_gte_value) = &condition.gte { 166 + let clause = match field { 167 + "indexed_at" => { 168 + format!("{} >= ${}::timestamptz", field, param_count) 169 + } 170 + "did" | "collection" | "uri" | "cid" => { 171 + format!("{} >= ${}", field, param_count) 172 + } 173 + _ => { 174 + let json_path = build_json_path(field); 175 + format!("{} >= ${}", json_path, param_count) 176 + } 177 + }; 178 + *param_count += 1; 179 + clause 180 + } else if let Some(_lt_value) = &condition.lt { 181 + let clause = match field { 182 + "indexed_at" => { 183 + format!("{} < ${}::timestamptz", field, param_count) 184 + } 185 + "did" | "collection" | "uri" | "cid" => { 186 + format!("{} < ${}", field, param_count) 187 + } 188 + _ => { 189 + let json_path = build_json_path(field); 190 + format!("{} < ${}", json_path, param_count) 191 + } 192 + }; 193 + *param_count += 1; 194 + clause 195 + } else if let Some(_lte_value) = &condition.lte { 196 + let clause = match field { 197 + "indexed_at" => { 198 + format!("{} <= ${}::timestamptz", field, param_count) 199 + } 200 + "did" | "collection" | "uri" | "cid" => { 201 + format!("{} <= ${}", field, param_count) 202 + } 203 + _ => { 204 + let json_path = build_json_path(field); 205 + format!("{} <= ${}", json_path, param_count) 206 + } 207 + }; 208 + *param_count += 1; 209 + clause 150 210 } else { 151 211 String::new() 152 212 } ··· 236 296 237 297 if let Some(contains_value) = &condition.contains { 238 298 query_builder = query_builder.bind(contains_value); 299 + } 300 + 301 + if let Some(gt_value) = &condition.gt { 302 + if let Some(str_val) = gt_value.as_str() { 303 + query_builder = query_builder.bind(str_val); 304 + } else { 305 + query_builder = query_builder.bind(gt_value); 306 + } 307 + } 308 + 309 + if let Some(gte_value) = &condition.gte { 310 + if let Some(str_val) = gte_value.as_str() { 311 + query_builder = query_builder.bind(str_val); 312 + } else { 313 + query_builder = query_builder.bind(gte_value); 314 + } 315 + } 316 + 317 + if let Some(lt_value) = &condition.lt { 318 + if let Some(str_val) = lt_value.as_str() { 319 + query_builder = query_builder.bind(str_val); 320 + } else { 321 + query_builder = query_builder.bind(lt_value); 322 + } 323 + } 324 + 325 + if let Some(lte_value) = &condition.lte { 326 + if let Some(str_val) = lte_value.as_str() { 327 + query_builder = query_builder.bind(str_val); 328 + } else { 329 + query_builder = query_builder.bind(lte_value); 330 + } 239 331 } 240 332 241 333 query_builder
+143 -12
api/src/database/records.rs
··· 217 217 sort_by: Option<&Vec<SortField>>, 218 218 where_clause: Option<&WhereClause>, 219 219 ) -> Result<(Vec<Record>, Option<String>), DatabaseError> { 220 - let limit = limit.unwrap_or(50).min(100); 220 + // Default to 50 for API requests, but support unlimited queries for DataLoader 221 + let limit = limit.unwrap_or(50); 221 222 222 223 let mut where_clauses = Vec::new(); 223 224 let mut param_count = 1; ··· 433 434 if let Some(contains_value) = &condition.contains { 434 435 query_builder = query_builder.bind(contains_value); 435 436 } 437 + if let Some(gt_value) = &condition.gt { 438 + if let Some(str_val) = gt_value.as_str() { 439 + query_builder = query_builder.bind(str_val); 440 + } else { 441 + query_builder = query_builder.bind(gt_value); 442 + } 443 + } 444 + if let Some(gte_value) = &condition.gte { 445 + if let Some(str_val) = gte_value.as_str() { 446 + query_builder = query_builder.bind(str_val); 447 + } else { 448 + query_builder = query_builder.bind(gte_value); 449 + } 450 + } 451 + if let Some(lt_value) = &condition.lt { 452 + if let Some(str_val) = lt_value.as_str() { 453 + query_builder = query_builder.bind(str_val); 454 + } else { 455 + query_builder = query_builder.bind(lt_value); 456 + } 457 + } 458 + if let Some(lte_value) = &condition.lte { 459 + if let Some(str_val) = lte_value.as_str() { 460 + query_builder = query_builder.bind(str_val); 461 + } else { 462 + query_builder = query_builder.bind(lte_value); 463 + } 464 + } 436 465 } 437 466 438 467 if let Some(or_conditions) = &clause.or_conditions { ··· 454 483 if let Some(contains_value) = &condition.contains { 455 484 query_builder = query_builder.bind(contains_value); 456 485 } 486 + if let Some(gt_value) = &condition.gt { 487 + if let Some(str_val) = gt_value.as_str() { 488 + query_builder = query_builder.bind(str_val); 489 + } else { 490 + query_builder = query_builder.bind(gt_value); 491 + } 492 + } 493 + if let Some(gte_value) = &condition.gte { 494 + if let Some(str_val) = gte_value.as_str() { 495 + query_builder = query_builder.bind(str_val); 496 + } else { 497 + query_builder = query_builder.bind(gte_value); 498 + } 499 + } 500 + if let Some(lt_value) = &condition.lt { 501 + if let Some(str_val) = lt_value.as_str() { 502 + query_builder = query_builder.bind(str_val); 503 + } else { 504 + query_builder = query_builder.bind(lt_value); 505 + } 506 + } 507 + if let Some(lte_value) = &condition.lte { 508 + if let Some(str_val) = lte_value.as_str() { 509 + query_builder = query_builder.bind(str_val); 510 + } else { 511 + query_builder = query_builder.bind(lte_value); 512 + } 513 + } 457 514 } 458 515 } 459 516 } ··· 476 533 pub async fn get_aggregated_records( 477 534 &self, 478 535 slice_uri: &str, 479 - group_by_fields: &[String], 536 + group_by_fields: &[crate::models::GroupByField], 480 537 where_clause: Option<&WhereClause>, 481 538 order_by_count: Option<&str>, 482 539 limit: Option<i32>, ··· 488 545 let limit = limit.unwrap_or(50).min(1000); 489 546 let mut param_count = 1; 490 547 491 - // Build SELECT clause with JSON field extraction 548 + // Build SELECT clause with JSON field extraction and optional date truncation 492 549 let select_fields: Vec<String> = group_by_fields 493 550 .iter() 494 551 .enumerate() 495 - .map(|(i, field)| { 496 - // Check if it's a table column 497 - if matches!(field.as_str(), "did" | "collection" | "uri" | "cid" | "indexed_at") { 498 - format!("\"{}\" as field_{}", field, i) 499 - } else { 500 - // JSON field 501 - format!("json->>'{}' as field_{}", field, i) 552 + .map(|(i, group_by_field)| { 553 + match group_by_field { 554 + crate::models::GroupByField::Simple(field) => { 555 + // Check if it's a table column 556 + if matches!(field.as_str(), "did" | "collection" | "uri" | "cid" | "indexed_at") { 557 + format!("\"{}\" as field_{}", field, i) 558 + } else { 559 + // JSON field 560 + format!("json->>'{}' as field_{}", field, i) 561 + } 562 + } 563 + crate::models::GroupByField::Truncated { field, interval } => { 564 + // Date truncation using PostgreSQL's date_trunc function 565 + let interval_str = interval.to_pg_interval(); 566 + 567 + // Check if it's a table column 568 + if field == "indexed_at" { 569 + format!("date_trunc('{}', \"{}\")::text as field_{}", interval_str, field, i) 570 + } else { 571 + // JSON field - cast to timestamp for date_trunc, then to text 572 + format!("date_trunc('{}', (json->>'{}')::timestamp)::text as field_{}", interval_str, field, i) 573 + } 574 + } 502 575 } 503 576 }) 504 577 .collect(); ··· 536 609 select_clause, where_sql, group_by_clause.join(", "), order_by_sql, limit 537 610 ); 538 611 612 + tracing::debug!("Generated SQL: {}", query); 613 + 539 614 let mut query_builder = sqlx::query(&query); 540 615 query_builder = query_builder.bind(slice_uri); 541 616 ··· 559 634 if let Some(contains_value) = &condition.contains { 560 635 query_builder = query_builder.bind(contains_value); 561 636 } 637 + if let Some(gt_value) = &condition.gt { 638 + if let Some(str_val) = gt_value.as_str() { 639 + query_builder = query_builder.bind(str_val); 640 + } else { 641 + query_builder = query_builder.bind(gt_value.to_string()); 642 + } 643 + } 644 + if let Some(gte_value) = &condition.gte { 645 + if let Some(str_val) = gte_value.as_str() { 646 + query_builder = query_builder.bind(str_val); 647 + } else { 648 + query_builder = query_builder.bind(gte_value.to_string()); 649 + } 650 + } 651 + if let Some(lt_value) = &condition.lt { 652 + if let Some(str_val) = lt_value.as_str() { 653 + query_builder = query_builder.bind(str_val); 654 + } else { 655 + query_builder = query_builder.bind(lt_value.to_string()); 656 + } 657 + } 658 + if let Some(lte_value) = &condition.lte { 659 + if let Some(str_val) = lte_value.as_str() { 660 + query_builder = query_builder.bind(str_val); 661 + } else { 662 + query_builder = query_builder.bind(lte_value.to_string()); 663 + } 664 + } 562 665 } 563 666 564 667 if let Some(or_conditions) = &clause.or_conditions { ··· 580 683 if let Some(contains_value) = &condition.contains { 581 684 query_builder = query_builder.bind(contains_value); 582 685 } 686 + if let Some(gt_value) = &condition.gt { 687 + if let Some(str_val) = gt_value.as_str() { 688 + query_builder = query_builder.bind(str_val); 689 + } else { 690 + query_builder = query_builder.bind(gt_value.to_string()); 691 + } 692 + } 693 + if let Some(gte_value) = &condition.gte { 694 + if let Some(str_val) = gte_value.as_str() { 695 + query_builder = query_builder.bind(str_val); 696 + } else { 697 + query_builder = query_builder.bind(gte_value.to_string()); 698 + } 699 + } 700 + if let Some(lt_value) = &condition.lt { 701 + if let Some(str_val) = lt_value.as_str() { 702 + query_builder = query_builder.bind(str_val); 703 + } else { 704 + query_builder = query_builder.bind(lt_value.to_string()); 705 + } 706 + } 707 + if let Some(lte_value) = &condition.lte { 708 + if let Some(str_val) = lte_value.as_str() { 709 + query_builder = query_builder.bind(str_val); 710 + } else { 711 + query_builder = query_builder.bind(lte_value.to_string()); 712 + } 713 + } 583 714 } 584 715 } 585 716 } ··· 592 723 let mut obj = serde_json::Map::new(); 593 724 594 725 // Extract grouped field values 595 - for (i, field_name) in group_by_fields.iter().enumerate() { 726 + for (i, group_by_field) in group_by_fields.iter().enumerate() { 596 727 let col_name = format!("field_{}", i); 597 728 let value: Option<String> = row.try_get(col_name.as_str()).ok(); 598 729 ··· 609 740 serde_json::Value::Null 610 741 }; 611 742 612 - obj.insert(field_name.clone(), json_value); 743 + obj.insert(group_by_field.field_name().to_string(), json_value); 613 744 } 614 745 615 746 // Extract count
+9 -1
api/src/database/types.rs
··· 9 9 10 10 /// Represents a single condition in a WHERE clause. 11 11 /// 12 - /// Supports three types of operations: 12 + /// Supports multiple types of operations: 13 13 /// - `eq`: Exact match (field = value) 14 14 /// - `in_values`: Array membership (field IN (...)) 15 15 /// - `contains`: Pattern matching (field ILIKE '%value%') 16 + /// - `gt`: Greater than (field > value) 17 + /// - `gte`: Greater than or equal (field >= value) 18 + /// - `lt`: Less than (field < value) 19 + /// - `lte`: Less than or equal (field <= value) 16 20 #[derive(Debug, Clone, Serialize, Deserialize)] 17 21 #[serde(rename_all = "camelCase")] 18 22 pub struct WhereCondition { ··· 20 24 #[serde(rename = "in")] 21 25 pub in_values: Option<Vec<Value>>, 22 26 pub contains: Option<String>, 27 + pub gt: Option<Value>, 28 + pub gte: Option<Value>, 29 + pub lt: Option<Value>, 30 + pub lte: Option<Value>, 23 31 } 24 32 25 33 /// Represents a complete WHERE clause with AND/OR conditions.
+298
api/src/graphql/dataloader.rs
··· 1 + //! DataLoader implementation for batching database queries 2 + //! 3 + //! This module provides a DataLoader that batches multiple requests for records 4 + //! into single database queries, eliminating the N+1 query problem. 5 + 6 + use async_graphql::dataloader::{DataLoader as AsyncGraphQLDataLoader, Loader}; 7 + use std::collections::HashMap; 8 + use std::sync::Arc; 9 + 10 + use crate::database::Database; 11 + use crate::models::{IndexedRecord, WhereClause, WhereCondition}; 12 + 13 + /// Key for batching record queries by collection and DID 14 + #[derive(Debug, Clone, Hash, Eq, PartialEq)] 15 + pub struct CollectionDidKey { 16 + pub slice_uri: String, 17 + pub collection: String, 18 + pub did: String, 19 + } 20 + 21 + /// Loader for batching record queries by collection and DID 22 + pub struct CollectionDidLoader { 23 + db: Database, 24 + } 25 + 26 + impl CollectionDidLoader { 27 + pub fn new(db: Database) -> Self { 28 + Self { db } 29 + } 30 + } 31 + 32 + impl Loader<CollectionDidKey> for CollectionDidLoader { 33 + type Value = Vec<IndexedRecord>; 34 + type Error = Arc<String>; 35 + 36 + async fn load(&self, keys: &[CollectionDidKey]) -> Result<HashMap<CollectionDidKey, Self::Value>, Self::Error> { 37 + // Group keys by slice_uri and collection for optimal batching 38 + let mut grouped: HashMap<(String, String), Vec<String>> = HashMap::new(); 39 + 40 + for key in keys { 41 + grouped 42 + .entry((key.slice_uri.clone(), key.collection.clone())) 43 + .or_insert_with(Vec::new) 44 + .push(key.did.clone()); 45 + } 46 + 47 + let mut results: HashMap<CollectionDidKey, Vec<IndexedRecord>> = HashMap::new(); 48 + 49 + // Execute one query per (slice, collection) combination 50 + for ((slice_uri, collection), dids) in grouped { 51 + let mut where_clause = WhereClause { 52 + conditions: HashMap::new(), 53 + or_conditions: None, 54 + }; 55 + 56 + // Filter by collection 57 + where_clause.conditions.insert( 58 + "collection".to_string(), 59 + WhereCondition { 60 + gt: None, 61 + gte: None, 62 + lt: None, 63 + lte: None, 64 + eq: Some(serde_json::Value::String(collection.clone())), 65 + in_values: None, 66 + contains: None, 67 + }, 68 + ); 69 + 70 + // Filter by DIDs using IN clause for batching 71 + where_clause.conditions.insert( 72 + "did".to_string(), 73 + WhereCondition { 74 + gt: None, 75 + gte: None, 76 + lt: None, 77 + lte: None, 78 + eq: None, 79 + in_values: Some( 80 + dids.iter() 81 + .map(|did| serde_json::Value::String(did.clone())) 82 + .collect() 83 + ), 84 + contains: None, 85 + }, 86 + ); 87 + 88 + // Query database with no limit - load all records for batched filtering 89 + match self.db.get_slice_collections_records( 90 + &slice_uri, 91 + None, // No limit - load all records for this DID 92 + None, // cursor 93 + None, // sort 94 + Some(&where_clause), 95 + ).await { 96 + Ok((records, _cursor)) => { 97 + // Group results by DID 98 + for record in records { 99 + let key = CollectionDidKey { 100 + slice_uri: slice_uri.clone(), 101 + collection: collection.clone(), 102 + did: record.did.clone(), 103 + }; 104 + 105 + // Convert Record to IndexedRecord 106 + let indexed_record = IndexedRecord { 107 + uri: record.uri, 108 + cid: record.cid, 109 + did: record.did, 110 + collection: record.collection, 111 + value: record.json, 112 + indexed_at: record.indexed_at.to_rfc3339(), 113 + }; 114 + 115 + results 116 + .entry(key) 117 + .or_insert_with(Vec::new) 118 + .push(indexed_record); 119 + } 120 + } 121 + Err(e) => { 122 + tracing::error!("DataLoader batch query failed for {}/{}: {}", slice_uri, collection, e); 123 + // Return empty results for failed queries rather than failing the entire batch 124 + } 125 + } 126 + } 127 + 128 + // Ensure all requested keys have an entry (even if empty) 129 + for key in keys { 130 + results.entry(key.clone()).or_insert_with(Vec::new); 131 + } 132 + 133 + Ok(results) 134 + } 135 + } 136 + 137 + /// Key for batching record queries by collection and parent URI (for reverse joins) 138 + #[derive(Debug, Clone, Hash, Eq, PartialEq)] 139 + pub struct CollectionUriKey { 140 + pub slice_uri: String, 141 + pub collection: String, 142 + pub parent_uri: String, 143 + pub reference_field: String, // Field name that contains the reference (e.g., "subject") 144 + } 145 + 146 + /// Loader for batching record queries by collection and parent URI 147 + /// Used for reverse joins where we need to find records that reference a parent URI 148 + pub struct CollectionUriLoader { 149 + db: Database, 150 + } 151 + 152 + impl CollectionUriLoader { 153 + pub fn new(db: Database) -> Self { 154 + Self { db } 155 + } 156 + } 157 + 158 + impl Loader<CollectionUriKey> for CollectionUriLoader { 159 + type Value = Vec<IndexedRecord>; 160 + type Error = Arc<String>; 161 + 162 + async fn load(&self, keys: &[CollectionUriKey]) -> Result<HashMap<CollectionUriKey, Self::Value>, Self::Error> { 163 + // Group keys by (slice_uri, collection, reference_field) for optimal batching 164 + let mut grouped: HashMap<(String, String, String), Vec<String>> = HashMap::new(); 165 + 166 + for key in keys { 167 + grouped 168 + .entry((key.slice_uri.clone(), key.collection.clone(), key.reference_field.clone())) 169 + .or_insert_with(Vec::new) 170 + .push(key.parent_uri.clone()); 171 + } 172 + 173 + let mut results: HashMap<CollectionUriKey, Vec<IndexedRecord>> = HashMap::new(); 174 + 175 + // Execute one query per (slice, collection, reference_field) combination 176 + for ((slice_uri, collection, reference_field), parent_uris) in grouped { 177 + let mut where_clause = WhereClause { 178 + conditions: HashMap::new(), 179 + or_conditions: None, 180 + }; 181 + 182 + // Filter by collection 183 + where_clause.conditions.insert( 184 + "collection".to_string(), 185 + WhereCondition { 186 + gt: None, 187 + gte: None, 188 + lt: None, 189 + lte: None, 190 + eq: Some(serde_json::Value::String(collection.clone())), 191 + in_values: None, 192 + contains: None, 193 + }, 194 + ); 195 + 196 + // Filter by parent URIs using IN clause on the reference field 197 + // This queries: WHERE json->>'reference_field' IN (parent_uri1, parent_uri2, ...) 198 + where_clause.conditions.insert( 199 + reference_field.clone(), 200 + WhereCondition { 201 + gt: None, 202 + gte: None, 203 + lt: None, 204 + lte: None, 205 + eq: None, 206 + in_values: Some( 207 + parent_uris.iter() 208 + .map(|uri| serde_json::Value::String(uri.clone())) 209 + .collect() 210 + ), 211 + contains: None, 212 + }, 213 + ); 214 + 215 + // Query database with no limit - load all records for batched filtering 216 + match self.db.get_slice_collections_records( 217 + &slice_uri, 218 + None, // No limit - load all records matching parent URIs 219 + None, // cursor 220 + None, // sort 221 + Some(&where_clause), 222 + ).await { 223 + Ok((records, _cursor)) => { 224 + // Group results by parent URI (extract from the reference field) 225 + for record in records { 226 + // Try to extract URI - could be plain string or strongRef object 227 + let parent_uri = record.json.get(&reference_field).and_then(|v| { 228 + // First try as plain string 229 + if let Some(uri_str) = v.as_str() { 230 + return Some(uri_str.to_string()); 231 + } 232 + // Then try as strongRef 233 + crate::graphql::dataloaders::extract_uri_from_strong_ref(v) 234 + }); 235 + 236 + if let Some(parent_uri) = parent_uri { 237 + let key = CollectionUriKey { 238 + slice_uri: slice_uri.clone(), 239 + collection: collection.clone(), 240 + parent_uri: parent_uri.clone(), 241 + reference_field: reference_field.clone(), 242 + }; 243 + 244 + // Convert Record to IndexedRecord 245 + let indexed_record = IndexedRecord { 246 + uri: record.uri, 247 + cid: record.cid, 248 + did: record.did, 249 + collection: record.collection, 250 + value: record.json, 251 + indexed_at: record.indexed_at.to_rfc3339(), 252 + }; 253 + 254 + results 255 + .entry(key) 256 + .or_insert_with(Vec::new) 257 + .push(indexed_record); 258 + } 259 + } 260 + } 261 + Err(e) => { 262 + tracing::error!("CollectionUriLoader batch query failed for {}/{}: {}", slice_uri, collection, e); 263 + // Return empty results for failed queries rather than failing the entire batch 264 + } 265 + } 266 + } 267 + 268 + // Ensure all requested keys have an entry (even if empty) 269 + for key in keys { 270 + results.entry(key.clone()).or_insert_with(Vec::new); 271 + } 272 + 273 + Ok(results) 274 + } 275 + } 276 + 277 + /// Context data that includes the DataLoader 278 + #[derive(Clone)] 279 + pub struct GraphQLContext { 280 + #[allow(dead_code)] 281 + pub collection_did_loader: Arc<AsyncGraphQLDataLoader<CollectionDidLoader>>, 282 + pub collection_uri_loader: Arc<AsyncGraphQLDataLoader<CollectionUriLoader>>, 283 + } 284 + 285 + impl GraphQLContext { 286 + pub fn new(db: Database) -> Self { 287 + Self { 288 + collection_did_loader: Arc::new(AsyncGraphQLDataLoader::new( 289 + CollectionDidLoader::new(db.clone()), 290 + tokio::spawn 291 + )), 292 + collection_uri_loader: Arc::new(AsyncGraphQLDataLoader::new( 293 + CollectionUriLoader::new(db), 294 + tokio::spawn 295 + )), 296 + } 297 + } 298 + }
+24 -5
api/src/graphql/handler.rs
··· 15 15 16 16 use crate::errors::AppError; 17 17 use crate::AppState; 18 + use crate::graphql::GraphQLContext; 18 19 19 20 /// Global schema cache (one schema per slice) 20 21 /// This prevents rebuilding the schema on every request ··· 65 66 } 66 67 }; 67 68 68 - Ok(schema.execute(req.into_inner()).await.into()) 69 + // Create GraphQL context with DataLoader 70 + let gql_context = GraphQLContext::new(state.database.clone()); 71 + 72 + // Execute query with context 73 + Ok(schema 74 + .execute(req.into_inner().data(gql_context)) 75 + .await 76 + .into()) 69 77 } 70 78 71 79 /// GraphiQL UI handler ··· 198 206 } 199 207 }; 200 208 209 + // Create GraphQL context with DataLoader 210 + let gql_context = GraphQLContext::new(state.database.clone()); 211 + 201 212 // Upgrade to WebSocket and handle GraphQL subscriptions manually 202 213 Ok(ws 203 214 .protocols(["graphql-transport-ws", "graphql-ws"]) 204 - .on_upgrade(move |socket| handle_graphql_ws(socket, schema))) 215 + .on_upgrade(move |socket| handle_graphql_ws(socket, schema, gql_context))) 205 216 } 206 217 207 218 /// Handle GraphQL WebSocket connection 208 - async fn handle_graphql_ws(socket: WebSocket, schema: Schema) { 219 + async fn handle_graphql_ws(socket: WebSocket, schema: Schema, gql_context: GraphQLContext) { 209 220 let (ws_sender, ws_receiver) = socket.split(); 210 221 211 222 // Convert axum WebSocket messages to strings for async-graphql ··· 216 227 }) 217 228 }); 218 229 219 - // Create GraphQL WebSocket handler 220 - let mut stream = GraphQLWebSocket::new(schema, input, WebSocketProtocols::GraphQLWS); 230 + // Create GraphQL WebSocket handler with context 231 + let mut stream = GraphQLWebSocket::new(schema.clone(), input, WebSocketProtocols::GraphQLWS) 232 + .on_connection_init(move |_| { 233 + let gql_ctx = gql_context.clone(); 234 + async move { 235 + let mut data = async_graphql::Data::default(); 236 + data.insert(gql_ctx); 237 + Ok(data) 238 + } 239 + }); 221 240 222 241 // Send GraphQL messages back through WebSocket 223 242 let mut ws_sender = ws_sender;
+2
api/src/graphql/mod.rs
··· 5 5 6 6 mod schema_builder; 7 7 mod dataloaders; 8 + mod dataloader; 8 9 mod types; 9 10 pub mod handler; 10 11 pub mod pubsub; ··· 12 13 pub use schema_builder::build_graphql_schema; 13 14 pub use handler::{graphql_handler, graphql_playground, graphql_subscription_handler}; 14 15 pub use pubsub::{RecordUpdateEvent, RecordOperation, PUBSUB}; 16 + pub use dataloader::GraphQLContext;
+658 -143
api/src/graphql/schema_builder.rs
··· 15 15 use crate::database::Database; 16 16 use crate::graphql::types::{extract_collection_fields, extract_record_key, GraphQLField, GraphQLType}; 17 17 use crate::graphql::PUBSUB; 18 + use crate::graphql::dataloader::GraphQLContext; 18 19 19 20 /// Metadata about a collection for cross-referencing 20 21 #[derive(Clone)] ··· 22 23 nsid: String, 23 24 key_type: String, // "tid", "literal:self", or "any" 24 25 type_name: String, // GraphQL type name for this collection 26 + at_uri_fields: Vec<String>, // Fields with format "at-uri" for reverse joins 25 27 } 26 28 27 29 /// Builds a dynamic GraphQL schema from lexicons for a given slice ··· 52 54 // Build Query root type and collect all object types 53 55 let mut query = Object::new("Query"); 54 56 let mut objects_to_register = Vec::new(); 57 + let mut where_inputs_to_register = Vec::new(); 58 + let mut group_by_enums_to_register = Vec::new(); 55 59 56 60 // First pass: collect metadata about all collections for cross-referencing 57 61 let mut all_collections: Vec<CollectionMeta> = Vec::new(); ··· 68 72 let fields = extract_collection_fields(defs); 69 73 if !fields.is_empty() { 70 74 if let Some(key_type) = extract_record_key(defs) { 75 + // Extract at-uri field names for reverse joins 76 + let at_uri_fields: Vec<String> = fields.iter() 77 + .filter(|f| f.format.as_deref() == Some("at-uri")) 78 + .map(|f| f.name.clone()) 79 + .collect(); 80 + 81 + if !at_uri_fields.is_empty() { 82 + tracing::debug!( 83 + "Collection {} has at-uri fields: {:?}", 84 + nsid, 85 + at_uri_fields 86 + ); 87 + } 88 + 71 89 all_collections.push(CollectionMeta { 72 90 nsid: nsid.to_string(), 73 91 key_type, 74 92 type_name: nsid_to_type_name(nsid), 93 + at_uri_fields, 75 94 }); 76 95 } 77 96 } ··· 102 121 let edge_type = create_edge_type(&type_name); 103 122 let connection_type = create_connection_type(&type_name); 104 123 124 + // Create WhereInput type for this collection 125 + let mut where_input = InputObject::new(format!("{}WhereInput", type_name)); 126 + 127 + // Collect lexicon field names to avoid duplicates 128 + let lexicon_field_names: std::collections::HashSet<&str> = 129 + fields.iter().map(|f| f.name.as_str()).collect(); 130 + 131 + // Add system fields available on all records (skip if already in lexicon) 132 + let system_fields = [ 133 + ("indexedAt", "DateTimeFilter"), 134 + ("uri", "StringFilter"), 135 + ("cid", "StringFilter"), 136 + ("did", "StringFilter"), 137 + ("collection", "StringFilter"), 138 + ("actorHandle", "StringFilter"), 139 + ]; 140 + 141 + for (field_name, filter_type) in system_fields { 142 + if !lexicon_field_names.contains(field_name) { 143 + where_input = where_input.field(InputValue::new(field_name, TypeRef::named(filter_type))); 144 + } 145 + } 146 + 147 + // Add fields from the lexicon 148 + for field in &fields { 149 + let filter_type = match field.field_type { 150 + GraphQLType::Int => "IntFilter", 151 + _ => "StringFilter", // Default to StringFilter for strings and other types 152 + }; 153 + where_input = where_input.field(InputValue::new(&field.name, TypeRef::named(filter_type))); 154 + } 155 + 156 + // Create GroupByField enum for this collection 157 + let mut group_by_enum = Enum::new(format!("{}GroupByField", type_name)); 158 + group_by_enum = group_by_enum.item(EnumItem::new("indexedAt")); 159 + 160 + for field in &fields { 161 + group_by_enum = group_by_enum.item(EnumItem::new(&field.name)); 162 + } 163 + 164 + // Create collection-specific GroupByFieldInput 165 + let group_by_input = InputObject::new(format!("{}GroupByFieldInput", type_name)) 166 + .field(InputValue::new("field", TypeRef::named_nn(format!("{}GroupByField", type_name)))) 167 + .field(InputValue::new("interval", TypeRef::named("DateInterval"))); 168 + 169 + // Create collection-specific SortFieldInput 170 + let sort_field_input = InputObject::new(format!("{}SortFieldInput", type_name)) 171 + .field(InputValue::new("field", TypeRef::named_nn(format!("{}GroupByField", type_name)))) 172 + .field(InputValue::new("direction", TypeRef::named("SortDirection"))); 173 + 105 174 // Collect the types to register with schema later 106 175 objects_to_register.push(record_type); 107 176 objects_to_register.push(edge_type); 108 177 objects_to_register.push(connection_type); 178 + where_inputs_to_register.push(where_input); 179 + where_inputs_to_register.push(group_by_input); 180 + where_inputs_to_register.push(sort_field_input); 181 + group_by_enums_to_register.push(group_by_enum); 109 182 110 183 // Add query field for this collection 111 184 let collection_query_name = nsid_to_query_name(nsid); ··· 143 216 for item in list.iter() { 144 217 if let Ok(obj) = item.object() { 145 218 let field = obj.get("field") 146 - .and_then(|v| v.string().ok()) 147 - .unwrap_or("indexedAt") 148 - .to_string(); 219 + .and_then(|v| v.enum_name().ok().map(|s| s.to_string())) 220 + .unwrap_or_else(|| "indexedAt".to_string()); 149 221 let direction = obj.get("direction") 150 - .and_then(|v| v.string().ok()) 151 - .unwrap_or("desc") 152 - .to_string(); 222 + .and_then(|v| v.enum_name().ok().map(|s| s.to_string())) 223 + .unwrap_or_else(|| "desc".to_string()); 153 224 sort_fields.push(crate::models::SortField { field, direction }); 154 225 } 155 226 } ··· 171 242 where_clause.conditions.insert( 172 243 "collection".to_string(), 173 244 crate::models::WhereCondition { 245 + gt: None, 246 + gte: None, 247 + lt: None, 248 + lte: None, 174 249 eq: Some(serde_json::Value::String(collection.clone())), 175 250 in_values: None, 176 251 contains: None, ··· 184 259 for (field_name, condition_val) in where_obj.iter() { 185 260 if let Ok(condition_obj) = condition_val.object() { 186 261 let mut where_condition = crate::models::WhereCondition { 262 + gt: None, 263 + gte: None, 264 + lt: None, 265 + lte: None, 187 266 eq: None, 188 267 in_values: None, 189 268 contains: None, ··· 220 299 } 221 300 } 222 301 223 - where_clause.conditions.insert(field_name.to_string(), where_condition); 302 + // Parse gt condition 303 + if let Some(gt_val) = condition_obj.get("gt") { 304 + if let Ok(gt_str) = gt_val.string() { 305 + where_condition.gt = Some(serde_json::Value::String(gt_str.to_string())); 306 + } else if let Ok(gt_i64) = gt_val.i64() { 307 + where_condition.gt = Some(serde_json::Value::Number(gt_i64.into())); 308 + } 309 + } 310 + 311 + // Parse gte condition 312 + if let Some(gte_val) = condition_obj.get("gte") { 313 + if let Ok(gte_str) = gte_val.string() { 314 + where_condition.gte = Some(serde_json::Value::String(gte_str.to_string())); 315 + } else if let Ok(gte_i64) = gte_val.i64() { 316 + where_condition.gte = Some(serde_json::Value::Number(gte_i64.into())); 317 + } 318 + } 319 + 320 + // Parse lt condition 321 + if let Some(lt_val) = condition_obj.get("lt") { 322 + if let Ok(lt_str) = lt_val.string() { 323 + where_condition.lt = Some(serde_json::Value::String(lt_str.to_string())); 324 + } else if let Ok(lt_i64) = lt_val.i64() { 325 + where_condition.lt = Some(serde_json::Value::Number(lt_i64.into())); 326 + } 327 + } 328 + 329 + // Parse lte condition 330 + if let Some(lte_val) = condition_obj.get("lte") { 331 + if let Ok(lte_str) = lte_val.string() { 332 + where_condition.lte = Some(serde_json::Value::String(lte_str.to_string())); 333 + } else if let Ok(lte_i64) = lte_val.i64() { 334 + where_condition.lte = Some(serde_json::Value::Number(lte_i64.into())); 335 + } 336 + } 337 + 338 + // Convert indexedAt to indexed_at for database column 339 + let db_field_name = if field_name == "indexedAt" { 340 + "indexed_at".to_string() 341 + } else { 342 + field_name.to_string() 343 + }; 344 + 345 + where_clause.conditions.insert(db_field_name, where_condition); 224 346 } 225 347 } 226 348 } ··· 251 373 // Replace actorHandle condition with did condition 252 374 let did_condition = if dids.len() == 1 { 253 375 crate::models::WhereCondition { 376 + gt: None, 377 + gte: None, 378 + lt: None, 379 + lte: None, 254 380 eq: Some(serde_json::Value::String(dids[0].clone())), 255 381 in_values: None, 256 382 contains: None, 257 383 } 258 384 } else { 259 385 crate::models::WhereCondition { 386 + gt: None, 387 + gte: None, 388 + lt: None, 389 + lte: None, 260 390 eq: None, 261 391 in_values: Some(dids.into_iter().map(|d| serde_json::Value::String(d)).collect()), 262 392 contains: None, ··· 344 474 )) 345 475 .argument(async_graphql::dynamic::InputValue::new( 346 476 "sortBy", 347 - TypeRef::named_list("SortField"), 477 + TypeRef::named_list(format!("{}SortFieldInput", type_name)), 348 478 )) 349 479 .argument(async_graphql::dynamic::InputValue::new( 350 480 "where", 351 - TypeRef::named("JSON"), 481 + TypeRef::named(format!("{}WhereInput", type_name)), 352 482 )) 353 483 .description(format!("Query {} records", nsid)), 354 484 ); ··· 376 506 377 507 FieldFuture::new(async move { 378 508 // Parse groupBy argument 379 - let group_by_fields: Vec<String> = match ctx.args.get("groupBy") { 509 + let group_by_fields: Vec<crate::models::GroupByField> = match ctx.args.get("groupBy") { 380 510 Some(val) => { 381 511 if let Ok(list) = val.list() { 382 - list.iter() 383 - .filter_map(|v| v.string().ok().map(|s| s.to_string())) 384 - .collect() 512 + let mut fields = Vec::new(); 513 + for item in list.iter() { 514 + if let Ok(obj) = item.object() { 515 + // Get field name from enum 516 + let field_name = obj.get("field") 517 + .and_then(|v| v.enum_name().ok().map(|s| s.to_string())) 518 + .ok_or_else(|| Error::new("Missing field name in groupBy"))?; 519 + 520 + // Get optional interval 521 + if let Some(interval_val) = obj.get("interval") { 522 + if let Ok(interval_str) = interval_val.enum_name() { 523 + // Parse interval string to DateInterval 524 + let interval = match interval_str { 525 + "second" => crate::models::DateInterval::Second, 526 + "minute" => crate::models::DateInterval::Minute, 527 + "hour" => crate::models::DateInterval::Hour, 528 + "day" => crate::models::DateInterval::Day, 529 + "week" => crate::models::DateInterval::Week, 530 + "month" => crate::models::DateInterval::Month, 531 + "quarter" => crate::models::DateInterval::Quarter, 532 + "year" => crate::models::DateInterval::Year, 533 + _ => return Err(Error::new(format!("Invalid interval: {}", interval_str))), 534 + }; 535 + fields.push(crate::models::GroupByField::Truncated { 536 + field: field_name, 537 + interval, 538 + }); 539 + } else { 540 + return Err(Error::new("Invalid interval value")); 541 + } 542 + } else { 543 + // No interval, simple field 544 + fields.push(crate::models::GroupByField::Simple(field_name)); 545 + } 546 + } else { 547 + return Err(Error::new("Invalid groupBy item")); 548 + } 549 + } 550 + fields 385 551 } else { 386 552 Vec::new() 387 553 } ··· 423 589 where_clause.conditions.insert( 424 590 "collection".to_string(), 425 591 crate::models::WhereCondition { 592 + gt: None, 593 + gte: None, 594 + lt: None, 595 + lte: None, 426 596 eq: Some(serde_json::Value::String(collection.clone())), 427 597 in_values: None, 428 598 contains: None, ··· 435 605 for (field_name, condition_val) in where_obj.iter() { 436 606 if let Ok(condition_obj) = condition_val.object() { 437 607 let mut where_condition = crate::models::WhereCondition { 608 + gt: None, 609 + gte: None, 610 + lt: None, 611 + lte: None, 438 612 eq: None, 439 613 in_values: None, 440 614 contains: None, ··· 471 645 } 472 646 } 473 647 474 - where_clause.conditions.insert(field_name.to_string(), where_condition); 648 + // Parse gt condition 649 + if let Some(gt_val) = condition_obj.get("gt") { 650 + if let Ok(gt_str) = gt_val.string() { 651 + where_condition.gt = Some(serde_json::Value::String(gt_str.to_string())); 652 + } else if let Ok(gt_i64) = gt_val.i64() { 653 + where_condition.gt = Some(serde_json::Value::Number(gt_i64.into())); 654 + } 655 + } 656 + 657 + // Parse gte condition 658 + if let Some(gte_val) = condition_obj.get("gte") { 659 + if let Ok(gte_str) = gte_val.string() { 660 + where_condition.gte = Some(serde_json::Value::String(gte_str.to_string())); 661 + } else if let Ok(gte_i64) = gte_val.i64() { 662 + where_condition.gte = Some(serde_json::Value::Number(gte_i64.into())); 663 + } 664 + } 665 + 666 + // Parse lt condition 667 + if let Some(lt_val) = condition_obj.get("lt") { 668 + if let Ok(lt_str) = lt_val.string() { 669 + where_condition.lt = Some(serde_json::Value::String(lt_str.to_string())); 670 + } else if let Ok(lt_i64) = lt_val.i64() { 671 + where_condition.lt = Some(serde_json::Value::Number(lt_i64.into())); 672 + } 673 + } 674 + 675 + // Parse lte condition 676 + if let Some(lte_val) = condition_obj.get("lte") { 677 + if let Ok(lte_str) = lte_val.string() { 678 + where_condition.lte = Some(serde_json::Value::String(lte_str.to_string())); 679 + } else if let Ok(lte_i64) = lte_val.i64() { 680 + where_condition.lte = Some(serde_json::Value::Number(lte_i64.into())); 681 + } 682 + } 683 + 684 + // Convert indexedAt to indexed_at for database column 685 + let db_field_name = if field_name == "indexedAt" { 686 + "indexed_at".to_string() 687 + } else { 688 + field_name.to_string() 689 + }; 690 + 691 + where_clause.conditions.insert(db_field_name, where_condition); 475 692 } 476 693 } 477 694 } ··· 499 716 if !dids.is_empty() { 500 717 let did_condition = if dids.len() == 1 { 501 718 crate::models::WhereCondition { 719 + gt: None, 720 + gte: None, 721 + lt: None, 722 + lte: None, 502 723 eq: Some(serde_json::Value::String(dids[0].clone())), 503 724 in_values: None, 504 725 contains: None, 505 726 } 506 727 } else { 507 728 crate::models::WhereCondition { 729 + gt: None, 730 + gte: None, 731 + lt: None, 732 + lte: None, 508 733 eq: None, 509 734 in_values: Some(dids.into_iter().map(|d| serde_json::Value::String(d)).collect()), 510 735 contains: None, ··· 546 771 ) 547 772 .argument(async_graphql::dynamic::InputValue::new( 548 773 "groupBy", 549 - TypeRef::named_nn_list_nn(TypeRef::STRING), 774 + TypeRef::named_nn_list(format!("{}GroupByFieldInput", type_name)), 550 775 )) 551 776 .argument(async_graphql::dynamic::InputValue::new( 552 777 "where", 553 - TypeRef::named("JSON"), 778 + TypeRef::named(format!("{}WhereInput", type_name)), 554 779 )) 555 780 .argument(async_graphql::dynamic::InputValue::new( 556 781 "orderBy", ··· 571 796 // Build Subscription type with collection-specific subscriptions 572 797 let subscription = create_subscription_type(slice_uri.clone(), &lexicons); 573 798 574 - // Build and return the schema 799 + // Build and return the schema with complexity limits 575 800 let mut schema_builder = Schema::build(query.type_name(), Some(mutation.type_name()), Some(subscription.type_name())) 576 801 .register(query) 577 802 .register(mutation) 578 - .register(subscription); 803 + .register(subscription) 804 + .limit_depth(50) // Higher limit to support GraphiQL introspection with reverse joins 805 + .limit_complexity(5000); // Prevent expensive deeply nested queries 579 806 580 807 // Register JSON scalar type for complex fields 581 808 let json_scalar = Scalar::new("JSON"); 582 809 schema_builder = schema_builder.register(json_scalar); 583 810 811 + // Register filter input types for WHERE clauses 812 + let string_filter = InputObject::new("StringFilter") 813 + .field(InputValue::new("eq", TypeRef::named(TypeRef::STRING))) 814 + .field(InputValue::new("in", TypeRef::named_list(TypeRef::STRING))) 815 + .field(InputValue::new("contains", TypeRef::named(TypeRef::STRING))) 816 + .field(InputValue::new("gt", TypeRef::named(TypeRef::STRING))) 817 + .field(InputValue::new("gte", TypeRef::named(TypeRef::STRING))) 818 + .field(InputValue::new("lt", TypeRef::named(TypeRef::STRING))) 819 + .field(InputValue::new("lte", TypeRef::named(TypeRef::STRING))); 820 + schema_builder = schema_builder.register(string_filter); 821 + 822 + let int_filter = InputObject::new("IntFilter") 823 + .field(InputValue::new("eq", TypeRef::named(TypeRef::INT))) 824 + .field(InputValue::new("in", TypeRef::named_list(TypeRef::INT))) 825 + .field(InputValue::new("gt", TypeRef::named(TypeRef::INT))) 826 + .field(InputValue::new("gte", TypeRef::named(TypeRef::INT))) 827 + .field(InputValue::new("lt", TypeRef::named(TypeRef::INT))) 828 + .field(InputValue::new("lte", TypeRef::named(TypeRef::INT))); 829 + schema_builder = schema_builder.register(int_filter); 830 + 831 + let datetime_filter = InputObject::new("DateTimeFilter") 832 + .field(InputValue::new("eq", TypeRef::named(TypeRef::STRING))) 833 + .field(InputValue::new("gt", TypeRef::named(TypeRef::STRING))) 834 + .field(InputValue::new("gte", TypeRef::named(TypeRef::STRING))) 835 + .field(InputValue::new("lt", TypeRef::named(TypeRef::STRING))) 836 + .field(InputValue::new("lte", TypeRef::named(TypeRef::STRING))); 837 + schema_builder = schema_builder.register(datetime_filter); 838 + 584 839 // Register Blob type 585 840 let blob_type = create_blob_type(); 586 841 schema_builder = schema_builder.register(blob_type); ··· 608 863 let aggregation_order_by_input = create_aggregation_order_by_input(); 609 864 schema_builder = schema_builder.register(aggregation_order_by_input); 610 865 866 + // Register DateInterval enum for date truncation 867 + let date_interval_enum = create_date_interval_enum(); 868 + schema_builder = schema_builder.register(date_interval_enum); 869 + 611 870 // Register PageInfo type 612 871 let page_info_type = create_page_info_type(); 613 872 schema_builder = schema_builder.register(page_info_type); ··· 621 880 schema_builder = schema_builder.register(obj); 622 881 } 623 882 883 + // Register all WhereInput types 884 + for where_input in where_inputs_to_register { 885 + schema_builder = schema_builder.register(where_input); 886 + } 887 + 888 + // Register all GroupByField enums 889 + for group_by_enum in group_by_enums_to_register { 890 + schema_builder = schema_builder.register(group_by_enum); 891 + } 892 + 624 893 schema_builder 625 894 .finish() 626 895 .map_err(|e| format!("Schema build error: {:?}", e)) ··· 720 989 where_clause.conditions.insert( 721 990 "did".to_string(), 722 991 crate::models::WhereCondition { 992 + gt: None, 993 + gte: None, 994 + lt: None, 995 + lte: None, 723 996 eq: Some(serde_json::Value::String(did.clone())), 724 997 in_values: None, 725 998 contains: None, ··· 768 1041 if let Some(val) = value { 769 1042 // Check for explicit null value 770 1043 if val.is_null() { 771 - return Ok(Some(FieldValue::NULL)); 1044 + return Ok(None); 1045 + } 1046 + 1047 + // Check if this is an array of blobs 1048 + if let GraphQLType::Array(inner) = &field_type { 1049 + if matches!(inner.as_ref(), GraphQLType::Blob) { 1050 + if let Some(arr) = val.as_array() { 1051 + let blob_containers: Vec<FieldValue> = arr 1052 + .iter() 1053 + .filter_map(|blob_val| { 1054 + let obj = blob_val.as_object()?; 1055 + let blob_ref = obj 1056 + .get("ref") 1057 + .and_then(|r| r.as_object()) 1058 + .and_then(|r| r.get("$link")) 1059 + .and_then(|l| l.as_str()) 1060 + .unwrap_or("") 1061 + .to_string(); 1062 + 1063 + let mime_type = obj 1064 + .get("mimeType") 1065 + .and_then(|m| m.as_str()) 1066 + .unwrap_or("image/jpeg") 1067 + .to_string(); 1068 + 1069 + let size = obj 1070 + .get("size") 1071 + .and_then(|s| s.as_i64()) 1072 + .unwrap_or(0); 1073 + 1074 + let blob_container = BlobContainer { 1075 + blob_ref, 1076 + mime_type, 1077 + size, 1078 + did: container.record.did.clone(), 1079 + }; 1080 + 1081 + Some(FieldValue::owned_any(blob_container)) 1082 + }) 1083 + .collect(); 1084 + 1085 + return Ok(Some(FieldValue::list(blob_containers))); 1086 + } 1087 + 1088 + // If not a proper array, return empty list 1089 + return Ok(Some(FieldValue::list(Vec::<FieldValue>::new()))); 1090 + } 772 1091 } 773 1092 774 1093 // Check if this is a blob field ··· 804 1123 return Ok(Some(FieldValue::owned_any(blob_container))); 805 1124 } 806 1125 807 - // If not a proper blob object, return NULL 808 - return Ok(Some(FieldValue::NULL)); 1126 + // If not a proper blob object, return None (field is null) 1127 + return Ok(None); 809 1128 } 810 1129 811 1130 // Check if this is a reference field that needs joining ··· 827 1146 return Ok(Some(FieldValue::value(graphql_val))); 828 1147 } 829 1148 Ok(None) => { 830 - return Ok(Some(FieldValue::NULL)); 1149 + return Ok(None); 831 1150 } 832 1151 Err(e) => { 833 1152 tracing::error!("Error fetching linked record: {}", e); 834 - return Ok(Some(FieldValue::NULL)); 1153 + return Ok(None); 835 1154 } 836 1155 } 837 1156 } ··· 841 1160 let graphql_val = json_to_graphql_value(val); 842 1161 Ok(Some(FieldValue::value(graphql_val))) 843 1162 } else { 844 - Ok(Some(FieldValue::NULL)) 1163 + Ok(None) 845 1164 } 846 1165 }) 847 1166 })); 848 1167 } 849 1168 850 - // Add join fields for cross-referencing other collections by DID 1169 + // Add join fields for cross-referencing other collections 851 1170 for collection in all_collections { 852 1171 let field_name = nsid_to_join_field_name(&collection.nsid); 853 1172 ··· 856 1175 continue; 857 1176 } 858 1177 1178 + // Collect all string fields with format "at-uri" that might reference this collection 1179 + // We'll check each one at runtime to see if it contains a URI to this collection 1180 + let uri_ref_fields: Vec<_> = fields.iter() 1181 + .filter(|f| matches!(f.format.as_deref(), Some("at-uri"))) 1182 + .collect(); 1183 + 859 1184 let collection_nsid = collection.nsid.clone(); 860 1185 let key_type = collection.key_type.clone(); 861 1186 let db_for_join = database.clone(); 862 - let slice_for_join = slice_uri.clone(); 1187 + 1188 + // If we found at-uri fields, create a resolver that checks each one at runtime 1189 + if !uri_ref_fields.is_empty() { 1190 + let ref_field_names: Vec<String> = uri_ref_fields.iter().map(|f| f.name.clone()).collect(); 1191 + let db_for_uri_join = database.clone(); 1192 + let target_collection = collection_nsid.clone(); 1193 + 1194 + object = object.field(Field::new( 1195 + &field_name, 1196 + TypeRef::named(&collection.type_name), 1197 + move |ctx| { 1198 + let db = db_for_uri_join.clone(); 1199 + let field_names = ref_field_names.clone(); 1200 + let expected_collection = target_collection.clone(); 1201 + FieldFuture::new(async move { 1202 + let container = ctx.parent_value.try_downcast_ref::<RecordContainer>()?; 1203 + 1204 + // Try each at-uri field to find one that references this collection 1205 + for field_name in &field_names { 1206 + if let Some(uri_value) = container.record.value.get(field_name) { 1207 + if let Some(uri) = uri_value.as_str() { 1208 + // Check if the URI is for the expected collection 1209 + if uri.contains(&format!("/{}/", expected_collection)) { 1210 + // Fetch the record at this URI 1211 + match db.get_record(uri).await { 1212 + Ok(Some(record)) => { 1213 + let new_container = RecordContainer { record }; 1214 + return Ok(Some(FieldValue::owned_any(new_container))); 1215 + } 1216 + Ok(None) => continue, // Try next field 1217 + Err(_) => continue, // Try next field 1218 + } 1219 + } 1220 + } 1221 + } 1222 + } 1223 + // No matching URI found in any field 1224 + Ok(None) 1225 + }) 1226 + }, 1227 + )); 1228 + continue; // Skip the normal DID-based join logic 1229 + } 863 1230 864 1231 // Determine type and resolver based on key_type 865 1232 match key_type.as_str() { ··· 893 1260 )); 894 1261 } 895 1262 "tid" | "any" => { 896 - // Multiple records per DID - return array of the collection's type 897 - object = object.field( 1263 + // Skip - these are handled as plural reverse joins below with URI filtering 1264 + continue; 1265 + 1266 + // Multiple records per DID - return array of the collection's type (DISABLED) 1267 + /*object = object.field( 898 1268 Field::new( 899 1269 &field_name, 900 1270 TypeRef::named_nn_list_nn(&collection.type_name), 901 1271 move |ctx| { 902 - let db = db_for_join.clone(); 903 1272 let nsid = collection_nsid.clone(); 904 1273 let slice = slice_for_join.clone(); 1274 + let db_fallback = db_for_join.clone(); 905 1275 FieldFuture::new(async move { 906 1276 let container = ctx.parent_value.try_downcast_ref::<RecordContainer>()?; 907 1277 let did = &container.record.did; ··· 909 1279 // Get limit from argument, default to 50 910 1280 let limit = ctx.args.get("limit") 911 1281 .and_then(|v| v.i64().ok()) 912 - .map(|i| i as i32) 1282 + .map(|i| i as usize) 913 1283 .unwrap_or(50) 914 1284 .min(100); // Cap at 100 to prevent abuse 915 1285 916 - // Build where clause to find all records of this collection for this DID 917 - let mut where_clause = crate::models::WhereClause { 918 - conditions: HashMap::new(), 919 - or_conditions: None, 920 - }; 921 - where_clause.conditions.insert( 922 - "collection".to_string(), 923 - crate::models::WhereCondition { 924 - eq: Some(serde_json::Value::String(nsid.clone())), 925 - in_values: None, 926 - contains: None, 927 - }, 928 - ); 929 - where_clause.conditions.insert( 930 - "did".to_string(), 931 - crate::models::WhereCondition { 932 - eq: Some(serde_json::Value::String(did.clone())), 933 - in_values: None, 934 - contains: None, 935 - }, 936 - ); 1286 + // Try to get DataLoader from context 1287 + if let Some(gql_ctx) = ctx.data_opt::<GraphQLContext>() { 1288 + // Use DataLoader for batched loading 1289 + let key = CollectionDidKey { 1290 + slice_uri: slice.clone(), 1291 + collection: nsid.clone(), 1292 + did: did.clone(), 1293 + }; 1294 + 1295 + match gql_ctx.collection_did_loader.load_one(key).await { 1296 + Ok(Some(mut records)) => { 1297 + // Apply limit after loading 1298 + records.truncate(limit); 937 1299 938 - match db.get_slice_collections_records( 939 - &slice, 940 - Some(limit), 941 - None, // cursor 942 - None, // sort 943 - Some(&where_clause), 944 - ).await { 945 - Ok((records, _cursor)) => { 946 - let values: Vec<FieldValue> = records 947 - .into_iter() 948 - .map(|record| { 949 - // Convert Record to IndexedRecord 950 - let indexed_record = crate::models::IndexedRecord { 951 - uri: record.uri, 952 - cid: record.cid, 953 - did: record.did, 954 - collection: record.collection, 955 - value: record.json, 956 - indexed_at: record.indexed_at.to_rfc3339(), 957 - }; 958 - let container = RecordContainer { 959 - record: indexed_record, 960 - }; 961 - FieldValue::owned_any(container) 962 - }) 963 - .collect(); 964 - Ok(Some(FieldValue::list(values))) 1300 + let values: Vec<FieldValue> = records 1301 + .into_iter() 1302 + .map(|indexed_record| { 1303 + let container = RecordContainer { 1304 + record: indexed_record, 1305 + }; 1306 + FieldValue::owned_any(container) 1307 + }) 1308 + .collect(); 1309 + Ok(Some(FieldValue::list(values))) 1310 + } 1311 + Ok(None) => { 1312 + Ok(Some(FieldValue::list(Vec::<FieldValue>::new()))) 1313 + } 1314 + Err(e) => { 1315 + tracing::debug!("DataLoader error for {}: {:?}", nsid, e); 1316 + Ok(Some(FieldValue::list(Vec::<FieldValue>::new()))) 1317 + } 965 1318 } 966 - Err(e) => { 967 - tracing::debug!("Error querying {}: {}", nsid, e); 968 - Ok(Some(FieldValue::list(Vec::<FieldValue>::new()))) 1319 + } else { 1320 + // Fallback to direct database query if DataLoader not available 1321 + let db = db_fallback.clone(); 1322 + let mut where_clause = crate::models::WhereClause { 1323 + conditions: HashMap::new(), 1324 + or_conditions: None, 1325 + }; 1326 + where_clause.conditions.insert( 1327 + "collection".to_string(), 1328 + crate::models::WhereCondition { 1329 + gt: None, 1330 + gte: None, 1331 + lt: None, 1332 + lte: None, 1333 + eq: Some(serde_json::Value::String(nsid.clone())), 1334 + in_values: None, 1335 + contains: None, 1336 + }, 1337 + ); 1338 + where_clause.conditions.insert( 1339 + "did".to_string(), 1340 + crate::models::WhereCondition { 1341 + gt: None, 1342 + gte: None, 1343 + lt: None, 1344 + lte: None, 1345 + eq: Some(serde_json::Value::String(did.clone())), 1346 + in_values: None, 1347 + contains: None, 1348 + }, 1349 + ); 1350 + 1351 + match db.get_slice_collections_records( 1352 + &slice, 1353 + Some(limit as i32), 1354 + None, // cursor 1355 + None, // sort 1356 + Some(&where_clause), 1357 + ).await { 1358 + Ok((records, _cursor)) => { 1359 + let values: Vec<FieldValue> = records 1360 + .into_iter() 1361 + .map(|record| { 1362 + let indexed_record = crate::models::IndexedRecord { 1363 + uri: record.uri, 1364 + cid: record.cid, 1365 + did: record.did, 1366 + collection: record.collection, 1367 + value: record.json, 1368 + indexed_at: record.indexed_at.to_rfc3339(), 1369 + }; 1370 + let container = RecordContainer { 1371 + record: indexed_record, 1372 + }; 1373 + FieldValue::owned_any(container) 1374 + }) 1375 + .collect(); 1376 + Ok(Some(FieldValue::list(values))) 1377 + } 1378 + Err(e) => { 1379 + tracing::debug!("Error querying {}: {}", nsid, e); 1380 + Ok(Some(FieldValue::list(Vec::<FieldValue>::new()))) 1381 + } 969 1382 } 970 1383 } 971 1384 }) ··· 975 1388 "limit", 976 1389 TypeRef::named(TypeRef::INT), 977 1390 )) 978 - ); 1391 + );*/ 979 1392 } 980 1393 _ => { 981 1394 // Unknown key type, skip ··· 988 1401 // This enables bidirectional traversal (e.g., profile.plays and play.profile) 989 1402 for collection in all_collections { 990 1403 let reverse_field_name = format!("{}s", nsid_to_join_field_name(&collection.nsid)); 991 - let db_for_reverse = database.clone(); 992 1404 let slice_for_reverse = slice_uri.clone(); 993 1405 let collection_nsid = collection.nsid.clone(); 994 1406 let collection_type = collection.type_name.clone(); 1407 + let at_uri_fields = collection.at_uri_fields.clone(); 995 1408 996 1409 object = object.field( 997 1410 Field::new( 998 1411 &reverse_field_name, 999 1412 TypeRef::named_nn_list_nn(&collection_type), 1000 1413 move |ctx| { 1001 - let db = db_for_reverse.clone(); 1002 1414 let slice = slice_for_reverse.clone(); 1003 1415 let nsid = collection_nsid.clone(); 1416 + let ref_fields = at_uri_fields.clone(); 1004 1417 FieldFuture::new(async move { 1005 1418 let container = ctx.parent_value.try_downcast_ref::<RecordContainer>()?; 1006 - let did = &container.record.did; 1007 1419 1008 1420 // Get limit from argument, default to 50 1009 1421 let limit = ctx.args.get("limit") 1010 1422 .and_then(|v| v.i64().ok()) 1011 - .map(|i| i as i32) 1423 + .map(|i| i as usize) 1012 1424 .unwrap_or(50) 1013 1425 .min(100); // Cap at 100 to prevent abuse 1014 1426 1015 - // Build where clause to find all records of this collection for this DID 1016 - let mut where_clause = crate::models::WhereClause { 1017 - conditions: HashMap::new(), 1018 - or_conditions: None, 1019 - }; 1020 - where_clause.conditions.insert( 1021 - "collection".to_string(), 1022 - crate::models::WhereCondition { 1023 - eq: Some(serde_json::Value::String(nsid.clone())), 1024 - in_values: None, 1025 - contains: None, 1026 - }, 1027 - ); 1028 - where_clause.conditions.insert( 1029 - "did".to_string(), 1030 - crate::models::WhereCondition { 1031 - eq: Some(serde_json::Value::String(did.clone())), 1032 - in_values: None, 1033 - contains: None, 1034 - }, 1035 - ); 1427 + // Try to get DataLoader from context 1428 + if let Some(gql_ctx) = ctx.data_opt::<GraphQLContext>() { 1429 + let parent_uri = &container.record.uri; 1430 + 1431 + // Try each at-uri field from the lexicon 1432 + tracing::debug!( 1433 + "Trying reverse join for {} with at-uri fields: {:?}", 1434 + nsid, 1435 + ref_fields 1436 + ); 1437 + 1438 + for ref_field in &ref_fields { 1439 + let key = crate::graphql::dataloader::CollectionUriKey { 1440 + slice_uri: slice.clone(), 1441 + collection: nsid.clone(), 1442 + parent_uri: parent_uri.clone(), 1443 + reference_field: ref_field.clone(), 1444 + }; 1445 + 1446 + tracing::debug!( 1447 + "Querying {} via field '{}' for URI: {}", 1448 + nsid, 1449 + ref_field, 1450 + parent_uri 1451 + ); 1452 + 1453 + match gql_ctx.collection_uri_loader.load_one(key).await { 1454 + Ok(Some(mut records)) => { 1455 + if !records.is_empty() { 1456 + tracing::debug!( 1457 + "Found {} {} records via '{}' field for parent URI: {}", 1458 + records.len(), 1459 + nsid, 1460 + ref_field, 1461 + parent_uri 1462 + ); 1463 + 1464 + // Apply limit 1465 + records.truncate(limit); 1036 1466 1037 - match db.get_slice_collections_records( 1038 - &slice, 1039 - Some(limit), 1040 - None, // cursor 1041 - None, // sort 1042 - Some(&where_clause), 1043 - ).await { 1044 - Ok((records, _cursor)) => { 1045 - let values: Vec<FieldValue> = records 1046 - .into_iter() 1047 - .map(|record| { 1048 - // Convert Record to IndexedRecord 1049 - let indexed_record = crate::models::IndexedRecord { 1050 - uri: record.uri, 1051 - cid: record.cid, 1052 - did: record.did, 1053 - collection: record.collection, 1054 - value: record.json, 1055 - indexed_at: record.indexed_at.to_rfc3339(), 1056 - }; 1057 - let container = RecordContainer { 1058 - record: indexed_record, 1059 - }; 1060 - FieldValue::owned_any(container) 1061 - }) 1062 - .collect(); 1063 - Ok(Some(FieldValue::list(values))) 1467 + let values: Vec<FieldValue> = records 1468 + .into_iter() 1469 + .map(|indexed_record| { 1470 + let container = RecordContainer { 1471 + record: indexed_record, 1472 + }; 1473 + FieldValue::owned_any(container) 1474 + }) 1475 + .collect(); 1476 + return Ok(Some(FieldValue::list(values))); 1477 + } 1478 + } 1479 + Ok(None) => continue, 1480 + Err(e) => { 1481 + tracing::debug!("DataLoader error for {} field '{}': {:?}", nsid, ref_field, e); 1482 + continue; 1483 + } 1484 + } 1064 1485 } 1065 - Err(e) => { 1066 - tracing::debug!("Error querying {}: {}", nsid, e); 1067 - Ok(Some(FieldValue::list(Vec::<FieldValue>::new()))) 1068 - } 1486 + 1487 + // No records found via any at-uri field 1488 + tracing::debug!("No {} records found for parent URI: {}", nsid, parent_uri); 1489 + return Ok(Some(FieldValue::list(Vec::<FieldValue>::new()))); 1069 1490 } 1491 + 1492 + // Fallback: DataLoader not available 1493 + tracing::debug!("DataLoader not available for reverse join"); 1494 + Ok(Some(FieldValue::list(Vec::<FieldValue>::new()))) 1070 1495 }) 1071 1496 }, 1072 1497 ) ··· 1075 1500 TypeRef::named(TypeRef::INT), 1076 1501 )) 1077 1502 ); 1503 + 1504 + // Add count field for the reverse join 1505 + let count_field_name = format!("{}Count", reverse_field_name); 1506 + let db_for_count = database.clone(); 1507 + let slice_for_count = slice_uri.clone(); 1508 + let collection_for_count = collection.nsid.clone(); 1509 + let at_uri_fields_for_count = collection.at_uri_fields.clone(); 1510 + 1511 + object = object.field( 1512 + Field::new( 1513 + &count_field_name, 1514 + TypeRef::named_nn(TypeRef::INT), 1515 + move |ctx| { 1516 + let slice = slice_for_count.clone(); 1517 + let nsid = collection_for_count.clone(); 1518 + let db = db_for_count.clone(); 1519 + let ref_fields = at_uri_fields_for_count.clone(); 1520 + FieldFuture::new(async move { 1521 + let container = ctx.parent_value.try_downcast_ref::<RecordContainer>()?; 1522 + let parent_uri = &container.record.uri; 1523 + 1524 + // Build where clause to count records referencing this URI 1525 + for ref_field in &ref_fields { 1526 + let mut where_clause = crate::models::WhereClause { 1527 + conditions: HashMap::new(), 1528 + or_conditions: None, 1529 + }; 1530 + 1531 + where_clause.conditions.insert( 1532 + "collection".to_string(), 1533 + crate::models::WhereCondition { 1534 + gt: None, 1535 + gte: None, 1536 + lt: None, 1537 + lte: None, 1538 + eq: Some(serde_json::Value::String(nsid.clone())), 1539 + in_values: None, 1540 + contains: None, 1541 + }, 1542 + ); 1543 + 1544 + where_clause.conditions.insert( 1545 + ref_field.clone(), 1546 + crate::models::WhereCondition { 1547 + gt: None, 1548 + gte: None, 1549 + lt: None, 1550 + lte: None, 1551 + eq: Some(serde_json::Value::String(parent_uri.clone())), 1552 + in_values: None, 1553 + contains: None, 1554 + }, 1555 + ); 1556 + 1557 + match db.count_slice_collections_records(&slice, Some(&where_clause)).await { 1558 + Ok(count) if count > 0 => { 1559 + return Ok(Some(FieldValue::value(count as i32))); 1560 + } 1561 + Ok(_) => continue, 1562 + Err(e) => { 1563 + tracing::debug!("Count error for {}: {}", nsid, e); 1564 + continue; 1565 + } 1566 + } 1567 + } 1568 + 1569 + // No matching field found, return 0 1570 + Ok(Some(FieldValue::value(0))) 1571 + }) 1572 + }, 1573 + ) 1574 + ); 1078 1575 } 1079 1576 1080 1577 object ··· 1144 1641 } 1145 1642 GraphQLType::Blob => { 1146 1643 // Blob object type with url resolver 1147 - if is_required { 1148 - TypeRef::named_nn("Blob") 1149 - } else { 1150 - TypeRef::named("Blob") 1151 - } 1644 + // Always nullable since blob data might be missing or malformed 1645 + TypeRef::named("Blob") 1152 1646 } 1153 1647 GraphQLType::Json | GraphQLType::Ref | GraphQLType::Object(_) | GraphQLType::Union => { 1154 1648 // JSON scalar type - linked records and complex objects return as JSON ··· 1175 1669 TypeRef::named_nn_list_nn(inner_ref) 1176 1670 } else { 1177 1671 TypeRef::named_list(inner_ref) 1672 + } 1673 + } 1674 + GraphQLType::Blob => { 1675 + // Arrays of blobs - return list of Blob objects 1676 + if is_required { 1677 + TypeRef::named_nn_list("Blob") 1678 + } else { 1679 + TypeRef::named_list("Blob") 1178 1680 } 1179 1681 } 1180 1682 _ => { ··· 1715 2217 fn create_aggregation_order_by_input() -> InputObject { 1716 2218 InputObject::new("AggregationOrderBy") 1717 2219 .field(InputValue::new("count", TypeRef::named("SortDirection"))) 2220 + } 2221 + 2222 + /// Creates the DateInterval enum for date truncation 2223 + fn create_date_interval_enum() -> Enum { 2224 + Enum::new("DateInterval") 2225 + .item(EnumItem::new("second")) 2226 + .item(EnumItem::new("minute")) 2227 + .item(EnumItem::new("hour")) 2228 + .item(EnumItem::new("day")) 2229 + .item(EnumItem::new("week")) 2230 + .item(EnumItem::new("month")) 2231 + .item(EnumItem::new("quarter")) 2232 + .item(EnumItem::new("year")) 1718 2233 } 1719 2234 1720 2235 /// Converts a serde_json::Value to an async_graphql::Value
+7
api/src/graphql/types.rs
··· 8 8 pub name: String, 9 9 pub field_type: GraphQLType, 10 10 pub is_required: bool, 11 + pub format: Option<String>, // e.g., "at-uri", "uri", "datetime" 11 12 } 12 13 13 14 /// GraphQL type representation mapped from lexicon types ··· 96 97 .and_then(|t| t.as_str()) 97 98 .unwrap_or("unknown"); 98 99 100 + let format = field_def 101 + .get("format") 102 + .and_then(|f| f.as_str()) 103 + .map(|s| s.to_string()); 104 + 99 105 GraphQLField { 100 106 name: field_name.clone(), 107 + format, 101 108 field_type: map_lexicon_type_to_graphql( 102 109 field_type_name, 103 110 field_def,
+52
api/src/models.rs
··· 90 90 pub timestamp: String, 91 91 pub count: i64, 92 92 } 93 + 94 + /// Date interval for date truncation in aggregations 95 + #[derive(Debug, Clone, Serialize, Deserialize)] 96 + #[serde(rename_all = "lowercase")] 97 + pub enum DateInterval { 98 + Second, 99 + Minute, 100 + Hour, 101 + Day, 102 + Week, 103 + Month, 104 + Quarter, 105 + Year, 106 + } 107 + 108 + impl DateInterval { 109 + /// Convert to PostgreSQL date_trunc() interval string 110 + pub fn to_pg_interval(&self) -> &'static str { 111 + match self { 112 + DateInterval::Second => "second", 113 + DateInterval::Minute => "minute", 114 + DateInterval::Hour => "hour", 115 + DateInterval::Day => "day", 116 + DateInterval::Week => "week", 117 + DateInterval::Month => "month", 118 + DateInterval::Quarter => "quarter", 119 + DateInterval::Year => "year", 120 + } 121 + } 122 + } 123 + 124 + /// Group by field specification for aggregations 125 + #[derive(Debug, Clone)] 126 + pub enum GroupByField { 127 + /// Simple field name 128 + Simple(String), 129 + /// Date-truncated field 130 + Truncated { 131 + field: String, 132 + interval: DateInterval, 133 + }, 134 + } 135 + 136 + impl GroupByField { 137 + /// Get the field name 138 + pub fn field_name(&self) -> &str { 139 + match self { 140 + GroupByField::Simple(name) => name, 141 + GroupByField::Truncated { field, .. } => field, 142 + } 143 + } 144 + }
+762
docs/graphql-api.md
··· 1 + # GraphQL API 2 + 3 + Slices provides a powerful GraphQL API for querying indexed AT Protocol data. The API automatically generates schema from your lexicons and provides efficient querying with relationship traversal. 4 + 5 + ## Accessing the API 6 + 7 + GraphQL endpoints are available per-slice: 8 + 9 + ``` 10 + POST /graphql?slice=<slice-uri> 11 + ``` 12 + 13 + ### GraphQL Playground 14 + 15 + Access the interactive GraphQL Playground in your browser: 16 + 17 + ``` 18 + https://api.slices.network/graphql?slice=<slice-uri> 19 + ``` 20 + 21 + ## Schema Generation 22 + 23 + The GraphQL schema is automatically generated from your slice's lexicons: 24 + 25 + - **Types**: One GraphQL type per collection (e.g., `social.grain.gallery` → `SocialGrainGallery`) 26 + - **Queries**: Collection queries with filtering, sorting, and pagination 27 + - **Mutations**: Create, update, delete operations per collection 28 + - **Subscriptions**: Real-time updates for record changes 29 + 30 + ## Querying Data 31 + 32 + ### Basic Query 33 + 34 + ```graphql 35 + query { 36 + socialGrainGalleries { 37 + edges { 38 + node { 39 + uri 40 + title 41 + description 42 + createdAt 43 + } 44 + } 45 + } 46 + } 47 + ``` 48 + 49 + ### Filtering 50 + 51 + Use `where` clauses with typed filter conditions. Each collection has its own `{Collection}WhereInput` type with appropriate filters for each field. 52 + 53 + ```graphql 54 + query { 55 + socialGrainGalleries(where: { 56 + title: { contains: "Aerial" } 57 + }) { 58 + edges { 59 + node { 60 + uri 61 + title 62 + description 63 + } 64 + } 65 + } 66 + } 67 + ``` 68 + 69 + #### Filter Types 70 + 71 + The API provides three filter types based on field data types: 72 + 73 + **StringFilter** - For string fields: 74 + - `eq`: Exact match 75 + - `in`: Match any value in array 76 + - `contains`: Substring match (case-insensitive) 77 + - `gt`: Greater than (lexicographic) 78 + - `gte`: Greater than or equal to 79 + - `lt`: Less than 80 + - `lte`: Less than or equal to 81 + 82 + **IntFilter** - For integer fields: 83 + - `eq`: Exact match 84 + - `in`: Match any value in array 85 + - `gt`: Greater than 86 + - `gte`: Greater than or equal to 87 + - `lt`: Less than 88 + - `lte`: Less than or equal to 89 + 90 + **DateTimeFilter** - For datetime fields: 91 + - `eq`: Exact match 92 + - `gt`: After datetime 93 + - `gte`: At or after datetime 94 + - `lt`: Before datetime 95 + - `lte`: At or before datetime 96 + 97 + #### Date Range Example 98 + 99 + ```graphql 100 + query RecentGalleries { 101 + socialGrainGalleries( 102 + where: { 103 + createdAt: { 104 + gte: "2025-01-01T00:00:00Z" 105 + lt: "2025-12-31T23:59:59Z" 106 + } 107 + } 108 + ) { 109 + edges { 110 + node { 111 + uri 112 + title 113 + createdAt 114 + } 115 + } 116 + } 117 + } 118 + ``` 119 + 120 + #### Multiple Conditions 121 + 122 + Combine multiple filters - they are AND'ed together: 123 + 124 + ```graphql 125 + query { 126 + socialGrainGalleries( 127 + where: { 128 + title: { contains: "Aerial" } 129 + createdAt: { gte: "2025-01-01T00:00:00Z" } 130 + } 131 + ) { 132 + edges { 133 + node { 134 + uri 135 + title 136 + description 137 + createdAt 138 + } 139 + } 140 + } 141 + } 142 + ``` 143 + 144 + ### Pagination 145 + 146 + Relay-style cursor pagination: 147 + 148 + ```graphql 149 + query { 150 + socialGrainGalleries(first: 10, after: "cursor") { 151 + edges { 152 + cursor 153 + node { 154 + uri 155 + title 156 + } 157 + } 158 + pageInfo { 159 + hasNextPage 160 + endCursor 161 + } 162 + } 163 + } 164 + ``` 165 + 166 + ### Sorting 167 + 168 + Each collection has its own typed `{Collection}SortFieldInput` for type-safe sorting: 169 + 170 + ```graphql 171 + query { 172 + socialGrainGalleries( 173 + sortBy: [ 174 + { field: createdAt, direction: desc } 175 + ] 176 + ) { 177 + edges { 178 + node { 179 + uri 180 + title 181 + createdAt 182 + } 183 + } 184 + } 185 + } 186 + ``` 187 + 188 + **Multi-field sorting:** 189 + ```graphql 190 + query { 191 + socialGrainGalleries( 192 + sortBy: [ 193 + { field: actorHandle, direction: asc } 194 + { field: createdAt, direction: desc } 195 + ] 196 + ) { 197 + edges { 198 + node { 199 + uri 200 + title 201 + actorHandle 202 + createdAt 203 + } 204 + } 205 + } 206 + } 207 + ``` 208 + 209 + The `field` enum values are collection-specific (e.g., `SocialGrainGallerySortFieldInput`). Use GraphQL introspection or the playground to see available fields for each collection. 210 + 211 + ## Aggregations 212 + 213 + Aggregation queries allow you to group records and perform calculations. Each collection has a corresponding `{Collection}Aggregated` query. 214 + 215 + ### Basic Aggregation 216 + 217 + Group records by one or more fields and get counts: 218 + 219 + ```graphql 220 + query TopTracks { 221 + fmTealAlphaFeedPlaysAggregated( 222 + groupBy: [trackName] 223 + orderBy: { count: desc } 224 + limit: 10 225 + ) { 226 + trackName 227 + count 228 + } 229 + } 230 + ``` 231 + 232 + ### Multi-Field Grouping 233 + 234 + Group by multiple fields using the typed `{Collection}GroupByField` enum: 235 + 236 + ```graphql 237 + query TopTracksByArtist { 238 + fmTealAlphaFeedPlaysAggregated( 239 + groupBy: [trackName, artists] 240 + orderBy: { count: desc } 241 + limit: 20 242 + ) { 243 + trackName 244 + artists 245 + count 246 + } 247 + } 248 + ``` 249 + 250 + ### Filtering Aggregations 251 + 252 + Combine typed filters with aggregations for time-based analysis: 253 + 254 + ```graphql 255 + query TopTracksThisWeek { 256 + fmTealAlphaFeedPlaysAggregated( 257 + groupBy: [trackName, artists] 258 + where: { 259 + indexedAt: { 260 + gte: "2025-01-01T00:00:00Z" 261 + lt: "2025-01-08T00:00:00Z" 262 + } 263 + trackName: { contains: "Love" } 264 + } 265 + orderBy: { count: desc } 266 + limit: 10 267 + ) { 268 + trackName 269 + artists 270 + count 271 + } 272 + } 273 + ``` 274 + 275 + ### Aggregation Features 276 + 277 + - **Typed GroupBy**: Each collection has a `{Collection}GroupByField` enum for type-safe field selection 278 + - **Typed Filters**: Use the same `{Collection}WhereInput` as regular queries 279 + - **Sorting**: Order by `count` (ascending or descending) or any grouped field 280 + - **Pagination**: Use `limit` to control result count 281 + - **Multiple Fields**: Group by any combination of fields from your lexicon 282 + - **Date Truncation**: Group by time intervals (second, minute, hour, day, week, month, quarter, year) 283 + 284 + ### Date Truncation 285 + 286 + Group records by time intervals using the `interval` parameter in `groupBy`: 287 + 288 + ```graphql 289 + query DailyPlays { 290 + fmTealAlphaFeedPlaysAggregated( 291 + groupBy: [ 292 + { field: "playedTime", interval: day } 293 + ] 294 + orderBy: { count: desc } 295 + limit: 30 296 + ) { 297 + playedTime 298 + count 299 + } 300 + } 301 + ``` 302 + 303 + **Supported Intervals:** 304 + - `second` - Group by second 305 + - `minute` - Group by minute 306 + - `hour` - Group by hour 307 + - `day` - Group by day (common for daily reports) 308 + - `week` - Group by week (Monday-Sunday) 309 + - `month` - Group by month 310 + - `quarter` - Group by quarter (Q1-Q4) 311 + - `year` - Group by year 312 + 313 + **Combining with Regular Fields:** 314 + ```graphql 315 + query TrackPlaysByDay { 316 + fmTealAlphaFeedPlaysAggregated( 317 + groupBy: [ 318 + { field: "trackName" }, 319 + { field: "playedTime", interval: day } 320 + ] 321 + orderBy: { count: desc } 322 + limit: 100 323 + ) { 324 + trackName 325 + playedTime 326 + count 327 + } 328 + } 329 + ``` 330 + 331 + **How it Works:** 332 + - Uses PostgreSQL's `date_trunc()` function for efficient time bucketing 333 + - Automatically handles timestamp casting for JSON fields 334 + - Returns truncated timestamps (e.g., `2025-01-15 00:00:00` for day interval) 335 + - Works with both system fields (`indexedAt`) and lexicon datetime fields 336 + 337 + ### Use Cases 338 + 339 + **Daily/Weekly/Monthly Reports**: 340 + ```graphql 341 + query WeeklyPlays { 342 + fmTealAlphaFeedPlaysAggregated( 343 + groupBy: [trackName] 344 + where: { 345 + playedTime: { 346 + gte: "2025-01-01T00:00:00Z" 347 + lt: "2025-01-08T00:00:00Z" 348 + } 349 + } 350 + orderBy: { count: desc } 351 + limit: 50 352 + ) { 353 + trackName 354 + count 355 + } 356 + } 357 + ``` 358 + 359 + **Trend Analysis**: 360 + ```graphql 361 + query TrendingArtists { 362 + fmTealAlphaFeedPlaysAggregated( 363 + groupBy: [artists] 364 + where: { 365 + playedTime: { gte: "2025-01-01T00:00:00Z" } 366 + } 367 + orderBy: { count: desc } 368 + limit: 20 369 + ) { 370 + artists 371 + count 372 + } 373 + } 374 + ``` 375 + 376 + ## Relationships 377 + 378 + The GraphQL API automatically generates relationship fields based on your lexicon's `at-uri` fields. 379 + 380 + ### Forward Joins (References) 381 + 382 + When a record has an `at-uri` field, you get a **singular** field that resolves to the referenced record. 383 + 384 + **Lexicon Schema (social.grain.gallery.item):** 385 + ```json 386 + { 387 + "lexicon": 1, 388 + "id": "social.grain.gallery.item", 389 + "defs": { 390 + "main": { 391 + "type": "record", 392 + "key": "tid", 393 + "record": { 394 + "type": "object", 395 + "required": ["gallery", "item", "position", "createdAt"], 396 + "properties": { 397 + "gallery": { 398 + "type": "string", 399 + "format": "at-uri" 400 + }, 401 + "item": { 402 + "type": "string", 403 + "format": "at-uri" 404 + }, 405 + "position": { "type": "integer" }, 406 + "createdAt": { "type": "string", "format": "datetime" } 407 + } 408 + } 409 + } 410 + } 411 + } 412 + ``` 413 + 414 + **Generated GraphQL Type:** 415 + ```graphql 416 + type SocialGrainGalleryItem { 417 + uri: String! 418 + gallery: String! # at-uri field from lexicon 419 + item: String! # at-uri field from lexicon 420 + position: Int! 421 + createdAt: String! 422 + 423 + # Auto-generated forward joins (singular) 424 + socialGrainGallery: SocialGrainGallery 425 + socialGrainPhoto: SocialGrainPhoto 426 + } 427 + ``` 428 + 429 + **Example Query:** 430 + ```graphql 431 + query { 432 + socialGrainGalleryItems(limit: 5) { 433 + position 434 + # Follow the reference to get the photo 435 + socialGrainPhoto { 436 + uri 437 + alt 438 + aspectRatio 439 + } 440 + # Follow the reference to get the gallery 441 + socialGrainGallery { 442 + title 443 + description 444 + } 445 + } 446 + } 447 + ``` 448 + 449 + ### Reverse Joins (Backlinks) 450 + 451 + When other records reference this record via `at-uri` fields, you get **plural** fields that find all records pointing here. 452 + 453 + **Lexicon Schema (social.grain.favorite):** 454 + ```json 455 + { 456 + "lexicon": 1, 457 + "id": "social.grain.favorite", 458 + "defs": { 459 + "main": { 460 + "type": "record", 461 + "key": "tid", 462 + "record": { 463 + "type": "object", 464 + "required": ["subject", "createdAt"], 465 + "properties": { 466 + "subject": { 467 + "type": "string", 468 + "format": "at-uri" 469 + }, 470 + "createdAt": { "type": "string", "format": "datetime" } 471 + } 472 + } 473 + } 474 + } 475 + } 476 + ``` 477 + 478 + **Generated GraphQL Types:** 479 + ```graphql 480 + type SocialGrainFavorite { 481 + uri: String! 482 + subject: String! # at-uri pointing to gallery 483 + createdAt: String! 484 + 485 + # Forward join (follows the subject field) 486 + socialGrainGallery: SocialGrainGallery 487 + } 488 + 489 + type SocialGrainGallery { 490 + uri: String! 491 + title: String 492 + 493 + # Auto-generated reverse joins (plural) 494 + # These find all records whose at-uri fields point here 495 + socialGrainFavorites: [SocialGrainFavorite!]! 496 + socialGrainComments: [SocialGrainComment!]! 497 + socialGrainGalleryItems: [SocialGrainGalleryItem!]! 498 + } 499 + ``` 500 + 501 + **Example Query:** 502 + ```graphql 503 + query { 504 + socialGrainGalleries(where: { 505 + actorHandle: { eq: "chadtmiller.com" } 506 + }) { 507 + edges { 508 + node { 509 + uri 510 + title 511 + # Get all favorites for this gallery 512 + socialGrainFavorites { 513 + uri 514 + createdAt 515 + actorHandle 516 + } 517 + # Get all comments for this gallery 518 + socialGrainComments { 519 + uri 520 + text 521 + actorHandle 522 + } 523 + } 524 + } 525 + } 526 + } 527 + ``` 528 + 529 + ### Count Fields 530 + 531 + For efficient counting without loading all data, use `*Count` fields: 532 + 533 + ```graphql 534 + query { 535 + socialGrainGalleries { 536 + edges { 537 + node { 538 + uri 539 + title 540 + # Efficient count queries (no data loading) 541 + socialGrainFavoritesCount 542 + socialGrainCommentsCount 543 + socialGrainPhotosCount 544 + } 545 + } 546 + } 547 + } 548 + ``` 549 + 550 + ### Combining Counts and Data 551 + 552 + Best practice: Get counts separately from limited data: 553 + 554 + ```graphql 555 + query { 556 + socialGrainGalleries { 557 + edges { 558 + node { 559 + uri 560 + title 561 + # Total count 562 + socialGrainFavoritesCount 563 + socialGrainCommentsCount 564 + 565 + # Show preview (first 3) 566 + socialGrainFavorites(limit: 3) { 567 + uri 568 + actorHandle 569 + } 570 + socialGrainComments(limit: 3) { 571 + uri 572 + text 573 + } 574 + } 575 + } 576 + } 577 + } 578 + ``` 579 + 580 + ## DataLoader & Performance 581 + 582 + The GraphQL API uses DataLoader for efficient batching: 583 + 584 + ### CollectionDidLoader 585 + - Batches queries by `(slice_uri, collection, did)` 586 + - Used for forward joins where the DID is known 587 + - Eliminates N+1 queries when following references 588 + 589 + ### CollectionUriLoader 590 + - Batches queries by `(slice_uri, collection, parent_uri, reference_field)` 591 + - Used for reverse joins based on at-uri fields 592 + - Efficiently loads all records that reference a parent URI 593 + - Supports multiple at-uri fields (tries each until match found) 594 + 595 + Example: Loading 100 galleries with favorites 596 + - **Without DataLoader**: 1 + 100 queries (N+1 problem) 597 + - **With DataLoader**: 1 + 1 query (batched) 598 + 599 + ## Complex Queries 600 + 601 + ### Nested Relationships 602 + 603 + ```graphql 604 + query { 605 + socialGrainGalleries { 606 + edges { 607 + node { 608 + title 609 + socialGrainGalleryItems { 610 + position 611 + socialGrainPhoto { 612 + uri 613 + alt 614 + photo { 615 + url(preset: "fullsize") 616 + } 617 + socialGrainPhotoExifs { 618 + fNumber 619 + iSO 620 + make 621 + model 622 + } 623 + } 624 + } 625 + } 626 + } 627 + } 628 + } 629 + ``` 630 + 631 + ### Full Example 632 + 633 + ```graphql 634 + query MyGrainGalleries { 635 + socialGrainGalleries( 636 + where: { actorHandle: { eq: "chadtmiller.com" } } 637 + orderBy: { field: "createdAt", direction: DESC } 638 + ) { 639 + edges { 640 + node { 641 + uri 642 + title 643 + description 644 + createdAt 645 + 646 + # Counts 647 + socialGrainFavoritesCount 648 + socialGrainCommentsCount 649 + 650 + # Preview data 651 + socialGrainFavorites(limit: 5) { 652 + uri 653 + createdAt 654 + actorHandle 655 + } 656 + 657 + socialGrainComments(limit: 3) { 658 + uri 659 + text 660 + createdAt 661 + actorHandle 662 + } 663 + 664 + # Gallery items with nested photos 665 + socialGrainGalleryItems { 666 + position 667 + socialGrainPhoto { 668 + uri 669 + alt 670 + photo { 671 + url(preset: "fullsize") 672 + } 673 + aspectRatio 674 + createdAt 675 + socialGrainPhotoExifs { 676 + fNumber 677 + iSO 678 + make 679 + model 680 + } 681 + } 682 + } 683 + } 684 + } 685 + pageInfo { 686 + hasNextPage 687 + endCursor 688 + } 689 + } 690 + } 691 + ``` 692 + 693 + ## Mutations 694 + 695 + **WIP** - Create, update, and delete operations for records. 696 + 697 + ## Subscriptions 698 + 699 + Real-time updates for record changes. Each collection has three subscription fields: 700 + 701 + ### Created Records 702 + 703 + Subscribe to newly created records: 704 + 705 + ```graphql 706 + subscription { 707 + socialGrainGalleryCreated { 708 + uri 709 + title 710 + description 711 + createdAt 712 + } 713 + } 714 + ``` 715 + 716 + ### Updated Records 717 + 718 + Subscribe to record updates: 719 + 720 + ```graphql 721 + subscription { 722 + socialGrainGalleryUpdated { 723 + uri 724 + title 725 + description 726 + updatedAt 727 + } 728 + } 729 + ``` 730 + 731 + ### Deleted Records 732 + 733 + Subscribe to record deletions (returns just the URI): 734 + 735 + ```graphql 736 + subscription { 737 + socialGrainGalleryDeleted 738 + } 739 + ``` 740 + 741 + ## Limits & Performance 742 + 743 + - **Depth Limit**: 50 (supports introspection with circular relationships) 744 + - **Complexity Limit**: 5000 (prevents expensive queries) 745 + - **Default Limit**: 50 records per query 746 + - **DataLoader**: Automatic batching eliminates N+1 queries 747 + 748 + ## Best Practices 749 + 750 + 1. **Use count fields** when you only need totals 751 + 2. **Limit nested data** with `limit` parameter 752 + 3. **Request only needed fields** (no over-fetching) 753 + 4. **Use cursors** for pagination, not offset 754 + 5. **Batch related queries** with DataLoader (automatic) 755 + 6. **Combine counts + limited data** for previews 756 + 757 + ## Error Handling 758 + 759 + GraphQL errors include: 760 + - `"Query is nested too deep"` - Exceeds depth limit (50) 761 + - `"Query is too complex"` - Exceeds complexity limit (5000) 762 + - `"Schema error"` - Invalid slice or missing lexicons
+21 -1
frontend/src/features/docs/handlers.tsx
··· 46 46 description: "Complete endpoint documentation", 47 47 }, 48 48 { 49 + slug: "graphql-api", 50 + title: "GraphQL API", 51 + description: "Query indexed data with GraphQL", 52 + }, 53 + { 49 54 slug: "sdk-usage", 50 55 title: "SDK Usage", 51 56 description: "Advanced client patterns and examples", ··· 80 85 } 81 86 } 82 87 88 + // Decode HTML entities 89 + function decodeHtmlEntities(text: string): string { 90 + const entities: Record<string, string> = { 91 + "&amp;": "&", 92 + "&lt;": "<", 93 + "&gt;": ">", 94 + "&quot;": '"', 95 + "&#39;": "'", 96 + "&nbsp;": " ", 97 + }; 98 + 99 + return text.replace(/&[^;]+;/g, (entity) => entities[entity] || entity); 100 + } 101 + 83 102 // Extract headers for table of contents 84 103 function extractHeaders(html: string) { 85 104 const headerRegex = /<h([1-6])[^>]*>(.*?)<\/h[1-6]>/g; ··· 88 107 let match; 89 108 while ((match = headerRegex.exec(html)) !== null) { 90 109 const level = parseInt(match[1]); 91 - const text = match[2].replace(/<[^>]+>/g, ""); // Strip HTML tags 110 + const rawText = match[2].replace(/<[^>]+>/g, ""); // Strip HTML tags 111 + const text = decodeHtmlEntities(rawText); // Decode HTML entities 92 112 const id = text 93 113 .toLowerCase() 94 114 .replace(/[^\w\s-]/g, "") // Remove special characters