Highly ambitious ATProtocol AppView service and sdks

Compare changes

Choose any two refs to compare.

Changed files
+603 -65
api
crates
slices-lexicon
src
validation
primitive
docs
frontend-v2
packages
session
src
adapters
+480 -8
api/src/graphql/schema_builder.rs
··· 45 45 at_uri_fields: Vec<String>, // Fields with format "at-uri" for reverse joins 46 46 } 47 47 48 + /// Type registry for tracking generated nested object types 49 + type TypeRegistry = HashMap<String, Object>; 50 + 51 + /// Container for nested object field values 52 + #[derive(Clone)] 53 + struct NestedObjectContainer { 54 + data: serde_json::Value, 55 + } 56 + 57 + /// Generates a unique type name for a nested object field 58 + fn generate_nested_type_name(parent_type: &str, field_name: &str) -> String { 59 + let mut chars = field_name.chars(); 60 + let capitalized_field = match chars.next() { 61 + None => String::new(), 62 + Some(first) => first.to_uppercase().collect::<String>() + chars.as_str(), 63 + }; 64 + format!("{}{}", parent_type, capitalized_field) 65 + } 66 + 67 + /// Resolves a lexicon ref and generates a GraphQL type for it 68 + /// Returns the generated type name 69 + fn resolve_lexicon_ref_type( 70 + ref_nsid: &str, 71 + current_lexicon_nsid: &str, 72 + all_lexicons: &[serde_json::Value], 73 + type_registry: &mut TypeRegistry, 74 + database: &Database, 75 + ) -> String { 76 + // Handle different ref formats: 77 + // 1. Local ref: #image 78 + // 2. External ref with specific def: app.bsky.embed.defs#aspectRatio 79 + // 3. External ref to main: community.lexicon.location.hthree 80 + let (target_nsid, def_name) = if ref_nsid.starts_with('#') { 81 + // Local ref - use current lexicon NSID and the def name without # 82 + (current_lexicon_nsid, &ref_nsid[1..]) 83 + } else if let Some(hash_pos) = ref_nsid.find('#') { 84 + // External ref with specific def - split on # 85 + (&ref_nsid[..hash_pos], &ref_nsid[hash_pos + 1..]) 86 + } else { 87 + // External ref to main def 88 + (ref_nsid, "main") 89 + }; 90 + 91 + // Generate type name from NSID and def name 92 + let type_name = if def_name == "main" { 93 + // For refs to main: CommunityLexiconLocationHthree 94 + nsid_to_type_name(target_nsid) 95 + } else { 96 + // For refs to specific def: AppBskyEmbedDefsAspectRatio 97 + format!("{}{}", nsid_to_type_name(target_nsid), capitalize_first(def_name)) 98 + }; 99 + 100 + // Check if already generated 101 + if type_registry.contains_key(&type_name) { 102 + return type_name; 103 + } 104 + 105 + // Find the lexicon definition 106 + let lexicon = all_lexicons.iter().find(|lex| { 107 + lex.get("id").and_then(|id| id.as_str()) == Some(target_nsid) 108 + }); 109 + 110 + if let Some(lex) = lexicon { 111 + // Extract the definition (either "main" or specific def like "image") 112 + if let Some(defs) = lex.get("defs") { 113 + if let Some(def) = defs.get(def_name) { 114 + // Extract fields from this specific definition 115 + if let Some(properties) = def.get("properties") { 116 + let fields = extract_fields_from_properties(properties); 117 + 118 + if !fields.is_empty() { 119 + // Generate the type using existing nested object generator 120 + generate_nested_object_type(&type_name, &fields, type_registry, database); 121 + return type_name; 122 + } 123 + } 124 + } 125 + } 126 + } 127 + 128 + // Fallback: couldn't resolve the ref, will use JSON 129 + tracing::warn!("Could not resolve lexicon ref: {} (target: {}, def: {})", ref_nsid, target_nsid, def_name); 130 + type_name 131 + } 132 + 133 + /// Capitalizes the first character of a string 134 + fn capitalize_first(s: &str) -> String { 135 + let mut chars = s.chars(); 136 + match chars.next() { 137 + None => String::new(), 138 + Some(first) => first.to_uppercase().collect::<String>() + chars.as_str(), 139 + } 140 + } 141 + 142 + /// Extracts fields from a lexicon properties object 143 + fn extract_fields_from_properties(properties: &serde_json::Value) -> Vec<GraphQLField> { 144 + let mut fields = Vec::new(); 145 + 146 + if let Some(props) = properties.as_object() { 147 + for (field_name, field_def) in props { 148 + let field_type_str = field_def.get("type").and_then(|t| t.as_str()).unwrap_or("unknown"); 149 + let field_type = crate::graphql::types::map_lexicon_type_to_graphql(field_type_str, field_def); 150 + 151 + // Check if field is required 152 + let is_required = false; // We'd need the parent's "required" array to know this 153 + 154 + // Extract format if present 155 + let format = field_def.get("format").and_then(|f| f.as_str()).map(|s| s.to_string()); 156 + 157 + fields.push(GraphQLField { 158 + name: field_name.clone(), 159 + field_type, 160 + is_required, 161 + format, 162 + }); 163 + } 164 + } 165 + 166 + fields 167 + } 168 + 169 + /// Recursively generates GraphQL object types for nested objects 170 + /// Returns the type name of the generated object type 171 + fn generate_nested_object_type( 172 + type_name: &str, 173 + fields: &[GraphQLField], 174 + type_registry: &mut TypeRegistry, 175 + database: &Database, 176 + ) -> String { 177 + // Check if type already exists in registry 178 + if type_registry.contains_key(type_name) { 179 + return type_name.to_string(); 180 + } 181 + 182 + let mut object = Object::new(type_name); 183 + 184 + // Add fields to the object 185 + for field in fields { 186 + let field_name = field.name.clone(); 187 + let field_name_for_field = field_name.clone(); // Clone for Field::new 188 + let field_type = field.field_type.clone(); 189 + 190 + // Determine the TypeRef for this field 191 + let type_ref = match &field.field_type { 192 + GraphQLType::Object(nested_fields) => { 193 + // Generate nested object type recursively 194 + let nested_type_name = generate_nested_type_name(type_name, &field_name); 195 + let actual_type_name = generate_nested_object_type( 196 + &nested_type_name, 197 + nested_fields, 198 + type_registry, 199 + database, 200 + ); 201 + 202 + if field.is_required { 203 + TypeRef::named_nn(actual_type_name) 204 + } else { 205 + TypeRef::named(actual_type_name) 206 + } 207 + } 208 + GraphQLType::Array(inner) => { 209 + if let GraphQLType::Object(nested_fields) = inner.as_ref() { 210 + // Generate nested object type for array items 211 + let nested_type_name = generate_nested_type_name(type_name, &field_name); 212 + let actual_type_name = generate_nested_object_type( 213 + &nested_type_name, 214 + nested_fields, 215 + type_registry, 216 + database, 217 + ); 218 + 219 + if field.is_required { 220 + TypeRef::named_nn_list(actual_type_name) 221 + } else { 222 + TypeRef::named_list(actual_type_name) 223 + } 224 + } else { 225 + // Use standard type ref for arrays of primitives 226 + graphql_type_to_typeref(&field.field_type, field.is_required) 227 + } 228 + } 229 + _ => { 230 + // Use standard type ref for other types 231 + graphql_type_to_typeref(&field.field_type, field.is_required) 232 + } 233 + }; 234 + 235 + // Add field with resolver 236 + object = object.field(Field::new(&field_name_for_field, type_ref, move |ctx| { 237 + let field_name = field_name.clone(); 238 + let field_type = field_type.clone(); 239 + 240 + FieldFuture::new(async move { 241 + // Get parent container 242 + let container = ctx.parent_value.try_downcast_ref::<NestedObjectContainer>()?; 243 + let value = container.data.get(&field_name); 244 + 245 + if let Some(val) = value { 246 + if val.is_null() { 247 + return Ok(None); 248 + } 249 + 250 + // For nested objects, wrap in container 251 + if matches!(field_type, GraphQLType::Object(_)) { 252 + let nested_container = NestedObjectContainer { 253 + data: val.clone(), 254 + }; 255 + return Ok(Some(FieldValue::owned_any(nested_container))); 256 + } 257 + 258 + // For arrays of objects, wrap each item 259 + if let GraphQLType::Array(inner) = &field_type { 260 + if matches!(inner.as_ref(), GraphQLType::Object(_)) { 261 + if let Some(arr) = val.as_array() { 262 + let containers: Vec<FieldValue> = arr 263 + .iter() 264 + .map(|item| { 265 + let nested_container = NestedObjectContainer { 266 + data: item.clone(), 267 + }; 268 + FieldValue::owned_any(nested_container) 269 + }) 270 + .collect(); 271 + return Ok(Some(FieldValue::list(containers))); 272 + } 273 + return Ok(Some(FieldValue::list(Vec::<FieldValue>::new()))); 274 + } 275 + } 276 + 277 + // For other types, return the GraphQL value 278 + let graphql_val = json_to_graphql_value(val); 279 + Ok(Some(FieldValue::value(graphql_val))) 280 + } else { 281 + Ok(None) 282 + } 283 + }) 284 + })); 285 + } 286 + 287 + // Store the generated type in registry 288 + type_registry.insert(type_name.to_string(), object); 289 + type_name.to_string() 290 + } 291 + 48 292 /// Builds a dynamic GraphQL schema from lexicons for a given slice 49 293 pub async fn build_graphql_schema(database: Database, slice_uri: String, auth_base_url: String) -> Result<Schema, String> { 50 294 // Fetch all lexicons for this slice ··· 110 354 } 111 355 } 112 356 357 + // Initialize type registry for nested object types 358 + let mut type_registry: TypeRegistry = HashMap::new(); 359 + 113 360 // Second pass: create types and queries 114 361 for lexicon in &lexicons { 115 362 // get_lexicons_by_slice returns {lexicon: 1, id: "nsid", defs: {...}} ··· 136 383 slice_uri.clone(), 137 384 &all_collections, 138 385 auth_base_url.clone(), 386 + &mut type_registry, 387 + &lexicons, 388 + nsid, 139 389 ); 140 390 141 391 // Create edge and connection types for this collection (Relay standard) ··· 1051 1301 schema_builder = schema_builder.register(mutation_input); 1052 1302 } 1053 1303 1304 + // Register all nested object types from the type registry 1305 + for (_, nested_type) in type_registry { 1306 + schema_builder = schema_builder.register(nested_type); 1307 + } 1308 + 1054 1309 schema_builder 1055 1310 .finish() 1056 1311 .map_err(|e| format!("Schema build error: {:?}", e)) ··· 1079 1334 slice_uri: String, 1080 1335 all_collections: &[CollectionMeta], 1081 1336 auth_base_url: String, 1337 + type_registry: &mut TypeRegistry, 1338 + all_lexicons: &[serde_json::Value], 1339 + lexicon_nsid: &str, 1082 1340 ) -> Object { 1083 1341 let mut object = Object::new(type_name); 1084 1342 ··· 1222 1480 let field_type = field.field_type.clone(); 1223 1481 let db_clone = database.clone(); 1224 1482 1225 - let type_ref = graphql_type_to_typeref(&field.field_type, field.is_required); 1483 + // Determine type ref - handle nested objects and lexicon refs specially 1484 + let type_ref = match &field.field_type { 1485 + GraphQLType::LexiconRef(ref_nsid) => { 1486 + // Resolve lexicon ref and generate type for it 1487 + let resolved_type_name = resolve_lexicon_ref_type( 1488 + ref_nsid, 1489 + lexicon_nsid, 1490 + all_lexicons, 1491 + type_registry, 1492 + &database, 1493 + ); 1494 + 1495 + if field.is_required { 1496 + TypeRef::named_nn(resolved_type_name) 1497 + } else { 1498 + TypeRef::named(resolved_type_name) 1499 + } 1500 + } 1501 + GraphQLType::Object(nested_fields) => { 1502 + // Generate nested object type 1503 + let nested_type_name = generate_nested_type_name(type_name, &field_name); 1504 + let actual_type_name = generate_nested_object_type( 1505 + &nested_type_name, 1506 + nested_fields, 1507 + type_registry, 1508 + &database, 1509 + ); 1510 + 1511 + if field.is_required { 1512 + TypeRef::named_nn(actual_type_name) 1513 + } else { 1514 + TypeRef::named(actual_type_name) 1515 + } 1516 + } 1517 + GraphQLType::Array(inner) => { 1518 + match inner.as_ref() { 1519 + GraphQLType::LexiconRef(ref_nsid) => { 1520 + // Resolve lexicon ref for array items 1521 + let resolved_type_name = resolve_lexicon_ref_type( 1522 + ref_nsid, 1523 + lexicon_nsid, 1524 + all_lexicons, 1525 + type_registry, 1526 + &database, 1527 + ); 1528 + 1529 + if field.is_required { 1530 + TypeRef::named_nn_list(resolved_type_name) 1531 + } else { 1532 + TypeRef::named_list(resolved_type_name) 1533 + } 1534 + } 1535 + GraphQLType::Object(nested_fields) => { 1536 + // Generate nested object type for array items 1537 + let nested_type_name = generate_nested_type_name(type_name, &field_name); 1538 + let actual_type_name = generate_nested_object_type( 1539 + &nested_type_name, 1540 + nested_fields, 1541 + type_registry, 1542 + &database, 1543 + ); 1544 + 1545 + if field.is_required { 1546 + TypeRef::named_nn_list(actual_type_name) 1547 + } else { 1548 + TypeRef::named_list(actual_type_name) 1549 + } 1550 + } 1551 + _ => graphql_type_to_typeref(&field.field_type, field.is_required), 1552 + } 1553 + } 1554 + _ => graphql_type_to_typeref(&field.field_type, field.is_required), 1555 + }; 1226 1556 1227 1557 object = object.field(Field::new(&field_name_for_field, type_ref, move |ctx| { 1228 1558 let field_name = field_name.clone(); ··· 1346 1676 } 1347 1677 } 1348 1678 1349 - // For non-ref fields, return the raw JSON value 1679 + // Check if this is a lexicon ref field 1680 + if matches!(field_type, GraphQLType::LexiconRef(_)) { 1681 + let nested_container = NestedObjectContainer { 1682 + data: val.clone(), 1683 + }; 1684 + return Ok(Some(FieldValue::owned_any(nested_container))); 1685 + } 1686 + 1687 + // Check if this is a nested object field 1688 + if matches!(field_type, GraphQLType::Object(_)) { 1689 + let nested_container = NestedObjectContainer { 1690 + data: val.clone(), 1691 + }; 1692 + return Ok(Some(FieldValue::owned_any(nested_container))); 1693 + } 1694 + 1695 + // Check if this is an array of nested objects or lexicon refs 1696 + if let GraphQLType::Array(inner) = &field_type { 1697 + if matches!(inner.as_ref(), GraphQLType::LexiconRef(_)) || matches!(inner.as_ref(), GraphQLType::Object(_)) { 1698 + if let Some(arr) = val.as_array() { 1699 + let containers: Vec<FieldValue> = arr 1700 + .iter() 1701 + .map(|item| { 1702 + let nested_container = NestedObjectContainer { 1703 + data: item.clone(), 1704 + }; 1705 + FieldValue::owned_any(nested_container) 1706 + }) 1707 + .collect(); 1708 + return Ok(Some(FieldValue::list(containers))); 1709 + } 1710 + return Ok(Some(FieldValue::list(Vec::<FieldValue>::new()))); 1711 + } 1712 + } 1713 + 1714 + // For non-ref, non-object fields, return the raw JSON value 1350 1715 let graphql_val = json_to_graphql_value(val); 1351 1716 Ok(Some(FieldValue::value(graphql_val))) 1352 1717 } else { ··· 1914 2279 // Always nullable since blob data might be missing or malformed 1915 2280 TypeRef::named("Blob") 1916 2281 } 1917 - GraphQLType::Json | GraphQLType::Ref | GraphQLType::Object(_) | GraphQLType::Union => { 1918 - // JSON scalar type - linked records and complex objects return as JSON 2282 + GraphQLType::Json | GraphQLType::Ref | GraphQLType::LexiconRef(_) | GraphQLType::Object(_) | GraphQLType::Union => { 2283 + // JSON scalar type - linked records, lexicon refs, and complex objects return as JSON (fallback) 1919 2284 if is_required { 1920 2285 TypeRef::named_nn("JSON") 1921 2286 } else { ··· 2504 2869 let type_name = nsid_to_type_name(nsid); 2505 2870 2506 2871 // Add create mutation 2507 - mutation = add_create_mutation(mutation, &type_name, nsid, database.clone(), slice_uri.clone()); 2872 + mutation = add_create_mutation(mutation, &type_name, nsid, &fields, database.clone(), slice_uri.clone()); 2508 2873 2509 2874 // Add update mutation 2510 - mutation = add_update_mutation(mutation, &type_name, nsid, database.clone(), slice_uri.clone()); 2875 + mutation = add_update_mutation(mutation, &type_name, nsid, &fields, database.clone(), slice_uri.clone()); 2511 2876 2512 2877 // Add delete mutation 2513 2878 mutation = add_delete_mutation(mutation, &type_name, nsid, database.clone(), slice_uri.clone()); ··· 3157 3522 input 3158 3523 } 3159 3524 3525 + /// Transforms fields in record data from GraphQL format to AT Protocol format 3526 + /// 3527 + /// Blob fields: 3528 + /// - GraphQL format: `{ref: "bafyrei...", mimeType: "...", size: 123}` 3529 + /// - AT Protocol format: `{$type: "blob", ref: {$link: "bafyrei..."}, mimeType: "...", size: 123}` 3530 + /// 3531 + /// Lexicon ref fields: 3532 + /// - Adds `$type: "{ref_nsid}"` to objects (e.g., `{$type: "community.lexicon.location.hthree#main", ...}`) 3533 + /// 3534 + /// Nested objects: 3535 + /// - Recursively processes nested objects and arrays 3536 + fn transform_fields_for_atproto( 3537 + mut data: serde_json::Value, 3538 + fields: &[GraphQLField], 3539 + ) -> serde_json::Value { 3540 + if let serde_json::Value::Object(ref mut map) = data { 3541 + for field in fields { 3542 + if let Some(field_value) = map.get_mut(&field.name) { 3543 + match &field.field_type { 3544 + GraphQLType::Blob => { 3545 + // Transform single blob field 3546 + if let Some(blob_obj) = field_value.as_object_mut() { 3547 + // Add $type: "blob" 3548 + blob_obj.insert("$type".to_string(), serde_json::Value::String("blob".to_string())); 3549 + 3550 + // Check if ref is a string (GraphQL format) 3551 + if let Some(serde_json::Value::String(cid)) = blob_obj.get("ref") { 3552 + // Transform to {$link: "cid"} (AT Protocol format) 3553 + let link_obj = serde_json::json!({ 3554 + "$link": cid 3555 + }); 3556 + blob_obj.insert("ref".to_string(), link_obj); 3557 + } 3558 + } 3559 + } 3560 + GraphQLType::LexiconRef(ref_nsid) => { 3561 + // Transform lexicon ref field by adding $type 3562 + if let Some(ref_obj) = field_value.as_object_mut() { 3563 + ref_obj.insert("$type".to_string(), serde_json::Value::String(ref_nsid.clone())); 3564 + } 3565 + } 3566 + GraphQLType::Object(nested_fields) => { 3567 + // Recursively transform nested objects 3568 + *field_value = transform_fields_for_atproto(field_value.clone(), nested_fields); 3569 + } 3570 + GraphQLType::Array(inner) => { 3571 + match inner.as_ref() { 3572 + GraphQLType::Blob => { 3573 + // Transform array of blobs 3574 + if let Some(arr) = field_value.as_array_mut() { 3575 + for blob_value in arr { 3576 + if let Some(blob_obj) = blob_value.as_object_mut() { 3577 + // Add $type: "blob" 3578 + blob_obj.insert("$type".to_string(), serde_json::Value::String("blob".to_string())); 3579 + 3580 + if let Some(serde_json::Value::String(cid)) = blob_obj.get("ref") { 3581 + let link_obj = serde_json::json!({ 3582 + "$link": cid 3583 + }); 3584 + blob_obj.insert("ref".to_string(), link_obj); 3585 + } 3586 + } 3587 + } 3588 + } 3589 + } 3590 + GraphQLType::LexiconRef(ref_nsid) => { 3591 + // Transform array of lexicon refs 3592 + if let Some(arr) = field_value.as_array_mut() { 3593 + for ref_value in arr { 3594 + if let Some(ref_obj) = ref_value.as_object_mut() { 3595 + ref_obj.insert("$type".to_string(), serde_json::Value::String(ref_nsid.clone())); 3596 + } 3597 + } 3598 + } 3599 + } 3600 + GraphQLType::Object(nested_fields) => { 3601 + // Transform array of objects recursively 3602 + if let Some(arr) = field_value.as_array_mut() { 3603 + for item in arr { 3604 + *item = transform_fields_for_atproto(item.clone(), nested_fields); 3605 + } 3606 + } 3607 + } 3608 + _ => {} // Other array types don't need transformation 3609 + } 3610 + } 3611 + _ => {} // Other field types don't need transformation 3612 + } 3613 + } 3614 + } 3615 + } 3616 + 3617 + data 3618 + } 3619 + 3160 3620 /// Adds a create mutation for a collection 3161 3621 fn add_create_mutation( 3162 3622 mutation: Object, 3163 3623 type_name: &str, 3164 3624 nsid: &str, 3625 + fields: &[GraphQLField], 3165 3626 database: Database, 3166 3627 slice_uri: String, 3167 3628 ) -> Object { 3168 3629 let mutation_name = format!("create{}", type_name); 3169 3630 let nsid = nsid.to_string(); 3170 3631 let nsid_clone = nsid.clone(); 3632 + let fields = fields.to_vec(); 3171 3633 3172 3634 mutation.field( 3173 3635 Field::new( ··· 3177 3639 let db = database.clone(); 3178 3640 let slice = slice_uri.clone(); 3179 3641 let collection = nsid.clone(); 3642 + let fields = fields.clone(); 3180 3643 3181 3644 FieldFuture::new(async move { 3182 3645 // Get GraphQL context which contains auth info ··· 3192 3655 .ok_or_else(|| Error::new("Missing input argument"))?; 3193 3656 3194 3657 // Convert GraphQL value to JSON using deserialize 3195 - let record_data: serde_json::Value = input.deserialize() 3658 + let mut record_data: serde_json::Value = input.deserialize() 3196 3659 .map_err(|e| Error::new(format!("Failed to deserialize input: {:?}", e)))?; 3660 + 3661 + // Transform fields from GraphQL to AT Protocol format (adds $type, transforms blob refs) 3662 + record_data = transform_fields_for_atproto(record_data, &fields); 3197 3663 3198 3664 // Optional rkey argument 3199 3665 let rkey = ctx.args.get("rkey") ··· 3322 3788 mutation: Object, 3323 3789 type_name: &str, 3324 3790 nsid: &str, 3791 + fields: &[GraphQLField], 3325 3792 database: Database, 3326 3793 slice_uri: String, 3327 3794 ) -> Object { 3328 3795 let mutation_name = format!("update{}", type_name); 3329 3796 let nsid = nsid.to_string(); 3330 3797 let nsid_clone = nsid.clone(); 3798 + let fields = fields.to_vec(); 3331 3799 3332 3800 mutation.field( 3333 3801 Field::new( ··· 3337 3805 let db = database.clone(); 3338 3806 let slice = slice_uri.clone(); 3339 3807 let collection = nsid.clone(); 3808 + let fields = fields.clone(); 3340 3809 3341 3810 FieldFuture::new(async move { 3342 3811 // Get GraphQL context which contains auth info ··· 3358 3827 .ok_or_else(|| Error::new("Missing input argument"))?; 3359 3828 3360 3829 // Convert GraphQL value to JSON using deserialize 3361 - let record_data: serde_json::Value = input.deserialize() 3830 + let mut record_data: serde_json::Value = input.deserialize() 3362 3831 .map_err(|e| Error::new(format!("Failed to deserialize input: {:?}", e)))?; 3832 + 3833 + // Transform fields from GraphQL to AT Protocol format (adds $type, transforms blob refs) 3834 + record_data = transform_fields_for_atproto(record_data, &fields); 3363 3835 3364 3836 // Verify OAuth token and get user info 3365 3837 let user_info = crate::auth::verify_oauth_token_cached(
+6 -1
api/src/graphql/types.rs
··· 20 20 Float, 21 21 /// Reference to another record (for strongRef) 22 22 Ref, 23 + /// Reference to a lexicon type definition (e.g., community.lexicon.location.hthree) 24 + LexiconRef(String), 23 25 /// Array of a type 24 26 Array(Box<GraphQLType>), 25 27 /// Object with nested fields ··· 45 47 "unknown" => GraphQLType::Json, 46 48 "null" => GraphQLType::Json, 47 49 "ref" => { 48 - // Check if this is a strongRef (link to another record) 50 + // Check if this is a strongRef (link to another record) or a lexicon type ref 49 51 let ref_name = lexicon_def 50 52 .get("ref") 51 53 .and_then(|r| r.as_str()) ··· 53 55 54 56 if ref_name == "com.atproto.repo.strongRef" { 55 57 GraphQLType::Ref 58 + } else if !ref_name.is_empty() { 59 + // This is a reference to a lexicon type definition 60 + GraphQLType::LexiconRef(ref_name.to_string()) 56 61 } else { 57 62 GraphQLType::Json 58 63 }
+24 -11
crates/slices-lexicon/src/validation/primitive/string.rs
··· 577 577 578 578 /// Validates TID (Timestamp Identifier) format 579 579 /// 580 - /// TID format: 13-character base32-encoded timestamp + random bits 581 - /// Uses Crockford base32 alphabet: 0123456789ABCDEFGHJKMNPQRSTVWXYZ (case-insensitive) 580 + /// TID format: 13-character base32-sortable encoded timestamp + random bits 581 + /// Uses ATProto base32-sortable alphabet: 234567abcdefghijklmnopqrstuvwxyz (lowercase only) 582 582 pub fn is_valid_tid(&self, value: &str) -> bool { 583 583 use regex::Regex; 584 584 ··· 586 586 return false; 587 587 } 588 588 589 - // TID uses Crockford base32 (case-insensitive, excludes I, L, O, U) 590 - let tid_regex = Regex::new(r"^[0-9A-HJKMNP-TV-Z]{13}$").unwrap(); 591 - let uppercase_value = value.to_uppercase(); 589 + // TID uses base32-sortable (s32) - lowercase only 590 + // First character must be from limited set (ensures top bit is 0) 591 + // Remaining 12 characters from full base32-sortable alphabet 592 + let tid_regex = Regex::new(r"^[234567abcdefghij][234567abcdefghijklmnopqrstuvwxyz]{12}$").unwrap(); 592 593 593 - tid_regex.is_match(&uppercase_value) 594 + tid_regex.is_match(value) 594 595 } 595 596 596 597 /// Validates Record Key format ··· 1096 1097 1097 1098 let validator = StringValidator; 1098 1099 1099 - // Valid TIDs (13 characters, Crockford base32) 1100 - assert!(validator.validate_data(&json!("3JZFKJT0000ZZ"), &schema, &ctx).is_ok()); 1101 - assert!(validator.validate_data(&json!("3jzfkjt0000zz"), &schema, &ctx).is_ok()); // case insensitive 1100 + // Valid TIDs (base32-sortable, 13 chars, lowercase) 1101 + assert!(validator.validate_data(&json!("3m3zm7eurxk26"), &schema, &ctx).is_ok()); 1102 + assert!(validator.validate_data(&json!("2222222222222"), &schema, &ctx).is_ok()); // minimum TID 1103 + assert!(validator.validate_data(&json!("a222222222222"), &schema, &ctx).is_ok()); // leading 'a' (lower bound) 1104 + assert!(validator.validate_data(&json!("j234567abcdef"), &schema, &ctx).is_ok()); // leading 'j' (upper bound) 1105 + 1102 1106 1103 - // Invalid TIDs 1107 + // Invalid TIDs - uppercase not allowed (charset is lowercase only) 1108 + assert!(validator.validate_data(&json!("3m3zM7eurxk26"), &schema, &ctx).is_err()); // mixed case 1109 + 1110 + // Invalid TIDs - wrong length 1104 1111 assert!(validator.validate_data(&json!("too-short"), &schema, &ctx).is_err()); 1105 1112 assert!(validator.validate_data(&json!("too-long-string"), &schema, &ctx).is_err()); 1113 + 1114 + // Invalid TIDs - invalid characters (hyphen/punct rejected; digits 0,1,8,9 not allowed) 1106 1115 assert!(validator.validate_data(&json!("invalid-chars!"), &schema, &ctx).is_err()); 1107 - assert!(validator.validate_data(&json!("invalid-ILOU0"), &schema, &ctx).is_err()); // invalid chars (I, L, O, U) 1116 + assert!(validator.validate_data(&json!("xyz1234567890"), &schema, &ctx).is_err()); // has 0,1,8,9 1117 + 1118 + // Invalid TIDs - first character must be one of 234567abcdefghij 1119 + assert!(validator.validate_data(&json!("k222222222222"), &schema, &ctx).is_err()); // leading 'k' forbidden 1120 + assert!(validator.validate_data(&json!("z234567abcdef"), &schema, &ctx).is_err()); // leading 'z' forbidden 1108 1121 } 1109 1122 1110 1123 #[test]
+20 -7
docs/graphql-api.md
··· 881 881 882 882 **Returns:** 883 883 884 - - `blob`: A JSON blob object containing: 885 - - `ref`: The CID (content identifier) reference for the blob 886 - - `mimeType`: The MIME type of the uploaded blob 887 - - `size`: The size of the blob in bytes 884 + - `blob`: A Blob object containing: 885 + - `ref` (String): The CID (content identifier) reference for the blob 886 + - `mimeType` (String): The MIME type of the uploaded blob 887 + - `size` (Int): The size of the blob in bytes 888 + - `url` (String): CDN URL for the blob (supports presets) 888 889 889 890 **Example with Variables:** 890 891 ··· 897 898 898 899 **Usage in Records:** 899 900 900 - After uploading a blob, use the returned blob object in your record mutations: 901 + After uploading a blob, use the returned blob object in your record mutations. You can provide the blob as a complete object with `ref` as a String: 901 902 902 903 ```graphql 903 904 mutation UpdateProfile($avatar: JSON) { ··· 905 906 rkey: "self" 906 907 input: { 907 908 displayName: "My Name" 908 - avatar: $avatar # Use the blob object from uploadBlob 909 + avatar: $avatar # Blob object with ref as String (CID) 909 910 } 910 911 ) { 911 912 uri 912 913 displayName 913 914 avatar { 914 - ref 915 + ref # Returns as String (CID) 915 916 mimeType 916 917 size 917 918 url(preset: "avatar") ··· 919 920 } 920 921 } 921 922 ``` 923 + 924 + **Example blob object for mutations:** 925 + 926 + ```json 927 + { 928 + "ref": "bafyreigbtj4x7ip5legnfznufuopl4sg4knzc2cof6duas4b3q2fy6swua", 929 + "mimeType": "image/jpeg", 930 + "size": 245678 931 + } 932 + ``` 933 + 934 + **Note:** The GraphQL API automatically handles the conversion between the GraphQL format (where `ref` is a String containing the CID) and the AT Protocol format (where `ref` is an object `{$link: "cid"}`). You always work with `ref` as a simple String in GraphQL queries and mutations. 922 935 923 936 ### Create Records 924 937
+1 -1
frontend-v2/schema.graphql
··· 931 931 } 932 932 933 933 type BlobUploadResponse { 934 - blob: JSON! 934 + blob: Blob! 935 935 } 936 936 937 937 type CollectionStats {
+8 -7
frontend-v2/server/profile-init.ts
··· 18 18 export async function initializeUserProfile( 19 19 userDid: string, 20 20 userHandle: string, 21 - tokens: TokenInfo 21 + tokens: TokenInfo, 22 22 ): Promise<void> { 23 23 if (!API_URL || !SLICE_URI) { 24 24 console.error("Missing API_URL or VITE_SLICE_URI environment variables"); ··· 26 26 } 27 27 28 28 try { 29 - const graphqlUrl = `${API_URL}/graphql?slice=${encodeURIComponent(SLICE_URI)}`; 29 + const graphqlUrl = `${API_URL}/graphql?slice=${ 30 + encodeURIComponent(SLICE_URI) 31 + }`; 30 32 const authHeader = `${tokens.tokenType} ${tokens.accessToken}`; 31 33 32 34 // 1. Check if profile already exists ··· 132 134 }); 133 135 134 136 if (!bskyResponse.ok) { 135 - throw new Error(`Fetch Bluesky profile failed: ${bskyResponse.statusText}`); 137 + throw new Error( 138 + `Fetch Bluesky profile failed: ${bskyResponse.statusText}`, 139 + ); 136 140 } 137 141 138 142 const bskyData = await bskyResponse.json(); ··· 160 164 ) { 161 165 // Reconstruct blob format for AT Protocol 162 166 profileInput.avatar = { 163 - $type: "blob", 164 - ref: { 165 - $link: bskyProfile.avatar.ref, 166 - }, 167 + ref: bskyProfile.avatar.ref, 167 168 mimeType: bskyProfile.avatar.mimeType, 168 169 size: bskyProfile.avatar.size, 169 170 };
+35 -6
frontend-v2/src/__generated__/ProfileSettingsUploadBlobMutation.graphql.ts
··· 1 1 /** 2 - * @generated SignedSource<<a2334c7e93bb6d5b4748df1211a418ae>> 2 + * @generated SignedSource<<728b9a3525f975b6c58a5cdcd323f89e>> 3 3 * @lightSyntaxTransform 4 4 * @nogrep 5 5 */ ··· 15 15 }; 16 16 export type ProfileSettingsUploadBlobMutation$data = { 17 17 readonly uploadBlob: { 18 - readonly blob: any; 18 + readonly blob: { 19 + readonly mimeType: string; 20 + readonly ref: string; 21 + readonly size: number; 22 + }; 19 23 }; 20 24 }; 21 25 export type ProfileSettingsUploadBlobMutation = { ··· 59 63 { 60 64 "alias": null, 61 65 "args": null, 62 - "kind": "ScalarField", 66 + "concreteType": "Blob", 67 + "kind": "LinkedField", 63 68 "name": "blob", 69 + "plural": false, 70 + "selections": [ 71 + { 72 + "alias": null, 73 + "args": null, 74 + "kind": "ScalarField", 75 + "name": "ref", 76 + "storageKey": null 77 + }, 78 + { 79 + "alias": null, 80 + "args": null, 81 + "kind": "ScalarField", 82 + "name": "mimeType", 83 + "storageKey": null 84 + }, 85 + { 86 + "alias": null, 87 + "args": null, 88 + "kind": "ScalarField", 89 + "name": "size", 90 + "storageKey": null 91 + } 92 + ], 64 93 "storageKey": null 65 94 } 66 95 ], ··· 85 114 "selections": (v1/*: any*/) 86 115 }, 87 116 "params": { 88 - "cacheID": "3a4a6b19d2898f14635b098941614cab", 117 + "cacheID": "afd8db2ee7590308e81afc0b0e5c86dd", 89 118 "id": null, 90 119 "metadata": {}, 91 120 "name": "ProfileSettingsUploadBlobMutation", 92 121 "operationKind": "mutation", 93 - "text": "mutation ProfileSettingsUploadBlobMutation(\n $data: String!\n $mimeType: String!\n) {\n uploadBlob(data: $data, mimeType: $mimeType) {\n blob\n }\n}\n" 122 + "text": "mutation ProfileSettingsUploadBlobMutation(\n $data: String!\n $mimeType: String!\n) {\n uploadBlob(data: $data, mimeType: $mimeType) {\n blob {\n ref\n mimeType\n size\n }\n }\n}\n" 94 123 } 95 124 }; 96 125 })(); 97 126 98 - (node as any).hash = "76da65b07a282ed7f2dee12b4cac82d6"; 127 + (node as any).hash = "74a3a8bf43181cd62d2e81c45be384e5"; 99 128 100 129 export default node;
+18 -13
frontend-v2/src/pages/ProfileSettings.tsx
··· 1 - import { useParams, Link } from "react-router-dom"; 1 + import { Link, useParams } from "react-router-dom"; 2 2 import { useState } from "react"; 3 3 import { graphql, useLazyLoadQuery, useMutation } from "react-relay"; 4 4 import type { ProfileSettingsQuery } from "../__generated__/ProfileSettingsQuery.graphql.ts"; ··· 44 44 where: { 45 45 actorHandle: { eq: handle }, 46 46 }, 47 - } 47 + }, 48 48 ); 49 49 50 50 const profile = data.networkSlicesActorProfiles.edges[0]?.node; ··· 59 59 graphql` 60 60 mutation ProfileSettingsUploadBlobMutation($data: String!, $mimeType: String!) { 61 61 uploadBlob(data: $data, mimeType: $mimeType) { 62 - blob 62 + blob { 63 + ref 64 + mimeType 65 + size 66 + } 63 67 } 64 68 } 65 - ` 69 + `, 66 70 ); 67 71 68 72 const [commitUpdateProfile, isUpdatingProfile] = useMutation( ··· 80 84 } 81 85 } 82 86 } 83 - ` 87 + `, 84 88 ); 85 89 86 90 const [commitCreateProfile, isCreatingProfile] = useMutation( ··· 98 102 } 99 103 } 100 104 } 101 - ` 105 + `, 102 106 ); 103 107 104 108 // Helper to convert File to base64 ··· 108 112 reader.onload = () => { 109 113 const arrayBuffer = reader.result as ArrayBuffer; 110 114 const bytes = new Uint8Array(arrayBuffer); 111 - const binary = Array.from(bytes).map(b => String.fromCharCode(b)).join(''); 115 + const binary = Array.from(bytes).map((b) => String.fromCharCode(b)) 116 + .join(""); 112 117 resolve(btoa(binary)); 113 118 }; 114 119 reader.onerror = reject; ··· 129 134 // Upload new avatar 130 135 const base64Data = await fileToBase64(avatarFile); 131 136 132 - const uploadResult = await new Promise<{ uploadBlob: { blob: unknown } }>((resolve, reject) => { 137 + const uploadResult = await new Promise< 138 + { uploadBlob: { blob: unknown } } 139 + >((resolve, reject) => { 133 140 commitUploadBlob({ 134 141 variables: { 135 142 data: base64Data, 136 143 mimeType: avatarFile.type, 137 144 }, 138 - onCompleted: (data) => resolve(data as { uploadBlob: { blob: unknown } }), 145 + onCompleted: (data) => 146 + resolve(data as { uploadBlob: { blob: unknown } }), 139 147 onError: (error) => reject(error), 140 148 }); 141 149 }); ··· 144 152 } else if (profile?.avatar) { 145 153 // Keep existing avatar - reconstruct blob with $type field for AT Protocol 146 154 avatarBlob = { 147 - $type: "blob", 148 - ref: { 149 - $link: profile.avatar.ref, 150 - }, 155 + ref: profile.avatar.ref, 151 156 mimeType: profile.avatar.mimeType, 152 157 size: profile.avatar.size, 153 158 };
+11 -11
packages/session/src/adapters/postgres.ts
··· 6 6 user_id: string; 7 7 handle: string | null; 8 8 is_authenticated: boolean; 9 - data: string | null; 10 - created_at: Date; 11 - expires_at: Date; 12 - last_accessed_at: Date; 9 + data: Record<string, unknown> | null; 10 + created_at: number; 11 + expires_at: number; 12 + last_accessed_at: number; 13 13 } 14 14 15 15 export class PostgresAdapter implements SessionAdapter { ··· 100 100 data.userId, 101 101 data.handle || null, 102 102 data.isAuthenticated, 103 - data.data ? JSON.stringify(data.data) : null, 103 + data.data || null, 104 104 data.createdAt, 105 105 data.expiresAt, 106 106 data.lastAccessedAt, ··· 116 116 updates: Partial<SessionData> 117 117 ): Promise<boolean> { 118 118 const setParts: string[] = []; 119 - const values: (string | number | boolean | null)[] = []; 119 + const values: (string | number | boolean | null | Record<string, unknown>)[] = []; 120 120 let paramIndex = 1; 121 121 122 122 if (updates.userId !== undefined) { ··· 136 136 137 137 if (updates.data !== undefined) { 138 138 setParts.push(`data = $${paramIndex++}`); 139 - values.push(updates.data ? JSON.stringify(updates.data) : null); 139 + values.push(updates.data || null); 140 140 } 141 141 142 142 if (updates.expiresAt !== undefined) { ··· 226 226 userId: row.user_id, 227 227 handle: row.handle || undefined, 228 228 isAuthenticated: row.is_authenticated, 229 - data: row.data ? JSON.parse(row.data) : undefined, 230 - createdAt: row.created_at.getTime(), 231 - expiresAt: row.expires_at.getTime(), 232 - lastAccessedAt: row.last_accessed_at.getTime(), 229 + data: row.data || undefined, 230 + createdAt: row.created_at, 231 + expiresAt: row.expires_at, 232 + lastAccessedAt: row.last_accessed_at, 233 233 }; 234 234 } 235 235