Highly ambitious ATProtocol AppView service and sdks
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

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

Chad Miller df3e8cc6 b9fc3ca4

+814 -208
+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);