Highly ambitious ATProtocol AppView service and sdks

add user sync collections method for an after first login sync use case, fix limit param in search and list record requests

+4 -4
api/scripts/codegen.sh
··· 13 13 echo "," >> "$temp_file" 14 14 fi 15 15 first=false 16 - 16 + 17 17 # Extract nsid and definitions from the lexicon file 18 18 nsid=$(jq -r '.id' "$lexicon_file") 19 19 definitions=$(jq '.defs' "$lexicon_file") 20 - 20 + 21 21 # Create the formatted lexicon object 22 22 echo " {" >> "$temp_file" 23 23 echo " \"nsid\": \"$nsid\"," >> "$temp_file" ··· 32 32 33 33 # Generate the TypeScript client 34 34 echo "Generating TypeScript client..." 35 - deno run --allow-all ./scripts/generate-typescript.ts "$(cat "$temp_file")" > ./generated_client.ts 35 + deno run --allow-all ./scripts/generate_typescript.ts "$(cat "$temp_file")" > ./generated_client.ts 36 36 37 37 # Clean up 38 38 rm "$temp_file" 39 39 40 - echo "✅ Generated TypeScript client at ./generated_client.ts" 40 + echo "✅ Generated TypeScript client at ./generated_client.ts"
+95 -9
api/scripts/generate_typescript.ts
··· 387 387 type: "JobStatus[]", 388 388 }); 389 389 390 + // Sync user collections interfaces 391 + sourceFile.addInterface({ 392 + name: "SyncUserCollectionsRequest", 393 + isExported: true, 394 + properties: [ 395 + { name: "slice", type: "string" }, 396 + { name: "timeoutSeconds", type: "number", hasQuestionToken: true }, 397 + ], 398 + }); 399 + 400 + sourceFile.addInterface({ 401 + name: "SyncUserCollectionsResult", 402 + isExported: true, 403 + properties: [ 404 + { name: "success", type: "boolean" }, 405 + { name: "reposProcessed", type: "number" }, 406 + { name: "recordsSynced", type: "number" }, 407 + { name: "timedOut", type: "boolean" }, 408 + { name: "message", type: "string" }, 409 + ], 410 + }); 411 + 390 412 sourceFile.addInterface({ 391 413 name: "JetstreamStatusResponse", 392 414 isExported: true, ··· 555 577 } 556 578 } 557 579 558 - // Convert NSID to PascalCase 580 + // Convert NSID to PascalCase (for record types, no "Record" suffix) 559 581 function nsidToPascalCase(nsid: string): string { 560 582 return ( 561 583 nsid ··· 566 588 .map((word) => word.charAt(0).toUpperCase() + word.slice(1)) 567 589 .join("") 568 590 ) 569 - .join("") + "Record" 591 + .join("") 570 592 ); 571 593 } 572 594 ··· 601 623 } else if (ref.includes("#")) { 602 624 // Cross-lexicon reference: app.bsky.embed.defs#aspectRatio -> AppBskyEmbedDefs["AspectRatio"] 603 625 const [nsid, defName] = ref.split("#"); 626 + 627 + if (defName === "main") { 628 + // Find the lexicon and check if it has multiple definitions 629 + const lexicon = lexicons.find(lex => lex.id === nsid); 630 + if (lexicon && lexicon.definitions) { 631 + const defCount = Object.keys(lexicon.definitions).length; 632 + const mainDef = lexicon.definitions.main; 633 + 634 + if (defCount === 1 && mainDef) { 635 + // Single definition - use clean name 636 + if (mainDef.type === "record") { 637 + // For records: AppBskyActorProfile 638 + return nsidToPascalCase(nsid); 639 + } else { 640 + // For objects: ComAtprotoRepoStrongRef 641 + return nsidToNamespace(nsid); 642 + } 643 + } else { 644 + // Multiple definitions - use namespace pattern 645 + const namespace = nsidToNamespace(nsid); 646 + return `${namespace}["Main"]`; 647 + } 648 + } 649 + // Fallback 650 + return nsidToNamespace(nsid); 651 + } 652 + 604 653 const namespace = nsidToNamespace(nsid); 605 654 return `${namespace}["${defNameToPascalCase(defName)}"]`; 606 655 } else { 607 - // Direct lexicon reference: app.bsky.richtext.facet -> AppBskyRichtextFacet["Main"] 608 - // This refers to the main definition of the lexicon 609 - const namespace = nsidToNamespace(ref); 610 - return `${namespace}["Main"]`; 656 + // Direct lexicon reference: check if single or multiple definitions 657 + const lexicon = lexicons.find(lex => lex.id === ref); 658 + if (lexicon && lexicon.definitions) { 659 + const defCount = Object.keys(lexicon.definitions).length; 660 + const mainDef = lexicon.definitions.main; 661 + 662 + if (defCount === 1 && mainDef) { 663 + // Single definition - use clean name 664 + if (mainDef.type === "record") { 665 + // For records: AppBskyActorProfile 666 + return nsidToPascalCase(ref); 667 + } else { 668 + // For objects: ComAtprotoRepoStrongRef 669 + return nsidToNamespace(ref); 670 + } 671 + } else if (mainDef) { 672 + // Multiple definitions - use namespace pattern 673 + const namespace = nsidToNamespace(ref); 674 + return `${namespace}["Main"]`; 675 + } 676 + } 677 + // Fallback 678 + return nsidToNamespace(ref); 611 679 } 612 680 } 613 681 ··· 653 721 defValue: any 654 722 ): void { 655 723 const namespace = nsidToNamespace(lexicon.id); 656 - const interfaceName = defNameToPascalCase(defKey); 724 + 725 + // For single-definition lexicons with main, use clean name 726 + const defCount = Object.keys(lexicon.definitions).length; 727 + const interfaceName = (defKey === "main" && defCount === 1) 728 + ? namespace // Clean name: ComAtprotoRepoStrongRef 729 + : `${namespace}${defNameToPascalCase(defKey)}`; // Multi-def: AppBskyRichtextFacetMention 657 730 658 731 const properties: Array<{ 659 732 name: string; ··· 678 751 } 679 752 680 753 sourceFile.addInterface({ 681 - name: `${namespace}${interfaceName}`, 754 + name: interfaceName, 682 755 isExported: true, 683 756 properties: properties, 684 757 }); ··· 718 791 } 719 792 } 720 793 721 - // For main records, use the traditional Record suffix naming 794 + // For main records, use clean naming without Record suffix 722 795 const interfaceName = 723 796 defKey === "main" 724 797 ? nsidToPascalCase(lexicon.id) ··· 1400 1473 isAsync: true, 1401 1474 statements: [ 1402 1475 `return await this.makeRequest<JetstreamStatusResponse>('social.slices.slice.getJetstreamStatus', 'GET');`, 1476 + ], 1477 + }); 1478 + 1479 + classDeclaration.addMethod({ 1480 + name: "syncUserCollections", 1481 + parameters: [ 1482 + { name: "params", type: "SyncUserCollectionsRequest", hasQuestionToken: true }, 1483 + ], 1484 + returnType: "Promise<SyncUserCollectionsResult>", 1485 + isAsync: true, 1486 + statements: [ 1487 + `const requestParams = { slice: this.sliceUri, ...params };`, 1488 + `return await this.makeRequest<SyncUserCollectionsResult>('social.slices.slice.syncUserCollections', 'POST', requestParams);`, 1403 1489 ], 1404 1490 }); 1405 1491 }
+49 -38
api/src/database.rs
··· 4 4 use crate::errors::DatabaseError; 5 5 use crate::models::{Actor, CollectionStats, IndexedRecord, Record}; 6 6 7 - // Helper function to get field type from lexicon definition 7 + // Helper function to get field type from lexicon definition 8 8 async fn get_field_type_from_lexicon( 9 - pool: &sqlx::PgPool, 10 - slice_uri: &str, 11 - collection: &str, 9 + pool: &sqlx::PgPool, 10 + slice_uri: &str, 11 + collection: &str, 12 12 field: &str 13 13 ) -> Option<String> { 14 14 let lexicon_query = sqlx::query!( 15 15 r#" 16 16 SELECT json->>'definitions' as definitions 17 - FROM record 17 + FROM record 18 18 WHERE collection = 'social.slices.lexicon' 19 - AND json->>'slice' = $1 19 + AND json->>'slice' = $1 20 20 AND json->>'nsid' = $2 21 21 AND (json->>'definitions')::jsonb->'main'->>'type' = 'record' 22 22 LIMIT 1 ··· 24 24 slice_uri, 25 25 collection 26 26 ); 27 - 27 + 28 28 if let Ok(Some(row)) = lexicon_query.fetch_optional(pool).await { 29 29 if let Some(definitions_str) = &row.definitions { 30 30 if let Ok(definitions) = serde_json::from_str::<serde_json::Value>(definitions_str) { ··· 33 33 .get("record")? 34 34 .get("properties")? 35 35 .get(field)?; 36 - 36 + 37 37 // Check if it's a datetime field 38 38 if let Some(field_type) = field_def.get("type").and_then(|t| t.as_str()) { 39 39 if field_type == "string" { ··· 54 54 // Async helper function to parse sort parameter with lexicon type information 55 55 async fn parse_sort_parameter_with_lexicon( 56 56 pool: &sqlx::PgPool, 57 - slice_uri: &str, 57 + slice_uri: &str, 58 58 collection: &str, 59 59 sort: Option<&str> 60 60 ) -> String { ··· 69 69 "desc" => "DESC", 70 70 _ => "ASC", // Default to ASC for any invalid direction 71 71 }; 72 - 72 + 73 73 // Validate field name to prevent SQL injection 74 74 if field.chars().all(|c| c.is_alphanumeric() || c == '_') { 75 75 if field == "indexed_at" { ··· 77 77 } else { 78 78 // Get field type from lexicon 79 79 let field_type = get_field_type_from_lexicon(pool, slice_uri, collection, field).await; 80 - 80 + 81 81 if field_type == Some("datetime".to_string()) { 82 82 // For datetime fields, use safe casting that handles invalid dates 83 83 // This will cast valid dates and return NULL for invalid ones ··· 118 118 "desc" => "DESC", 119 119 _ => "ASC", // Default to ASC for any invalid direction 120 120 }; 121 - 121 + 122 122 // Validate field name to prevent SQL injection 123 123 if field.chars().all(|c| c.is_alphanumeric() || c == '_') { 124 124 if field == "indexed_at" { ··· 162 162 // Fallback to plain text for backward compatibility 163 163 cursor.to_string() 164 164 }; 165 - 165 + 166 166 let parts: Vec<&str> = cursor_content.split("::").collect(); 167 167 if parts.len() != 3 { 168 168 return Err("Invalid cursor format".into()); 169 169 } 170 - 170 + 171 171 let sort_value = parts[0].to_string(); 172 172 let indexed_at = parts[1].parse::<chrono::DateTime<chrono::Utc>>()?; 173 173 let cid = parts[2].to_string(); 174 - 174 + 175 175 Ok(ParsedCursor { 176 176 sort_value, 177 177 indexed_at, ··· 218 218 // For compound cursor filtering, we use tuple comparison: 219 219 // WHERE (sort_field, indexed_at, cid) < (cursor_sort, cursor_indexed_at, cursor_cid) for DESC 220 220 // WHERE (sort_field, indexed_at, cid) > (cursor_sort, cursor_indexed_at, cursor_cid) for ASC 221 - 221 + 222 222 let comparison_op = if is_desc { "<" } else { ">" }; 223 - 223 + 224 224 // Handle different field types for the sort field comparison 225 225 let sort_field_expr = if sort_field == "indexed_at" { 226 226 sort_field.to_string() ··· 228 228 // For JSON fields, cast to text for comparison 229 229 format!("json->>'{}'", sort_field) 230 230 }; 231 - 231 + 232 232 // Handle NULL values in cursor comparison 233 233 if comparison_op == "<" { 234 234 // For DESC ordering, we want records where: ··· 250 250 // Generate cursor from record and sort parameters 251 251 fn generate_cursor_from_record(record: &Record, sort: Option<&str>) -> String { 252 252 let primary_sort_field = get_primary_sort_field(sort); 253 - 253 + 254 254 // Extract sort value from the record based on the sort field 255 255 let sort_value = match primary_sort_field.as_str() { 256 256 "indexed_at" => record.indexed_at.to_rfc3339(), ··· 267 267 .unwrap_or_else(|| "NULL".to_string()) // Use "NULL" string for null values to match SQL NULLS LAST behavior 268 268 } 269 269 }; 270 - 270 + 271 271 generate_cursor(&sort_value, record.indexed_at, &record.cid) 272 272 } 273 273 ··· 339 339 Ok(()) 340 340 } 341 341 342 - pub async fn get_existing_record_cids(&self, did: &str, collection: &str) -> Result<std::collections::HashMap<String, String>, DatabaseError> { 342 + pub async fn get_existing_record_cids_for_slice(&self, did: &str, collection: &str, slice_uri: &str) -> Result<std::collections::HashMap<String, String>, DatabaseError> { 343 343 let records = sqlx::query!( 344 344 r#"SELECT "uri", "cid" 345 345 FROM "record" 346 - WHERE "did" = $1 AND "collection" = $2"#, 346 + WHERE "did" = $1 AND "collection" = $2 AND "slice_uri" = $3"#, 347 347 did, 348 - collection 348 + collection, 349 + slice_uri 349 350 ) 350 351 .fetch_all(&self.pool) 351 352 .await?; ··· 489 490 COUNT(DISTINCT r.did) as unique_actors 490 491 FROM record r 491 492 INNER JOIN slice_collections sc ON r.collection = sc.collection_nsid 493 + WHERE r.slice_uri = $1 492 494 GROUP BY r.collection 493 495 ORDER BY r.collection 494 496 "#, ··· 540 542 SELECT COUNT(*) as count 541 543 FROM record r 542 544 INNER JOIN slice_collections sc ON r.collection = sc.collection_nsid 545 + WHERE r.slice_uri = $1 543 546 "#, 544 547 slice_uri 545 548 ) ··· 710 713 // For lexicon collection, we filter by slice directly 711 714 if collection == "social.slices.lexicon" { 712 715 let order_by = parse_sort_parameter(sort); // Use simple parsing for lexicon collections 713 - 716 + 714 717 // Note: For lexicon searches, authors filtering is not commonly used as lexicons are typically not user-specific 715 718 // But we include it for completeness 716 719 let records = match (cursor, field) { ··· 720 723 let primary_sort_field = get_primary_sort_field(sort); 721 724 let is_desc = is_primary_sort_desc(sort); 722 725 let cursor_where_clause = build_cursor_where_clause(&parsed_cursor, &primary_sort_field, is_desc); 723 - 726 + 724 727 let query_sql = format!( 725 728 "SELECT uri, cid, did, collection, json, indexed_at, slice_uri FROM record WHERE collection = $4 AND json->>'slice' = $5 AND json->>$6 ILIKE '%' || $7 || '%' {} ORDER BY {} LIMIT $8", 726 729 cursor_where_clause, order_by ··· 837 840 let cursor_dt = cursor_time.parse::<chrono::DateTime<chrono::Utc>>() 838 841 .unwrap_or_else(|_| chrono::Utc::now()); 839 842 let query_sql = format!( 840 - "SELECT uri, cid, did, collection, json, indexed_at, slice_uri FROM record WHERE collection = $1 AND json->>$2 ILIKE '%' || $3 || '%' AND indexed_at < $4 ORDER BY {} LIMIT $5", 843 + "SELECT uri, cid, did, collection, json, indexed_at, slice_uri FROM record WHERE collection = $1 AND slice_uri = $2 AND json->>$3 ILIKE '%' || $4 || '%' AND indexed_at < $5 ORDER BY {} LIMIT $6", 841 844 order_by 842 845 ); 843 846 sqlx::query_as::<_, Record>(&query_sql) 844 847 .bind(collection) 848 + .bind(slice_uri) 845 849 .bind(field_name) 846 850 .bind(query) 847 851 .bind(cursor_dt) ··· 853 857 let cursor_dt = cursor_time.parse::<chrono::DateTime<chrono::Utc>>() 854 858 .unwrap_or_else(|_| chrono::Utc::now()); 855 859 let query_sql = format!( 856 - "SELECT uri, cid, did, collection, json, indexed_at, slice_uri FROM record WHERE collection = $1 AND json::text ILIKE '%' || $2 || '%' AND indexed_at < $3 ORDER BY {} LIMIT $4", 860 + "SELECT uri, cid, did, collection, json, indexed_at, slice_uri FROM record WHERE collection = $1 AND slice_uri = $2 AND json::text ILIKE '%' || $3 || '%' AND indexed_at < $4 ORDER BY {} LIMIT $5", 857 861 order_by 858 862 ); 859 863 sqlx::query_as::<_, Record>(&query_sql) 860 864 .bind(collection) 865 + .bind(slice_uri) 861 866 .bind(query) 862 867 .bind(cursor_dt) 863 868 .bind(limit as i64) ··· 866 871 }, 867 872 (None, Some(field_name)) => { 868 873 let query_sql = format!( 869 - "SELECT uri, cid, did, collection, json, indexed_at, slice_uri FROM record WHERE collection = $1 AND json->>$2 ILIKE '%' || $3 || '%' ORDER BY {} LIMIT $4", 874 + "SELECT uri, cid, did, collection, json, indexed_at, slice_uri FROM record WHERE collection = $1 AND slice_uri = $2 AND json->>$3 ILIKE '%' || $4 || '%' ORDER BY {} LIMIT $5", 870 875 order_by 871 876 ); 872 877 sqlx::query_as::<_, Record>(&query_sql) 873 878 .bind(collection) 879 + .bind(slice_uri) 874 880 .bind(field_name) 875 881 .bind(query) 876 882 .bind(limit as i64) ··· 879 885 }, 880 886 (None, None) => { 881 887 let query_sql = format!( 882 - "SELECT uri, cid, did, collection, json, indexed_at, slice_uri FROM record WHERE collection = $1 AND json::text ILIKE '%' || $2 || '%' ORDER BY {} LIMIT $3", 888 + "SELECT uri, cid, did, collection, json, indexed_at, slice_uri FROM record WHERE collection = $1 AND slice_uri = $2 AND json::text ILIKE '%' || $3 || '%' ORDER BY {} LIMIT $4", 883 889 order_by 884 890 ); 885 891 sqlx::query_as::<_, Record>(&query_sql) 886 892 .bind(collection) 893 + .bind(slice_uri) 887 894 .bind(query) 888 895 .bind(limit as i64) 889 896 .fetch_all(&self.pool) ··· 916 923 // For lexicon collection, we filter by slice directly 917 924 if collection == "social.slices.lexicon" { 918 925 let order_by = parse_sort_parameter(sort); // Use simple parsing for lexicon collections 919 - 920 - 926 + 927 + 921 928 // Determine the author list to use 922 929 let author_list: Option<Vec<String>> = if let Some(authors_list) = authors { 923 930 Some(authors_list.clone()) ··· 1055 1062 let cursor_dt = cursor_time.parse::<chrono::DateTime<chrono::Utc>>() 1056 1063 .unwrap_or_else(|_| chrono::Utc::now()); 1057 1064 let query = format!( 1058 - "SELECT uri, cid, did, collection, json, indexed_at, slice_uri FROM record WHERE collection = $1 AND indexed_at < $2 AND did = ANY($3) ORDER BY {} LIMIT $4", 1065 + "SELECT uri, cid, did, collection, json, indexed_at, slice_uri FROM record WHERE collection = $1 AND slice_uri = $2 AND indexed_at < $3 AND did = ANY($4) ORDER BY {} LIMIT $5", 1059 1066 order_by 1060 1067 ); 1061 1068 sqlx::query_as::<_, Record>(&query) 1062 1069 .bind(collection) 1070 + .bind(slice_uri) 1063 1071 .bind(cursor_dt) 1064 1072 .bind(author_list) 1065 1073 .bind(limit as i64) ··· 1070 1078 let cursor_dt = cursor_time.parse::<chrono::DateTime<chrono::Utc>>() 1071 1079 .unwrap_or_else(|_| chrono::Utc::now()); 1072 1080 let query = format!( 1073 - "SELECT uri, cid, did, collection, json, indexed_at, slice_uri FROM record WHERE collection = $1 AND indexed_at < $2 ORDER BY {} LIMIT $3", 1081 + "SELECT uri, cid, did, collection, json, indexed_at, slice_uri FROM record WHERE collection = $1 AND slice_uri = $2 AND indexed_at < $3 ORDER BY {} LIMIT $4", 1074 1082 order_by 1075 1083 ); 1076 1084 sqlx::query_as::<_, Record>(&query) 1077 1085 .bind(collection) 1086 + .bind(slice_uri) 1078 1087 .bind(cursor_dt) 1079 1088 .bind(limit as i64) 1080 1089 .fetch_all(&self.pool) ··· 1082 1091 }, 1083 1092 (None, Some(author_list)) => { 1084 1093 let query = format!( 1085 - "SELECT uri, cid, did, collection, json, indexed_at, slice_uri FROM record WHERE collection = $1 AND did = ANY($2) ORDER BY {} LIMIT $3", 1094 + "SELECT uri, cid, did, collection, json, indexed_at, slice_uri FROM record WHERE collection = $1 AND slice_uri = $2 AND did = ANY($3) ORDER BY {} LIMIT $4", 1086 1095 order_by 1087 1096 ); 1088 1097 sqlx::query_as::<_, Record>(&query) 1089 1098 .bind(collection) 1099 + .bind(slice_uri) 1090 1100 .bind(author_list) 1091 1101 .bind(limit as i64) 1092 1102 .fetch_all(&self.pool) ··· 1094 1104 }, 1095 1105 (None, None) => { 1096 1106 let query = format!( 1097 - "SELECT uri, cid, did, collection, json, indexed_at, slice_uri FROM record WHERE collection = $1 ORDER BY {} LIMIT $2", 1107 + "SELECT uri, cid, did, collection, json, indexed_at, slice_uri FROM record WHERE collection = $1 AND slice_uri = $2 ORDER BY {} LIMIT $3", 1098 1108 order_by 1099 1109 ); 1100 1110 sqlx::query_as::<_, Record>(&query) 1101 1111 .bind(collection) 1112 + .bind(slice_uri) 1102 1113 .bind(limit as i64) 1103 1114 .fetch_all(&self.pool) 1104 1115 .await? ··· 1155 1166 "#) 1156 1167 .fetch_all(&self.pool) 1157 1168 .await?; 1158 - 1169 + 1159 1170 Ok(rows.into_iter().map(|(uri,)| uri).collect()) 1160 1171 } 1161 1172 ··· 1169 1180 ) 1170 1181 .fetch_all(&self.pool) 1171 1182 .await?; 1172 - 1183 + 1173 1184 Ok(rows.into_iter().map(|row| (row.did, row.slice_uri)).collect()) 1174 1185 } 1175 1186 ··· 1185 1196 ) 1186 1197 .fetch_optional(&self.pool) 1187 1198 .await?; 1188 - 1199 + 1189 1200 Ok(row.and_then(|r| r.domain)) 1190 1201 } 1191 1202
+95
api/src/handler_sync_user_collections.rs
··· 1 + use axum::{ 2 + extract::State, 3 + http::{HeaderMap, StatusCode}, 4 + response::Json, 5 + }; 6 + use serde::Deserialize; 7 + use tracing::{info, warn}; 8 + 9 + use crate::auth::{extract_bearer_token, verify_oauth_token}; 10 + use crate::sync::{SyncService, SyncUserCollectionsResult}; 11 + use crate::AppState; 12 + 13 + #[derive(Deserialize)] 14 + #[serde(rename_all = "camelCase")] 15 + pub struct SyncUserCollectionsRequest { 16 + pub slice: String, 17 + #[serde(default = "default_timeout")] 18 + pub timeout_seconds: u64, 19 + } 20 + 21 + fn default_timeout() -> u64 { 22 + 30 // 30 second default timeout for login scenarios 23 + } 24 + 25 + /// Handler for social.slices.slice.syncUserCollections 26 + /// Synchronously syncs external collections for the authenticated user with timeout protection 27 + /// Automatically discovers external collections based on the slice's domain configuration 28 + pub async fn sync_user_collections( 29 + State(state): State<AppState>, 30 + headers: HeaderMap, 31 + Json(request): Json<SyncUserCollectionsRequest>, 32 + ) -> Result<Json<SyncUserCollectionsResult>, (StatusCode, Json<serde_json::Value>)> { 33 + // Extract and verify OAuth token 34 + let token = extract_bearer_token(&headers).map_err(|e| { 35 + (StatusCode::UNAUTHORIZED, Json(serde_json::json!({ 36 + "error": "AuthenticationRequired", 37 + "message": format!("Bearer token required: {}", e) 38 + }))) 39 + })?; 40 + 41 + let user_info = verify_oauth_token(&token, &state.config.auth_base_url).await 42 + .map_err(|e| { 43 + (StatusCode::UNAUTHORIZED, Json(serde_json::json!({ 44 + "error": "InvalidToken", 45 + "message": format!("Token verification failed: {}", e) 46 + }))) 47 + })?; 48 + 49 + let user_did = user_info.did.unwrap_or(user_info.sub); 50 + 51 + info!( 52 + "🔄 Starting user collections sync for {} on slice {} (timeout: {}s)", 53 + user_did, request.slice, request.timeout_seconds 54 + ); 55 + 56 + // Validate timeout (max 5 minutes for sync operations) 57 + if request.timeout_seconds > 300 { 58 + return Err((StatusCode::BAD_REQUEST, Json(serde_json::json!({ 59 + "error": "InvalidTimeout", 60 + "message": "Maximum timeout is 300 seconds (5 minutes)" 61 + })))); 62 + } 63 + 64 + // Create sync service 65 + let sync_service = SyncService::new(state.database.clone(), state.config.relay_endpoint.clone()); 66 + 67 + // Perform timeout-protected sync with auto-discovered external collections 68 + match sync_service.sync_user_collections( 69 + &user_did, 70 + &request.slice, 71 + request.timeout_seconds, 72 + ).await { 73 + Ok(result) => { 74 + if result.timed_out { 75 + info!( 76 + "⏰ Sync timed out for user {}, suggesting async job", 77 + user_did 78 + ); 79 + } else { 80 + info!( 81 + "✅ Sync completed for user {}: {} repos, {} records", 82 + user_did, result.repos_processed, result.records_synced 83 + ); 84 + } 85 + Ok(Json(result)) 86 + }, 87 + Err(e) => { 88 + warn!("❌ Sync failed for user {}: {}", user_did, e); 89 + Err((StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({ 90 + "error": "SyncFailed", 91 + "message": format!("Sync operation failed: {}", e) 92 + })))) 93 + } 94 + } 95 + }
+22 -3
api/src/handler_xrpc_dynamic.rs
··· 132 132 let mut manual_params = DynamicListParams { 133 133 author: params.get("author").and_then(|v| v.as_str()).map(|s| s.to_string()), 134 134 authors: None, 135 - limit: params.get("limit").and_then(|v| v.as_i64()).map(|i| i as i32), 135 + limit: params.get("limit").and_then(|v| { 136 + if let Some(s) = v.as_str() { 137 + s.parse::<i32>().ok() 138 + } else { 139 + v.as_i64().map(|i| i as i32) 140 + } 141 + }), 136 142 cursor: params.get("cursor").and_then(|v| v.as_str()).map(|s| s.to_string()), 137 143 slice: params.get("slice").and_then(|v| v.as_str()).ok_or(StatusCode::BAD_REQUEST)?.to_string(), 138 144 sort: params.get("sort").and_then(|v| v.as_str()).map(|s| s.to_string()), ··· 217 223 state: AppState, 218 224 params: serde_json::Value, 219 225 ) -> Result<Json<serde_json::Value>, StatusCode> { 220 - let search_params: DynamicSearchParams = serde_json::from_value(params) 221 - .map_err(|_| StatusCode::BAD_REQUEST)?; 226 + // Manual parameter extraction to handle string/number conversion 227 + let search_params = DynamicSearchParams { 228 + slice: params.get("slice").and_then(|v| v.as_str()).ok_or(StatusCode::BAD_REQUEST)?.to_string(), 229 + query: params.get("query").and_then(|v| v.as_str()).ok_or(StatusCode::BAD_REQUEST)?.to_string(), 230 + field: params.get("field").and_then(|v| v.as_str()).map(|s| s.to_string()), 231 + limit: params.get("limit").and_then(|v| { 232 + if let Some(s) = v.as_str() { 233 + s.parse::<i32>().ok() 234 + } else { 235 + v.as_i64().map(|i| i as i32) 236 + } 237 + }), 238 + cursor: params.get("cursor").and_then(|v| v.as_str()).map(|s| s.to_string()), 239 + sort: params.get("sort").and_then(|v| v.as_str()).map(|s| s.to_string()), 240 + }; 222 241 223 242 // Use slice-aware search method that filters by collection belonging to the slice 224 243 match state.database.search_slice_collection_records(
+5
api/src/main.rs
··· 9 9 mod handler_records; 10 10 mod handler_stats; 11 11 mod handler_sync; 12 + mod handler_sync_user_collections; 12 13 mod handler_upload_blob; 13 14 mod handler_xrpc_codegen; 14 15 mod handler_xrpc_dynamic; ··· 170 171 .route( 171 172 "/xrpc/social.slices.slice.startSync", 172 173 post(handler_sync::sync), 174 + ) 175 + .route( 176 + "/xrpc/social.slices.slice.syncUserCollections", 177 + post(handler_sync_user_collections::sync_user_collections), 173 178 ) 174 179 .route( 175 180 "/xrpc/social.slices.slice.getJobStatus",
+102 -3
api/src/sync.rs
··· 1 1 use chrono::{Utc}; 2 2 use reqwest::Client; 3 - use serde::Deserialize; 3 + use serde::{Deserialize, Serialize}; 4 4 use serde_json::Value; 5 + use tokio::time::{timeout, Duration}; 5 6 use tracing::{debug, error, info, warn}; 6 7 use atproto_identity::{ 7 8 plc::query as plc_query, ··· 44 45 did: String, 45 46 pds: String, 46 47 handle: Option<String>, 48 + } 49 + 50 + #[derive(Debug, Serialize)] 51 + #[serde(rename_all = "camelCase")] 52 + pub struct SyncUserCollectionsResult { 53 + pub success: bool, 54 + pub repos_processed: i64, 55 + pub records_synced: i64, 56 + pub timed_out: bool, 57 + pub message: String, 47 58 } 48 59 49 60 #[derive(Clone)] ··· 242 253 } 243 254 244 255 async fn fetch_records_for_repo_collection(&self, repo: &str, collection: &str, pds_url: &str, slice_uri: &str) -> Result<Vec<Record>, SyncError> { 245 - // First, get existing record CIDs from database 246 - let existing_cids = self.database.get_existing_record_cids(repo, collection) 256 + // First, get existing record CIDs from database for this specific slice 257 + let existing_cids = self.database.get_existing_record_cids_for_slice(repo, collection, slice_uri) 247 258 .await 248 259 .map_err(|e| SyncError::Generic(format!("Failed to get existing CIDs: {}", e)))?; 249 260 ··· 449 460 let mut cache = self.atp_cache.lock().unwrap(); 450 461 cache.clear(); 451 462 } 463 + 464 + /// Get external collections for a slice (collections that don't start with the slice's domain) 465 + async fn get_external_collections_for_slice(&self, slice_uri: &str) -> Result<Vec<String>, SyncError> { 466 + // Get the slice's domain 467 + let domain = self.database.get_slice_domain(slice_uri).await 468 + .map_err(|e| SyncError::Generic(format!("Failed to get slice domain: {}", e)))? 469 + .ok_or_else(|| SyncError::Generic(format!("Slice not found: {}", slice_uri)))?; 470 + 471 + // Get all collections (lexicons) for this slice 472 + let collections = self.database.get_slice_collections_list(slice_uri).await 473 + .map_err(|e| SyncError::Generic(format!("Failed to get slice collections: {}", e)))?; 474 + 475 + // Filter for external collections (those that don't start with the slice domain) 476 + let external_collections: Vec<String> = collections 477 + .into_iter() 478 + .filter(|collection| !collection.starts_with(&domain)) 479 + .collect(); 480 + 481 + info!("🔍 Found {} external collections for slice {} (domain: {}): {:?}", 482 + external_collections.len(), slice_uri, domain, external_collections); 483 + 484 + Ok(external_collections) 485 + } 486 + 487 + /// Sync user's data for all external collections defined in the slice 488 + /// Automatically discovers which collections to sync based on slice configuration 489 + /// Uses timeout protection to ensure responsive login flows 490 + pub async fn sync_user_collections( 491 + &self, 492 + user_did: &str, 493 + slice_uri: &str, 494 + timeout_secs: u64, 495 + ) -> Result<SyncUserCollectionsResult, SyncError> { 496 + info!("🔎 Auto-discovering external collections for user {} in slice {}", user_did, slice_uri); 497 + 498 + // Auto-discover external collections from slice configuration 499 + let external_collections = self.get_external_collections_for_slice(slice_uri).await?; 500 + 501 + if external_collections.is_empty() { 502 + info!("ℹ️ No external collections found for slice {}", slice_uri); 503 + return Ok(SyncUserCollectionsResult { 504 + success: true, 505 + repos_processed: 0, 506 + records_synced: 0, 507 + timed_out: false, 508 + message: "No external collections to sync".to_string(), 509 + }); 510 + } 511 + 512 + info!("📋 Syncing {} external collections for user {}: {:?}", 513 + external_collections.len(), user_did, external_collections); 514 + 515 + // Use backfill_collections with timeout protection, but only for this specific user 516 + let sync_future = async { 517 + self.backfill_collections( 518 + slice_uri, 519 + None, // No primary collections for user sync 520 + Some(&external_collections), 521 + Some(&[user_did.to_string()]), // Only sync this user's repos 522 + ).await 523 + }; 524 + 525 + match timeout(Duration::from_secs(timeout_secs), sync_future).await { 526 + Ok(result) => { 527 + let (repos_processed, records_synced) = result?; 528 + info!("✅ User sync completed within timeout: {} repos, {} records", repos_processed, records_synced); 529 + Ok(SyncUserCollectionsResult { 530 + success: true, 531 + repos_processed, 532 + records_synced, 533 + timed_out: false, 534 + message: format!("Sync completed: {} repos, {} records", repos_processed, records_synced), 535 + }) 536 + }, 537 + Err(_) => { 538 + // Timeout occurred - return partial success with guidance 539 + warn!("⏰ Sync for user {} timed out after {}s, suggest using async job", user_did, timeout_secs); 540 + Ok(SyncUserCollectionsResult { 541 + success: false, 542 + repos_processed: 0, 543 + records_synced: 0, 544 + timed_out: true, 545 + message: format!("Sync timed out after {}s - use startSync endpoint for larger syncs", timeout_secs), 546 + }) 547 + } 548 + } 549 + } 550 + 452 551 }
+402 -149
frontend/src/client.ts
··· 1 1 // Generated TypeScript client for AT Protocol records 2 - // Generated at: 2025-08-29 21:39:46 UTC 3 - // Lexicons: 3 2 + // Generated at: 2025-08-30 17:33:35 UTC 3 + // Lexicons: 6 4 4 5 5 /** 6 6 * @example Usage ··· 9 9 * 10 10 * const client = new AtProtoClient( 11 11 * 'https://slices-api.fly.dev', 12 - * 'at://did:plc:bcgltzqazw5tb6k2g3ttenbj/social.slices.slice/3lx5zq4t56s2q' 12 + * 'at://did:plc:bcgltzqazw5tb6k2g3ttenbj/social.slices.slice/3lwzmbjpqxk2q' 13 13 * ); 14 14 * 15 - * // List records from a slice 16 - * const slices = await client.social.slices.slice.listRecords(); 15 + * // List records from the app.bsky.actor.profile collection 16 + * const records = await client.app.bsky.actor.profile.listRecords(); 17 + * 18 + * // Get a specific record 19 + * const record = await client.app.bsky.actor.profile.getRecord({ 20 + * uri: 'at://did:plc:example/app.bsky.actor.profile/3abc123' 21 + * }); 22 + * 23 + * // Search records in the collection 24 + * const searchResults = await client.app.bsky.actor.profile.searchRecords({ 25 + * query: "example search term" 26 + * }); 17 27 * 18 - * // Get slice statistics 19 - * const stats = await client.social.slices.slice.stats({ 20 - * slice: 'at://did:plc:bcgltzqazw5tb6k2g3ttenbj/social.slices.slice/3lx5zq4t56s2q' 28 + * // Search specific field 29 + * const fieldSearch = await client.app.bsky.actor.profile.searchRecords({ 30 + * query: "blog", 31 + * field: "title" 21 32 * }); 22 33 * 23 - * // Serve the slice names as JSON 24 - * Deno.serve(async () => new Response(JSON.stringify(slices.records.map(r => r.value.name)))); 34 + * // Serve the records as JSON 35 + * Deno.serve(async () => new Response(JSON.stringify(records.records.map(r => r.value)))); 25 36 * ``` 26 37 */ 27 38 ··· 156 167 157 168 export type GetJobHistoryResponse = JobStatus[]; 158 169 170 + export interface SyncUserCollectionsRequest { 171 + slice: string; 172 + timeoutSeconds?: number; 173 + } 174 + 175 + export interface SyncUserCollectionsResult { 176 + success: boolean; 177 + reposProcessed: number; 178 + recordsSynced: number; 179 + timedOut: boolean; 180 + message: string; 181 + } 182 + 159 183 export interface JetstreamStatusResponse { 160 184 connected: boolean; 161 185 status: string; ··· 219 243 searchRecords(params: SearchRecordsParams): Promise<ListRecordsResponse<T>>; 220 244 } 221 245 222 - export interface SocialSlicesSliceRecord { 223 - /** Name of the slice */ 246 + export interface ComAtprotoLabelDefsLabel { 247 + /** Optionally, CID specifying the specific version of 'uri' resource this label applies to. */ 248 + cid?: string; 249 + /** Timestamp when this label was created. */ 250 + cts: string; 251 + /** Timestamp at which this label expires (no longer applies). */ 252 + exp?: string; 253 + /** If true, this is a negation label, overwriting a previous label. */ 254 + neg?: boolean; 255 + /** Signature of dag-cbor encoded label. */ 256 + sig?: string; 257 + /** DID of the actor who created this label. */ 258 + src: string; 259 + /** AT URI of the record, repository (account), or other resource that this label applies to. */ 260 + uri: string; 261 + /** The short string name of the value or type of this label. */ 262 + val: string; 263 + /** The AT Protocol version of the label object. */ 264 + ver?: number; 265 + } 266 + 267 + export interface ComAtprotoLabelDefsLabelValueDefinition { 268 + /** Does the user need to have adult content enabled in order to configure this label? */ 269 + adultOnly?: boolean; 270 + /** What should this label hide in the UI, if applied? 'content' hides all of the target; 'media' hides the images/video/audio; 'none' hides nothing. */ 271 + blurs: string; 272 + /** The default setting for this label. */ 273 + defaultSetting?: string; 274 + /** The value of the label being defined. Must only include lowercase ascii and the '-' character ([a-z-]+). */ 275 + identifier: string; 276 + locales: ComAtprotoLabelDefs["LabelValueDefinitionStrings"][]; 277 + /** How should a client visually convey this label? 'inform' means neutral and informational; 'alert' means negative and warning; 'none' means show nothing. */ 278 + severity: string; 279 + } 280 + 281 + export interface ComAtprotoLabelDefsLabelValueDefinitionStrings { 282 + /** A longer description of what the label means and why it might be applied. */ 283 + description: string; 284 + /** The code of the language these strings are written in. */ 285 + lang: string; 286 + /** A short human-readable name for the label. */ 224 287 name: string; 225 - /** Primary domain namespace for this slice (e.g. social.grain) */ 226 - domain: string; 227 - /** When the slice was created */ 228 - createdAt: string; 229 288 } 230 289 231 - export type SocialSlicesSliceRecordSortFields = "name" | "domain" | "createdAt"; 290 + export interface ComAtprotoLabelDefsSelfLabel { 291 + /** The short string name of the value or type of this label. */ 292 + val: string; 293 + } 232 294 233 - export interface SocialSlicesLexiconRecord { 234 - /** Namespaced identifier for the lexicon */ 235 - nsid: string; 236 - /** The lexicon schema definitions as JSON */ 237 - definitions: string; 238 - /** When the lexicon was created */ 239 - createdAt: string; 240 - /** When the lexicon was last updated */ 241 - updatedAt?: string; 242 - /** AT-URI reference to the slice this lexicon belongs to */ 243 - slice: string; 295 + export interface ComAtprotoLabelDefsSelfLabels { 296 + values: ComAtprotoLabelDefs["SelfLabel"][]; 244 297 } 245 298 246 - export type SocialSlicesLexiconRecordSortFields = 247 - | "nsid" 248 - | "definitions" 249 - | "createdAt" 250 - | "updatedAt" 251 - | "slice"; 299 + export interface ComAtprotoRepoStrongRef { 300 + cid: string; 301 + uri: string; 302 + } 252 303 253 - export interface SocialSlicesActorProfileRecord { 254 - displayName?: string; 304 + export interface AppBskyActorProfile { 305 + /** Small image to be displayed next to posts from account. AKA, 'profile picture' */ 306 + avatar?: BlobRef; 307 + /** Larger horizontal image to display behind profile view. */ 308 + banner?: BlobRef; 309 + createdAt?: string; 255 310 /** Free-form profile description text. */ 256 311 description?: string; 312 + displayName?: string; 313 + joinedViaStarterPack?: ComAtprotoRepoStrongRef; 314 + /** Self-label values, specific to the Bluesky application, on the overall account. */ 315 + labels?: 316 + | ComAtprotoLabelDefs["SelfLabels"] 317 + | { 318 + $type: string; 319 + [key: string]: unknown; 320 + }; 321 + pinnedPost?: ComAtprotoRepoStrongRef; 322 + } 323 + 324 + export type AppBskyActorProfileSortFields = 325 + | "createdAt" 326 + | "description" 327 + | "displayName"; 328 + 329 + export interface SocialSlicesActorProfile { 257 330 /** Small image to be displayed next to posts from account. AKA, 'profile picture' */ 258 331 avatar?: BlobRef; 259 332 createdAt?: string; 333 + /** Free-form profile description text. */ 334 + description?: string; 335 + displayName?: string; 260 336 } 261 337 262 - export type SocialSlicesActorProfileRecordSortFields = 263 - | "displayName" 338 + export type SocialSlicesActorProfileSortFields = 339 + | "createdAt" 264 340 | "description" 265 - | "createdAt"; 341 + | "displayName"; 342 + 343 + export interface SocialSlicesLexicon { 344 + /** When the lexicon was created */ 345 + createdAt: string; 346 + /** The lexicon schema definitions as JSON */ 347 + definitions: string; 348 + /** Namespaced identifier for the lexicon */ 349 + nsid: string; 350 + /** AT-URI reference to the slice this lexicon belongs to */ 351 + slice: string; 352 + /** When the lexicon was last updated */ 353 + updatedAt?: string; 354 + } 355 + 356 + export type SocialSlicesLexiconSortFields = 357 + | "createdAt" 358 + | "definitions" 359 + | "nsid" 360 + | "slice" 361 + | "updatedAt"; 362 + 363 + export interface SocialSlicesSlice { 364 + /** When the slice was created */ 365 + createdAt: string; 366 + /** Name of the slice */ 367 + name: string; 368 + } 369 + 370 + export type SocialSlicesSliceSortFields = "createdAt" | "name"; 371 + 372 + export interface ComAtprotoLabelDefs { 373 + readonly Label: ComAtprotoLabelDefsLabel; 374 + readonly LabelValueDefinition: ComAtprotoLabelDefsLabelValueDefinition; 375 + readonly LabelValueDefinitionStrings: ComAtprotoLabelDefsLabelValueDefinitionStrings; 376 + readonly SelfLabel: ComAtprotoLabelDefsSelfLabel; 377 + readonly SelfLabels: ComAtprotoLabelDefsSelfLabels; 378 + } 266 379 267 380 class BaseClient { 268 381 protected readonly baseUrl: string; ··· 378 491 } 379 492 } 380 493 381 - class SliceSlicesSocialClient extends BaseClient { 494 + class ProfileActorBskyAppClient extends BaseClient { 382 495 private readonly sliceUri: string; 383 496 384 497 constructor(baseUrl: string, sliceUri: string, oauthClient?: OAuthClient) { ··· 387 500 } 388 501 389 502 async listRecords( 390 - params?: ListRecordsParams<SocialSlicesSliceRecordSortFields> 391 - ): Promise<ListRecordsResponse<SocialSlicesSliceRecord>> { 503 + params?: ListRecordsParams<AppBskyActorProfileSortFields> 504 + ): Promise<ListRecordsResponse<AppBskyActorProfile>> { 392 505 const requestParams = { ...params, slice: this.sliceUri }; 393 - return await this.makeRequest<ListRecordsResponse<SocialSlicesSliceRecord>>( 394 - "social.slices.slice.listRecords", 506 + return await this.makeRequest<ListRecordsResponse<AppBskyActorProfile>>( 507 + "app.bsky.actor.profile.listRecords", 395 508 "GET", 396 509 requestParams 397 510 ); ··· 399 512 400 513 async getRecord( 401 514 params: GetRecordParams 402 - ): Promise<RecordResponse<SocialSlicesSliceRecord>> { 515 + ): Promise<RecordResponse<AppBskyActorProfile>> { 403 516 const requestParams = { ...params, slice: this.sliceUri }; 404 - return await this.makeRequest<RecordResponse<SocialSlicesSliceRecord>>( 405 - "social.slices.slice.getRecord", 517 + return await this.makeRequest<RecordResponse<AppBskyActorProfile>>( 518 + "app.bsky.actor.profile.getRecord", 406 519 "GET", 407 520 requestParams 408 521 ); 409 522 } 410 523 411 524 async searchRecords( 412 - params: SearchRecordsParams<SocialSlicesSliceRecordSortFields> 413 - ): Promise<ListRecordsResponse<SocialSlicesSliceRecord>> { 525 + params: SearchRecordsParams<AppBskyActorProfileSortFields> 526 + ): Promise<ListRecordsResponse<AppBskyActorProfile>> { 414 527 const requestParams = { ...params, slice: this.sliceUri }; 415 - return await this.makeRequest<ListRecordsResponse<SocialSlicesSliceRecord>>( 416 - "social.slices.slice.searchRecords", 528 + return await this.makeRequest<ListRecordsResponse<AppBskyActorProfile>>( 529 + "app.bsky.actor.profile.searchRecords", 417 530 "GET", 418 531 requestParams 419 532 ); 420 533 } 421 534 422 535 async createRecord( 423 - record: SocialSlicesSliceRecord, 536 + record: AppBskyActorProfile, 424 537 useSelfRkey?: boolean 425 538 ): Promise<{ uri: string; cid: string }> { 426 - const recordValue = { $type: "social.slices.slice", ...record }; 539 + const recordValue = { $type: "app.bsky.actor.profile", ...record }; 427 540 const payload = { 428 541 slice: this.sliceUri, 429 542 ...(useSelfRkey ? { rkey: "self" } : {}), 430 543 record: recordValue, 431 544 }; 432 545 return await this.makeRequest<{ uri: string; cid: string }>( 433 - "social.slices.slice.createRecord", 546 + "app.bsky.actor.profile.createRecord", 434 547 "POST", 435 548 payload 436 549 ); ··· 438 551 439 552 async updateRecord( 440 553 rkey: string, 441 - record: SocialSlicesSliceRecord 554 + record: AppBskyActorProfile 442 555 ): Promise<{ uri: string; cid: string }> { 443 - const recordValue = { $type: "social.slices.slice", ...record }; 556 + const recordValue = { $type: "app.bsky.actor.profile", ...record }; 444 557 const payload = { 445 558 slice: this.sliceUri, 446 559 rkey, 447 560 record: recordValue, 448 561 }; 449 562 return await this.makeRequest<{ uri: string; cid: string }>( 450 - "social.slices.slice.updateRecord", 563 + "app.bsky.actor.profile.updateRecord", 451 564 "POST", 452 565 payload 453 566 ); ··· 455 568 456 569 async deleteRecord(rkey: string): Promise<void> { 457 570 return await this.makeRequest<void>( 458 - "social.slices.slice.deleteRecord", 571 + "app.bsky.actor.profile.deleteRecord", 459 572 "POST", 460 573 { rkey } 461 574 ); 462 575 } 576 + } 577 + 578 + class ActorBskyAppClient extends BaseClient { 579 + readonly profile: ProfileActorBskyAppClient; 580 + private readonly sliceUri: string; 463 581 464 - async codegen(request: CodegenXrpcRequest): Promise<CodegenXrpcResponse> { 465 - return await this.makeRequest<CodegenXrpcResponse>( 466 - "social.slices.slice.codegen", 467 - "POST", 468 - request 582 + constructor(baseUrl: string, sliceUri: string, oauthClient?: OAuthClient) { 583 + super(baseUrl, oauthClient); 584 + this.sliceUri = sliceUri; 585 + this.profile = new ProfileActorBskyAppClient( 586 + baseUrl, 587 + sliceUri, 588 + oauthClient 469 589 ); 470 590 } 591 + } 592 + 593 + class BskyAppClient extends BaseClient { 594 + readonly actor: ActorBskyAppClient; 595 + private readonly sliceUri: string; 471 596 472 - async stats(params: SliceStatsParams): Promise<SliceStatsOutput> { 473 - return await this.makeRequest<SliceStatsOutput>( 474 - "social.slices.slice.stats", 475 - "POST", 476 - params 477 - ); 597 + constructor(baseUrl: string, sliceUri: string, oauthClient?: OAuthClient) { 598 + super(baseUrl, oauthClient); 599 + this.sliceUri = sliceUri; 600 + this.actor = new ActorBskyAppClient(baseUrl, sliceUri, oauthClient); 601 + } 602 + } 603 + 604 + class AppClient extends BaseClient { 605 + readonly bsky: BskyAppClient; 606 + private readonly sliceUri: string; 607 + 608 + constructor(baseUrl: string, sliceUri: string, oauthClient?: OAuthClient) { 609 + super(baseUrl, oauthClient); 610 + this.sliceUri = sliceUri; 611 + this.bsky = new BskyAppClient(baseUrl, sliceUri, oauthClient); 612 + } 613 + } 614 + 615 + class ProfileActorSlicesSocialClient extends BaseClient { 616 + private readonly sliceUri: string; 617 + 618 + constructor(baseUrl: string, sliceUri: string, oauthClient?: OAuthClient) { 619 + super(baseUrl, oauthClient); 620 + this.sliceUri = sliceUri; 478 621 } 479 622 480 - async records(params: SliceRecordsParams): Promise<SliceRecordsOutput> { 481 - return await this.makeRequest<SliceRecordsOutput>( 482 - "social.slices.slice.records", 483 - "POST", 484 - params 485 - ); 623 + async listRecords( 624 + params?: ListRecordsParams<SocialSlicesActorProfileSortFields> 625 + ): Promise<ListRecordsResponse<SocialSlicesActorProfile>> { 626 + const requestParams = { ...params, slice: this.sliceUri }; 627 + return await this.makeRequest< 628 + ListRecordsResponse<SocialSlicesActorProfile> 629 + >("social.slices.actor.profile.listRecords", "GET", requestParams); 486 630 } 487 631 488 - async getActors(params?: GetActorsParams): Promise<GetActorsResponse> { 632 + async getRecord( 633 + params: GetRecordParams 634 + ): Promise<RecordResponse<SocialSlicesActorProfile>> { 489 635 const requestParams = { ...params, slice: this.sliceUri }; 490 - return await this.makeRequest<GetActorsResponse>( 491 - "social.slices.slice.getActors", 636 + return await this.makeRequest<RecordResponse<SocialSlicesActorProfile>>( 637 + "social.slices.actor.profile.getRecord", 492 638 "GET", 493 639 requestParams 494 640 ); 495 641 } 496 642 497 - async startSync(params: BulkSyncParams): Promise<SyncJobResponse> { 643 + async searchRecords( 644 + params: SearchRecordsParams<SocialSlicesActorProfileSortFields> 645 + ): Promise<ListRecordsResponse<SocialSlicesActorProfile>> { 498 646 const requestParams = { ...params, slice: this.sliceUri }; 499 - return await this.makeRequest<SyncJobResponse>( 500 - "social.slices.slice.startSync", 647 + return await this.makeRequest< 648 + ListRecordsResponse<SocialSlicesActorProfile> 649 + >("social.slices.actor.profile.searchRecords", "GET", requestParams); 650 + } 651 + 652 + async createRecord( 653 + record: SocialSlicesActorProfile, 654 + useSelfRkey?: boolean 655 + ): Promise<{ uri: string; cid: string }> { 656 + const recordValue = { $type: "social.slices.actor.profile", ...record }; 657 + const payload = { 658 + slice: this.sliceUri, 659 + ...(useSelfRkey ? { rkey: "self" } : {}), 660 + record: recordValue, 661 + }; 662 + return await this.makeRequest<{ uri: string; cid: string }>( 663 + "social.slices.actor.profile.createRecord", 501 664 "POST", 502 - requestParams 665 + payload 503 666 ); 504 667 } 505 668 506 - async getJobStatus(params: GetJobStatusParams): Promise<JobStatus> { 507 - return await this.makeRequest<JobStatus>( 508 - "social.slices.slice.getJobStatus", 509 - "GET", 510 - params 669 + async updateRecord( 670 + rkey: string, 671 + record: SocialSlicesActorProfile 672 + ): Promise<{ uri: string; cid: string }> { 673 + const recordValue = { $type: "social.slices.actor.profile", ...record }; 674 + const payload = { 675 + slice: this.sliceUri, 676 + rkey, 677 + record: recordValue, 678 + }; 679 + return await this.makeRequest<{ uri: string; cid: string }>( 680 + "social.slices.actor.profile.updateRecord", 681 + "POST", 682 + payload 511 683 ); 512 684 } 513 685 514 - async getJobHistory( 515 - params: GetJobHistoryParams 516 - ): Promise<GetJobHistoryResponse> { 517 - return await this.makeRequest<GetJobHistoryResponse>( 518 - "social.slices.slice.getJobHistory", 519 - "GET", 520 - params 686 + async deleteRecord(rkey: string): Promise<void> { 687 + return await this.makeRequest<void>( 688 + "social.slices.actor.profile.deleteRecord", 689 + "POST", 690 + { rkey } 521 691 ); 522 692 } 693 + } 694 + 695 + class ActorSlicesSocialClient extends BaseClient { 696 + readonly profile: ProfileActorSlicesSocialClient; 697 + private readonly sliceUri: string; 523 698 524 - async getJetstreamStatus(): Promise<JetstreamStatusResponse> { 525 - return await this.makeRequest<JetstreamStatusResponse>( 526 - "social.slices.slice.getJetstreamStatus", 527 - "GET" 699 + constructor(baseUrl: string, sliceUri: string, oauthClient?: OAuthClient) { 700 + super(baseUrl, oauthClient); 701 + this.sliceUri = sliceUri; 702 + this.profile = new ProfileActorSlicesSocialClient( 703 + baseUrl, 704 + sliceUri, 705 + oauthClient 528 706 ); 529 707 } 530 708 } ··· 538 716 } 539 717 540 718 async listRecords( 541 - params?: ListRecordsParams<SocialSlicesLexiconRecordSortFields> 542 - ): Promise<ListRecordsResponse<SocialSlicesLexiconRecord>> { 719 + params?: ListRecordsParams<SocialSlicesLexiconSortFields> 720 + ): Promise<ListRecordsResponse<SocialSlicesLexicon>> { 543 721 const requestParams = { ...params, slice: this.sliceUri }; 544 - return await this.makeRequest< 545 - ListRecordsResponse<SocialSlicesLexiconRecord> 546 - >("social.slices.lexicon.listRecords", "GET", requestParams); 722 + return await this.makeRequest<ListRecordsResponse<SocialSlicesLexicon>>( 723 + "social.slices.lexicon.listRecords", 724 + "GET", 725 + requestParams 726 + ); 547 727 } 548 728 549 729 async getRecord( 550 730 params: GetRecordParams 551 - ): Promise<RecordResponse<SocialSlicesLexiconRecord>> { 731 + ): Promise<RecordResponse<SocialSlicesLexicon>> { 552 732 const requestParams = { ...params, slice: this.sliceUri }; 553 - return await this.makeRequest<RecordResponse<SocialSlicesLexiconRecord>>( 733 + return await this.makeRequest<RecordResponse<SocialSlicesLexicon>>( 554 734 "social.slices.lexicon.getRecord", 555 735 "GET", 556 736 requestParams ··· 558 738 } 559 739 560 740 async searchRecords( 561 - params: SearchRecordsParams<SocialSlicesLexiconRecordSortFields> 562 - ): Promise<ListRecordsResponse<SocialSlicesLexiconRecord>> { 741 + params: SearchRecordsParams<SocialSlicesLexiconSortFields> 742 + ): Promise<ListRecordsResponse<SocialSlicesLexicon>> { 563 743 const requestParams = { ...params, slice: this.sliceUri }; 564 - return await this.makeRequest< 565 - ListRecordsResponse<SocialSlicesLexiconRecord> 566 - >("social.slices.lexicon.searchRecords", "GET", requestParams); 744 + return await this.makeRequest<ListRecordsResponse<SocialSlicesLexicon>>( 745 + "social.slices.lexicon.searchRecords", 746 + "GET", 747 + requestParams 748 + ); 567 749 } 568 750 569 751 async createRecord( 570 - record: SocialSlicesLexiconRecord, 752 + record: SocialSlicesLexicon, 571 753 useSelfRkey?: boolean 572 754 ): Promise<{ uri: string; cid: string }> { 573 755 const recordValue = { $type: "social.slices.lexicon", ...record }; ··· 585 767 586 768 async updateRecord( 587 769 rkey: string, 588 - record: SocialSlicesLexiconRecord 770 + record: SocialSlicesLexicon 589 771 ): Promise<{ uri: string; cid: string }> { 590 772 const recordValue = { $type: "social.slices.lexicon", ...record }; 591 773 const payload = { ··· 609 791 } 610 792 } 611 793 612 - class ProfileActorSlicesSocialClient extends BaseClient { 794 + class SliceSlicesSocialClient extends BaseClient { 613 795 private readonly sliceUri: string; 614 796 615 797 constructor(baseUrl: string, sliceUri: string, oauthClient?: OAuthClient) { ··· 618 800 } 619 801 620 802 async listRecords( 621 - params?: ListRecordsParams<SocialSlicesActorProfileRecordSortFields> 622 - ): Promise<ListRecordsResponse<SocialSlicesActorProfileRecord>> { 803 + params?: ListRecordsParams<SocialSlicesSliceSortFields> 804 + ): Promise<ListRecordsResponse<SocialSlicesSlice>> { 623 805 const requestParams = { ...params, slice: this.sliceUri }; 624 - return await this.makeRequest< 625 - ListRecordsResponse<SocialSlicesActorProfileRecord> 626 - >("social.slices.actor.profile.listRecords", "GET", requestParams); 806 + return await this.makeRequest<ListRecordsResponse<SocialSlicesSlice>>( 807 + "social.slices.slice.listRecords", 808 + "GET", 809 + requestParams 810 + ); 627 811 } 628 812 629 813 async getRecord( 630 814 params: GetRecordParams 631 - ): Promise<RecordResponse<SocialSlicesActorProfileRecord>> { 815 + ): Promise<RecordResponse<SocialSlicesSlice>> { 632 816 const requestParams = { ...params, slice: this.sliceUri }; 633 - return await this.makeRequest< 634 - RecordResponse<SocialSlicesActorProfileRecord> 635 - >("social.slices.actor.profile.getRecord", "GET", requestParams); 817 + return await this.makeRequest<RecordResponse<SocialSlicesSlice>>( 818 + "social.slices.slice.getRecord", 819 + "GET", 820 + requestParams 821 + ); 636 822 } 637 823 638 824 async searchRecords( 639 - params: SearchRecordsParams<SocialSlicesActorProfileRecordSortFields> 640 - ): Promise<ListRecordsResponse<SocialSlicesActorProfileRecord>> { 825 + params: SearchRecordsParams<SocialSlicesSliceSortFields> 826 + ): Promise<ListRecordsResponse<SocialSlicesSlice>> { 641 827 const requestParams = { ...params, slice: this.sliceUri }; 642 - return await this.makeRequest< 643 - ListRecordsResponse<SocialSlicesActorProfileRecord> 644 - >("social.slices.actor.profile.searchRecords", "GET", requestParams); 828 + return await this.makeRequest<ListRecordsResponse<SocialSlicesSlice>>( 829 + "social.slices.slice.searchRecords", 830 + "GET", 831 + requestParams 832 + ); 645 833 } 646 834 647 835 async createRecord( 648 - record: SocialSlicesActorProfileRecord, 836 + record: SocialSlicesSlice, 649 837 useSelfRkey?: boolean 650 838 ): Promise<{ uri: string; cid: string }> { 651 - const recordValue = { $type: "social.slices.actor.profile", ...record }; 839 + const recordValue = { $type: "social.slices.slice", ...record }; 652 840 const payload = { 653 841 slice: this.sliceUri, 654 842 ...(useSelfRkey ? { rkey: "self" } : {}), 655 843 record: recordValue, 656 844 }; 657 845 return await this.makeRequest<{ uri: string; cid: string }>( 658 - "social.slices.actor.profile.createRecord", 846 + "social.slices.slice.createRecord", 659 847 "POST", 660 848 payload 661 849 ); ··· 663 851 664 852 async updateRecord( 665 853 rkey: string, 666 - record: SocialSlicesActorProfileRecord 854 + record: SocialSlicesSlice 667 855 ): Promise<{ uri: string; cid: string }> { 668 - const recordValue = { $type: "social.slices.actor.profile", ...record }; 856 + const recordValue = { $type: "social.slices.slice", ...record }; 669 857 const payload = { 670 858 slice: this.sliceUri, 671 859 rkey, 672 860 record: recordValue, 673 861 }; 674 862 return await this.makeRequest<{ uri: string; cid: string }>( 675 - "social.slices.actor.profile.updateRecord", 863 + "social.slices.slice.updateRecord", 676 864 "POST", 677 865 payload 678 866 ); ··· 680 868 681 869 async deleteRecord(rkey: string): Promise<void> { 682 870 return await this.makeRequest<void>( 683 - "social.slices.actor.profile.deleteRecord", 871 + "social.slices.slice.deleteRecord", 684 872 "POST", 685 873 { rkey } 686 874 ); 687 875 } 688 - } 876 + 877 + async codegen(request: CodegenXrpcRequest): Promise<CodegenXrpcResponse> { 878 + return await this.makeRequest<CodegenXrpcResponse>( 879 + "social.slices.slice.codegen", 880 + "POST", 881 + request 882 + ); 883 + } 884 + 885 + async stats(params: SliceStatsParams): Promise<SliceStatsOutput> { 886 + return await this.makeRequest<SliceStatsOutput>( 887 + "social.slices.slice.stats", 888 + "POST", 889 + params 890 + ); 891 + } 892 + 893 + async records(params: SliceRecordsParams): Promise<SliceRecordsOutput> { 894 + return await this.makeRequest<SliceRecordsOutput>( 895 + "social.slices.slice.records", 896 + "POST", 897 + params 898 + ); 899 + } 900 + 901 + async getActors(params?: GetActorsParams): Promise<GetActorsResponse> { 902 + const requestParams = { ...params, slice: this.sliceUri }; 903 + return await this.makeRequest<GetActorsResponse>( 904 + "social.slices.slice.getActors", 905 + "GET", 906 + requestParams 907 + ); 908 + } 689 909 690 - class ActorSlicesSocialClient extends BaseClient { 691 - readonly profile: ProfileActorSlicesSocialClient; 692 - private readonly sliceUri: string; 910 + async startSync(params: BulkSyncParams): Promise<SyncJobResponse> { 911 + const requestParams = { ...params, slice: this.sliceUri }; 912 + return await this.makeRequest<SyncJobResponse>( 913 + "social.slices.slice.startSync", 914 + "POST", 915 + requestParams 916 + ); 917 + } 693 918 694 - constructor(baseUrl: string, sliceUri: string, oauthClient?: OAuthClient) { 695 - super(baseUrl, oauthClient); 696 - this.sliceUri = sliceUri; 697 - this.profile = new ProfileActorSlicesSocialClient( 698 - baseUrl, 699 - sliceUri, 700 - oauthClient 919 + async getJobStatus(params: GetJobStatusParams): Promise<JobStatus> { 920 + return await this.makeRequest<JobStatus>( 921 + "social.slices.slice.getJobStatus", 922 + "GET", 923 + params 924 + ); 925 + } 926 + 927 + async getJobHistory( 928 + params: GetJobHistoryParams 929 + ): Promise<GetJobHistoryResponse> { 930 + return await this.makeRequest<GetJobHistoryResponse>( 931 + "social.slices.slice.getJobHistory", 932 + "GET", 933 + params 934 + ); 935 + } 936 + 937 + async getJetstreamStatus(): Promise<JetstreamStatusResponse> { 938 + return await this.makeRequest<JetstreamStatusResponse>( 939 + "social.slices.slice.getJetstreamStatus", 940 + "GET" 941 + ); 942 + } 943 + 944 + async syncUserCollections( 945 + params?: SyncUserCollectionsRequest 946 + ): Promise<SyncUserCollectionsResult> { 947 + const requestParams = { slice: this.sliceUri, ...params }; 948 + return await this.makeRequest<SyncUserCollectionsResult>( 949 + "social.slices.slice.syncUserCollections", 950 + "POST", 951 + requestParams 701 952 ); 702 953 } 703 954 } 704 955 705 956 class SlicesSocialClient extends BaseClient { 957 + readonly actor: ActorSlicesSocialClient; 958 + readonly lexicon: LexiconSlicesSocialClient; 706 959 readonly slice: SliceSlicesSocialClient; 707 - readonly lexicon: LexiconSlicesSocialClient; 708 - readonly actor: ActorSlicesSocialClient; 709 960 private readonly sliceUri: string; 710 961 711 962 constructor(baseUrl: string, sliceUri: string, oauthClient?: OAuthClient) { 712 963 super(baseUrl, oauthClient); 713 964 this.sliceUri = sliceUri; 714 - this.slice = new SliceSlicesSocialClient(baseUrl, sliceUri, oauthClient); 965 + this.actor = new ActorSlicesSocialClient(baseUrl, sliceUri, oauthClient); 715 966 this.lexicon = new LexiconSlicesSocialClient( 716 967 baseUrl, 717 968 sliceUri, 718 969 oauthClient 719 970 ); 720 - this.actor = new ActorSlicesSocialClient(baseUrl, sliceUri, oauthClient); 971 + this.slice = new SliceSlicesSocialClient(baseUrl, sliceUri, oauthClient); 721 972 } 722 973 } 723 974 ··· 733 984 } 734 985 735 986 export class AtProtoClient extends BaseClient { 987 + readonly app: AppClient; 736 988 readonly social: SocialClient; 737 989 readonly oauth?: OAuthClient; 738 990 private readonly sliceUri: string; ··· 740 992 constructor(baseUrl: string, sliceUri: string, oauthClient?: OAuthClient) { 741 993 super(baseUrl, oauthClient); 742 994 this.sliceUri = sliceUri; 995 + this.app = new AppClient(baseUrl, sliceUri, oauthClient); 743 996 this.social = new SocialClient(baseUrl, sliceUri, oauthClient); 744 997 this.oauth = this.oauthClient; 745 998 }
+40 -2
frontend/src/routes/oauth.ts
··· 69 69 70 70 // Create OAuth session with auto token management 71 71 const sessionId = await oauthSessions.createOAuthSession(); 72 - 72 + 73 73 if (!sessionId) { 74 74 return Response.redirect( 75 75 new URL( ··· 83 83 // Create session cookie 84 84 const sessionCookie = sessionStore.createSessionCookie(sessionId); 85 85 86 + // Sync external collections if user doesn't have them yet 87 + try { 88 + // Get user info from OAuth session 89 + const userInfo = await atprotoClient.oauth?.getUserInfo(); 90 + if (!userInfo?.sub) { 91 + console.log( 92 + "No user DID available, skipping external collections sync" 93 + ); 94 + } else { 95 + // Check if user already has bsky profile synced 96 + try { 97 + const profileCheck = 98 + await atprotoClient.app.bsky.actor.profile.listRecords({ 99 + authors: [userInfo.sub], 100 + limit: 1, 101 + }); 102 + 103 + // If we can't find existing records, sync them 104 + if (!profileCheck.records || profileCheck.records.length === 0) { 105 + console.log("No existing external collections found, syncing..."); 106 + await atprotoClient.social.slices.slice.syncUserCollections(); 107 + } else { 108 + console.log("External collections already synced, skipping sync"); 109 + } 110 + } catch (_profileError) { 111 + // If we can't check existing records, skip sync to be safe 112 + console.log( 113 + "Could not check existing external collections, skipping sync" 114 + ); 115 + } 116 + } 117 + } catch (error) { 118 + console.log( 119 + "Error during sync check, skipping external collections sync:", 120 + error 121 + ); 122 + } 123 + 86 124 return new Response(null, { 87 125 status: 302, 88 126 headers: { ··· 105 143 async function handleLogout(req: Request): Promise<Response> { 106 144 // Get session from request 107 145 const session = await sessionStore.getSessionFromRequest(req); 108 - 146 + 109 147 if (session) { 110 148 // Use OAuth session manager to handle logout 111 149 await oauthSessions.logout(session.sessionId);