+4
-4
api/scripts/codegen.sh
+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
+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
+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
+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
+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
+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
+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
+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
+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);