+480
-8
api/src/graphql/schema_builder.rs
+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
+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
+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
+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
+1
-1
frontend-v2/schema.graphql
+8
-7
frontend-v2/server/profile-init.ts
+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
+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
+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
+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