Auto-indexing service and GraphQL API for AT Protocol Records
0
fork

Configure Feed

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

feat: improve GraphQL type generation for lexicons

## Union Types
- Parse `refs` field from properties for union types
- Generate union types for property and array item unions
- Implement $type-based runtime type resolution
- Use contextual naming: `{ParentType}{Field}Union`

## Object Types
- Build object types from lexicon `#fragment` definitions
- Sort refs to build dependencies first (topological ordering)
- Add placeholder field for empty object types
- Expand local refs (#mention -> full NSID)

## Blob Handling
- Inject DID into blob objects for CDN URL generation
- Propagate DID through nested objects and arrays

## Array Fields
- Expand shorthand refs in array items
- Resolve array item refs to proper GraphQL types
- Handle union arrays with multiple item types

## Testing & Examples
- 199 tests for new functionality
- Add 02-following-feed example

+9562 -73
+389
dev-docs/plans/2025-12-03-array-fields-in-object-types.md
··· 1 + # Array Fields in Object Types Implementation Plan 2 + 3 + > **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. 4 + 5 + **Goal:** Make array fields in object types (like `AppBskyEmbedImages.images`) resolve to proper array types instead of `String`. 6 + 7 + **Architecture:** Pass lexicon ID context through object type building so shorthand refs (`#image`) can be expanded to fully-qualified refs (`app.bsky.embed.images#image`) before lookup in the object types dictionary. 8 + 9 + **Tech Stack:** Gleam, swell (GraphQL library), lexicon_graphql package 10 + 11 + --- 12 + 13 + ### Task 1: Add ref expansion helper to object_builder 14 + 15 + **Files:** 16 + - Modify: `lexicon_graphql/src/lexicon_graphql/internal/graphql/object_builder.gleam` 17 + 18 + **Step 1: Write the helper function** 19 + 20 + Add at the bottom of the file before the closing: 21 + 22 + ```gleam 23 + /// Expand a shorthand ref to fully-qualified ref 24 + /// "#image" with lexicon_id "app.bsky.embed.images" -> "app.bsky.embed.images#image" 25 + fn expand_ref(ref: String, lexicon_id: String) -> String { 26 + case string.starts_with(ref, "#") { 27 + True -> lexicon_id <> ref 28 + False -> ref 29 + } 30 + } 31 + ``` 32 + 33 + **Step 2: Add string import if not present** 34 + 35 + Check imports at top of file, add if missing: 36 + ```gleam 37 + import gleam/string 38 + ``` 39 + 40 + **Step 3: Run build to verify it compiles** 41 + 42 + Run: `cd /Users/chadmiller/code/quickslice/lexicon_graphql && gleam build` 43 + Expected: Compiles with no errors (warning about unused function is OK) 44 + 45 + **Step 4: Commit** 46 + 47 + ```bash 48 + git add lexicon_graphql/src/lexicon_graphql/internal/graphql/object_builder.gleam 49 + git commit -m "feat(object_builder): add expand_ref helper for shorthand refs" 50 + ``` 51 + 52 + --- 53 + 54 + ### Task 2: Add lexicon ID parser to registry 55 + 56 + **Files:** 57 + - Modify: `lexicon_graphql/src/lexicon_graphql/internal/lexicon/registry.gleam` 58 + 59 + **Step 1: Add the helper function** 60 + 61 + Add after the existing `parse_ref` function: 62 + 63 + ```gleam 64 + /// Extract lexicon ID from a fully-qualified ref 65 + /// "app.bsky.embed.images#image" -> "app.bsky.embed.images" 66 + /// "app.bsky.embed.images" -> "app.bsky.embed.images" 67 + pub fn lexicon_id_from_ref(ref: String) -> String { 68 + case string.split(ref, "#") { 69 + [lexicon_id, _] -> lexicon_id 70 + _ -> ref 71 + } 72 + } 73 + ``` 74 + 75 + **Step 2: Run build to verify it compiles** 76 + 77 + Run: `cd /Users/chadmiller/code/quickslice/lexicon_graphql && gleam build` 78 + Expected: Compiles with no errors 79 + 80 + **Step 3: Commit** 81 + 82 + ```bash 83 + git add lexicon_graphql/src/lexicon_graphql/internal/lexicon/registry.gleam 84 + git commit -m "feat(registry): add lexicon_id_from_ref helper" 85 + ``` 86 + 87 + --- 88 + 89 + ### Task 3: Add array items expansion helper to object_builder 90 + 91 + **Files:** 92 + - Modify: `lexicon_graphql/src/lexicon_graphql/internal/graphql/object_builder.gleam` 93 + 94 + **Step 1: Add the helper function** 95 + 96 + Add after `expand_ref`: 97 + 98 + ```gleam 99 + /// Expand shorthand refs in ArrayItems 100 + fn expand_array_items( 101 + items: types.ArrayItems, 102 + lexicon_id: String, 103 + ) -> types.ArrayItems { 104 + types.ArrayItems( 105 + type_: items.type_, 106 + ref: option.map(items.ref, fn(r) { expand_ref(r, lexicon_id) }), 107 + refs: option.map(items.refs, fn(rs) { 108 + list.map(rs, fn(r) { expand_ref(r, lexicon_id) }) 109 + }), 110 + ) 111 + } 112 + ``` 113 + 114 + **Step 2: Run build to verify it compiles** 115 + 116 + Run: `cd /Users/chadmiller/code/quickslice/lexicon_graphql && gleam build` 117 + Expected: Compiles with no errors (warning about unused function is OK) 118 + 119 + **Step 3: Commit** 120 + 121 + ```bash 122 + git add lexicon_graphql/src/lexicon_graphql/internal/graphql/object_builder.gleam 123 + git commit -m "feat(object_builder): add expand_array_items helper" 124 + ``` 125 + 126 + --- 127 + 128 + ### Task 4: Update build_object_fields to accept lexicon_id 129 + 130 + **Files:** 131 + - Modify: `lexicon_graphql/src/lexicon_graphql/internal/graphql/object_builder.gleam` 132 + 133 + **Step 1: Update function signature** 134 + 135 + Change `build_object_fields` from: 136 + ```gleam 137 + fn build_object_fields( 138 + properties: List(#(String, types.Property)), 139 + object_types_dict: Dict(String, schema.Type), 140 + ) -> List(schema.Field) { 141 + ``` 142 + 143 + To: 144 + ```gleam 145 + fn build_object_fields( 146 + properties: List(#(String, types.Property)), 147 + lexicon_id: String, 148 + object_types_dict: Dict(String, schema.Type), 149 + ) -> List(schema.Field) { 150 + ``` 151 + 152 + **Step 2: Run build to see call site errors** 153 + 154 + Run: `cd /Users/chadmiller/code/quickslice/lexicon_graphql && gleam build` 155 + Expected: Error about missing argument in `build_object_type` 156 + 157 + **Step 3: Commit (WIP)** 158 + 159 + ```bash 160 + git add lexicon_graphql/src/lexicon_graphql/internal/graphql/object_builder.gleam 161 + git commit -m "wip: update build_object_fields signature to accept lexicon_id" 162 + ``` 163 + 164 + --- 165 + 166 + ### Task 5: Update build_object_type to pass lexicon_id 167 + 168 + **Files:** 169 + - Modify: `lexicon_graphql/src/lexicon_graphql/internal/graphql/object_builder.gleam` 170 + 171 + **Step 1: Update function signature** 172 + 173 + Change `build_object_type` from: 174 + ```gleam 175 + pub fn build_object_type( 176 + obj_def: types.ObjectDef, 177 + type_name: String, 178 + object_types_dict: Dict(String, schema.Type), 179 + ) -> schema.Type { 180 + let fields = build_object_fields(obj_def.properties, object_types_dict) 181 + ``` 182 + 183 + To: 184 + ```gleam 185 + pub fn build_object_type( 186 + obj_def: types.ObjectDef, 187 + type_name: String, 188 + lexicon_id: String, 189 + object_types_dict: Dict(String, schema.Type), 190 + ) -> schema.Type { 191 + let fields = build_object_fields(obj_def.properties, lexicon_id, object_types_dict) 192 + ``` 193 + 194 + **Step 2: Run build to see call site errors** 195 + 196 + Run: `cd /Users/chadmiller/code/quickslice/lexicon_graphql && gleam build` 197 + Expected: Error about missing argument in `build_all_object_types` 198 + 199 + **Step 3: Commit (WIP)** 200 + 201 + ```bash 202 + git add lexicon_graphql/src/lexicon_graphql/internal/graphql/object_builder.gleam 203 + git commit -m "wip: update build_object_type signature to accept lexicon_id" 204 + ``` 205 + 206 + --- 207 + 208 + ### Task 6: Update build_all_object_types to extract and pass lexicon_id 209 + 210 + **Files:** 211 + - Modify: `lexicon_graphql/src/lexicon_graphql/internal/graphql/object_builder.gleam` 212 + 213 + **Step 1: Update the fold body** 214 + 215 + Change in `build_all_object_types`: 216 + ```gleam 217 + list.fold(object_refs, dict.new(), fn(acc, ref) { 218 + case lexicon_registry.get_object_def(registry, ref) { 219 + option.Some(obj_def) -> { 220 + let type_name = ref_to_type_name(ref) 221 + let object_type = build_object_type(obj_def, type_name, acc) 222 + dict.insert(acc, ref, object_type) 223 + } 224 + option.None -> acc 225 + } 226 + }) 227 + ``` 228 + 229 + To: 230 + ```gleam 231 + list.fold(object_refs, dict.new(), fn(acc, ref) { 232 + case lexicon_registry.get_object_def(registry, ref) { 233 + option.Some(obj_def) -> { 234 + let type_name = ref_to_type_name(ref) 235 + let lexicon_id = lexicon_registry.lexicon_id_from_ref(ref) 236 + let object_type = build_object_type(obj_def, type_name, lexicon_id, acc) 237 + dict.insert(acc, ref, object_type) 238 + } 239 + option.None -> acc 240 + } 241 + }) 242 + ``` 243 + 244 + **Step 2: Run build to verify it compiles** 245 + 246 + Run: `cd /Users/chadmiller/code/quickslice/lexicon_graphql && gleam build` 247 + Expected: Compiles with no errors 248 + 249 + **Step 3: Commit** 250 + 251 + ```bash 252 + git add lexicon_graphql/src/lexicon_graphql/internal/graphql/object_builder.gleam 253 + git commit -m "feat(object_builder): pass lexicon_id through build chain" 254 + ``` 255 + 256 + --- 257 + 258 + ### Task 7: Handle array types in build_object_fields 259 + 260 + **Files:** 261 + - Modify: `lexicon_graphql/src/lexicon_graphql/internal/graphql/object_builder.gleam` 262 + 263 + **Step 1: Update the property mapping** 264 + 265 + Change in `build_object_fields`: 266 + ```gleam 267 + list.map(properties, fn(prop) { 268 + let #(name, types.Property(type_, required, format, ref, _refs, _items)) = 269 + prop 270 + 271 + // Map the type, using the object_types_dict to resolve refs 272 + let graphql_type = 273 + type_mapper.map_type_with_registry(type_, format, ref, object_types_dict) 274 + ``` 275 + 276 + To: 277 + ```gleam 278 + list.map(properties, fn(prop) { 279 + let #(name, types.Property(type_, required, format, ref, _refs, items)) = 280 + prop 281 + 282 + // Map the type, handling arrays specially to resolve item refs 283 + let graphql_type = case type_ { 284 + "array" -> { 285 + let expanded_items = case items { 286 + option.Some(arr_items) -> 287 + option.Some(expand_array_items(arr_items, lexicon_id)) 288 + option.None -> option.None 289 + } 290 + type_mapper.map_array_type(expanded_items, object_types_dict) 291 + } 292 + _ -> type_mapper.map_type_with_registry(type_, format, ref, object_types_dict) 293 + } 294 + ``` 295 + 296 + **Step 2: Run build to verify it compiles** 297 + 298 + Run: `cd /Users/chadmiller/code/quickslice/lexicon_graphql && gleam build` 299 + Expected: Compiles with no errors 300 + 301 + **Step 3: Run tests** 302 + 303 + Run: `cd /Users/chadmiller/code/quickslice/lexicon_graphql && gleam test` 304 + Expected: All tests pass 305 + 306 + **Step 4: Commit** 307 + 308 + ```bash 309 + git add lexicon_graphql/src/lexicon_graphql/internal/graphql/object_builder.gleam 310 + git commit -m "feat(object_builder): handle array types with expanded refs" 311 + ``` 312 + 313 + --- 314 + 315 + ### Task 8: Rebuild server and verify fix 316 + 317 + **Files:** 318 + - None (verification only) 319 + 320 + **Step 1: Rebuild server** 321 + 322 + Run: `cd /Users/chadmiller/code/quickslice/server && gleam build` 323 + Expected: Compiles with no errors 324 + 325 + **Step 2: Restart server and test via MCP** 326 + 327 + After restarting the server, run introspection query: 328 + ```graphql 329 + { 330 + __type(name: "AppBskyEmbedImages") { 331 + fields { 332 + name 333 + type { kind name ofType { kind name } } 334 + } 335 + } 336 + } 337 + ``` 338 + 339 + Expected: `images` field should have type `LIST` with `ofType` containing `AppBskyEmbedImagesImage` 340 + 341 + **Step 3: Verify nested type exists** 342 + 343 + ```graphql 344 + { 345 + __type(name: "AppBskyEmbedImagesImage") { 346 + name 347 + fields { name } 348 + } 349 + } 350 + ``` 351 + 352 + Expected: Type exists with fields `alt`, `image`, `aspectRatio` 353 + 354 + **Step 4: Final commit** 355 + 356 + ```bash 357 + git add -A 358 + git commit -m "feat: array fields in object types resolve to proper types 359 + 360 + - Pass lexicon ID through object type building chain 361 + - Expand shorthand refs (#image -> lexicon#image) at build time 362 + - Handle array types by calling map_array_type with expanded items 363 + - Enables querying embed images with proper nested types" 364 + ``` 365 + 366 + --- 367 + 368 + ## Verification Query 369 + 370 + After implementation, this query should work: 371 + 372 + ```graphql 373 + { 374 + appBskyFeedPost { 375 + edges { 376 + node { 377 + embed { 378 + ... on AppBskyEmbedImages { 379 + images { 380 + alt 381 + aspectRatio 382 + } 383 + } 384 + } 385 + } 386 + } 387 + } 388 + } 389 + ```
+697
dev-docs/plans/2025-12-03-local-ref-resolution.md
··· 1 + # Local Ref Resolution Implementation Plan 2 + 3 + > **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. 4 + 5 + **Goal:** Fix local refs (like `#replyRef`) being resolved as `String` instead of their proper object types. 6 + 7 + **Architecture:** Add `lexicon_id` parameter to `map_property_type_with_context` and expand local refs (refs starting with `#`) to fully-qualified refs before looking them up in the object types dict. 8 + 9 + **Tech Stack:** Gleam, lexicon_graphql library, swell GraphQL library 10 + 11 + --- 12 + 13 + ## Background 14 + 15 + Local refs in AT Protocol lexicons use the format `#defName` to reference definitions within the same lexicon. For example, in `app.bsky.feed.post`: 16 + 17 + ```json 18 + "reply": { 19 + "ref": "#replyRef", 20 + "type": "ref" 21 + } 22 + ``` 23 + 24 + The `replyRef` object is defined in the same lexicon's `defs.others`. The object types dict is keyed by fully-qualified refs like `app.bsky.feed.post#replyRef`, but the lookup is using the raw `#replyRef`, causing a miss and fallback to `String`. 25 + 26 + --- 27 + 28 + ### Task 1: Add failing test for local ref resolution 29 + 30 + **Files:** 31 + - Create: `lexicon_graphql/test/local_ref_test.gleam` 32 + 33 + **Step 1: Write the failing test** 34 + 35 + ```gleam 36 + /// Tests for local ref resolution in schema builder 37 + /// 38 + /// Verifies that refs like "#replyRef" resolve to their object types 39 + import gleam/dict 40 + import gleam/option.{None, Some} 41 + import gleam/string 42 + import gleeunit/should 43 + import lexicon_graphql/schema/builder 44 + import lexicon_graphql/types 45 + import swell/introspection 46 + import swell/sdl 47 + 48 + /// Test that a local ref (starting with #) resolves to its object type 49 + pub fn local_ref_resolves_to_object_type_test() { 50 + // Create a lexicon with a record that has a local ref field 51 + let lexicon = 52 + types.Lexicon( 53 + id: "app.bsky.feed.post", 54 + defs: types.Defs( 55 + main: Some( 56 + types.RecordDef(type_: "record", key: None, properties: [ 57 + #( 58 + "text", 59 + types.Property( 60 + type_: "string", 61 + required: True, 62 + format: None, 63 + ref: None, 64 + refs: None, 65 + items: None, 66 + ), 67 + ), 68 + #( 69 + "reply", 70 + types.Property( 71 + type_: "ref", 72 + required: False, 73 + format: None, 74 + ref: Some("#replyRef"), // Local ref! 75 + refs: None, 76 + items: None, 77 + ), 78 + ), 79 + ]), 80 + ), 81 + others: dict.from_list([ 82 + #( 83 + "replyRef", 84 + types.Object( 85 + types.ObjectDef(type_: "object", required_fields: [], properties: [ 86 + #( 87 + "root", 88 + types.Property( 89 + type_: "string", 90 + required: True, 91 + format: None, 92 + ref: None, 93 + refs: None, 94 + items: None, 95 + ), 96 + ), 97 + #( 98 + "parent", 99 + types.Property( 100 + type_: "string", 101 + required: True, 102 + format: None, 103 + ref: None, 104 + refs: None, 105 + items: None, 106 + ), 107 + ), 108 + ]), 109 + ), 110 + ), 111 + ]), 112 + ), 113 + ) 114 + 115 + let result = builder.build_schema([lexicon]) 116 + should.be_ok(result) 117 + 118 + case result { 119 + Ok(schema_val) -> { 120 + let all_types = introspection.get_all_schema_types(schema_val) 121 + let serialized = sdl.print_types(all_types) 122 + 123 + // The replyRef object type should exist 124 + string.contains(serialized, "type AppBskyFeedPostReplyRef") 125 + |> should.be_true 126 + 127 + // The reply field should be AppBskyFeedPostReplyRef, NOT String 128 + string.contains(serialized, "reply: AppBskyFeedPostReplyRef") 129 + |> should.be_true 130 + } 131 + Error(_) -> should.fail() 132 + } 133 + } 134 + 135 + /// Test that local refs in arrays also resolve correctly 136 + pub fn local_ref_in_array_resolves_to_object_type_test() { 137 + let lexicon = 138 + types.Lexicon( 139 + id: "app.bsky.feed.post", 140 + defs: types.Defs( 141 + main: Some( 142 + types.RecordDef(type_: "record", key: None, properties: [ 143 + #( 144 + "text", 145 + types.Property( 146 + type_: "string", 147 + required: True, 148 + format: None, 149 + ref: None, 150 + refs: None, 151 + items: None, 152 + ), 153 + ), 154 + #( 155 + "entities", 156 + types.Property( 157 + type_: "array", 158 + required: False, 159 + format: None, 160 + ref: None, 161 + refs: None, 162 + items: Some(types.ArrayItems( 163 + type_: "ref", 164 + ref: Some("#entity"), // Local ref in array! 165 + refs: None, 166 + )), 167 + ), 168 + ), 169 + ]), 170 + ), 171 + others: dict.from_list([ 172 + #( 173 + "entity", 174 + types.Object( 175 + types.ObjectDef(type_: "object", required_fields: [], properties: [ 176 + #( 177 + "type", 178 + types.Property( 179 + type_: "string", 180 + required: True, 181 + format: None, 182 + ref: None, 183 + refs: None, 184 + items: None, 185 + ), 186 + ), 187 + #( 188 + "value", 189 + types.Property( 190 + type_: "string", 191 + required: True, 192 + format: None, 193 + ref: None, 194 + refs: None, 195 + items: None, 196 + ), 197 + ), 198 + ]), 199 + ), 200 + ), 201 + ]), 202 + ), 203 + ) 204 + 205 + let result = builder.build_schema([lexicon]) 206 + should.be_ok(result) 207 + 208 + case result { 209 + Ok(schema_val) -> { 210 + let all_types = introspection.get_all_schema_types(schema_val) 211 + let serialized = sdl.print_types(all_types) 212 + 213 + // The entity object type should exist 214 + string.contains(serialized, "type AppBskyFeedPostEntity") 215 + |> should.be_true 216 + 217 + // The entities field should be [AppBskyFeedPostEntity!], NOT [String!] 218 + string.contains(serialized, "entities: [AppBskyFeedPostEntity!]") 219 + |> should.be_true 220 + } 221 + Error(_) -> should.fail() 222 + } 223 + } 224 + ``` 225 + 226 + **Step 2: Run test to verify it fails** 227 + 228 + Run: `cd /Users/chadmiller/code/quickslice/lexicon_graphql && gleam test` 229 + 230 + Expected: FAIL - `reply: String` instead of `reply: AppBskyFeedPostReplyRef` 231 + 232 + **Step 3: Commit failing test** 233 + 234 + ```bash 235 + git add lexicon_graphql/test/local_ref_test.gleam 236 + git commit -m "test: add failing tests for local ref resolution" 237 + ``` 238 + 239 + --- 240 + 241 + ### Task 2: Add expand_ref helper to type_mapper 242 + 243 + **Files:** 244 + - Modify: `lexicon_graphql/src/lexicon_graphql/internal/graphql/type_mapper.gleam` 245 + 246 + **Step 1: Add expand_ref function at end of file** 247 + 248 + Add after line 338 (after `capitalize_first` function): 249 + 250 + ```gleam 251 + /// Expand a local ref to a fully-qualified ref 252 + /// "#replyRef" with lexicon_id "app.bsky.feed.post" -> "app.bsky.feed.post#replyRef" 253 + /// External refs pass through unchanged 254 + pub fn expand_ref(ref: String, lexicon_id: String) -> String { 255 + case string.starts_with(ref, "#") { 256 + True -> lexicon_id <> ref 257 + False -> ref 258 + } 259 + } 260 + ``` 261 + 262 + **Step 2: Run tests to verify no regression** 263 + 264 + Run: `cd /Users/chadmiller/code/quickslice/lexicon_graphql && gleam test` 265 + 266 + Expected: All existing tests pass (new function not yet used) 267 + 268 + **Step 3: Commit** 269 + 270 + ```bash 271 + git add lexicon_graphql/src/lexicon_graphql/internal/graphql/type_mapper.gleam 272 + git commit -m "feat(type_mapper): add expand_ref helper for local refs" 273 + ``` 274 + 275 + --- 276 + 277 + ### Task 3: Update map_property_type_with_context signature 278 + 279 + **Files:** 280 + - Modify: `lexicon_graphql/src/lexicon_graphql/internal/graphql/type_mapper.gleam:240-275` 281 + 282 + **Step 1: Add lexicon_id parameter and expand refs** 283 + 284 + Replace the existing `map_property_type_with_context` function: 285 + 286 + ```gleam 287 + /// Maps a lexicon Property to a GraphQL type, with parent context for union naming. 288 + /// Handles arrays, refs, and unions with proper type resolution. 289 + /// lexicon_id is used to expand local refs (e.g., "#replyRef" -> "app.bsky.feed.post#replyRef") 290 + pub fn map_property_type_with_context( 291 + property: types.Property, 292 + object_types: Dict(String, schema.Type), 293 + parent_type_name: String, 294 + field_name: String, 295 + lexicon_id: String, 296 + ) -> schema.Type { 297 + case property.type_ { 298 + "array" -> { 299 + // Expand local refs in array items before lookup 300 + let expanded_items = case property.items { 301 + option.Some(types.ArrayItems(type_: item_type, ref: item_ref, refs: item_refs)) -> { 302 + option.Some(types.ArrayItems( 303 + type_: item_type, 304 + ref: option.map(item_ref, fn(r) { expand_ref(r, lexicon_id) }), 305 + refs: option.map(item_refs, fn(rs) { 306 + list.map(rs, fn(r) { expand_ref(r, lexicon_id) }) 307 + }), 308 + )) 309 + } 310 + option.None -> option.None 311 + } 312 + map_array_type(expanded_items, object_types) 313 + } 314 + "ref" -> { 315 + case property.ref { 316 + option.Some(ref_str) -> { 317 + // Expand local ref before lookup 318 + let full_ref = expand_ref(ref_str, lexicon_id) 319 + case dict.get(object_types, full_ref) { 320 + Ok(obj_type) -> obj_type 321 + Error(_) -> schema.string_type() 322 + } 323 + } 324 + option.None -> schema.string_type() 325 + } 326 + } 327 + "union" -> { 328 + case property.refs { 329 + option.Some(refs) -> { 330 + // Expand local refs in union before lookup 331 + let expanded_refs = list.map(refs, fn(r) { expand_ref(r, lexicon_id) }) 332 + build_property_union_type( 333 + expanded_refs, 334 + object_types, 335 + parent_type_name, 336 + field_name, 337 + ) 338 + } 339 + option.None -> schema.string_type() 340 + } 341 + } 342 + _ -> map_type(property.type_) 343 + } 344 + } 345 + ``` 346 + 347 + **Step 2: Add list import if not present** 348 + 349 + Check the imports at the top of the file and add `import gleam/list` if not already there. 350 + 351 + **Step 3: Run tests (expect failures from callers)** 352 + 353 + Run: `cd /Users/chadmiller/code/quickslice/lexicon_graphql && gleam build` 354 + 355 + Expected: Compile error - callers missing new `lexicon_id` argument 356 + 357 + **Step 4: Commit (WIP)** 358 + 359 + ```bash 360 + git add lexicon_graphql/src/lexicon_graphql/internal/graphql/type_mapper.gleam 361 + git commit -m "feat(type_mapper): add lexicon_id param to expand local refs (WIP)" 362 + ``` 363 + 364 + --- 365 + 366 + ### Task 4: Update map_property_type to pass empty lexicon_id 367 + 368 + **Files:** 369 + - Modify: `lexicon_graphql/src/lexicon_graphql/internal/graphql/type_mapper.gleam:231-238` 370 + 371 + **Step 1: Update map_property_type wrapper** 372 + 373 + Replace the existing `map_property_type` function: 374 + 375 + ```gleam 376 + /// Maps a lexicon Property to a GraphQL type. 377 + /// Handles arrays specially by looking at the items field. 378 + /// Note: This version doesn't expand local refs. Use map_property_type_with_context 379 + /// with lexicon_id for proper local ref resolution. 380 + pub fn map_property_type( 381 + property: types.Property, 382 + object_types: Dict(String, schema.Type), 383 + ) -> schema.Type { 384 + map_property_type_with_context(property, object_types, "", "", "") 385 + } 386 + ``` 387 + 388 + **Step 2: Run build** 389 + 390 + Run: `cd /Users/chadmiller/code/quickslice/lexicon_graphql && gleam build` 391 + 392 + Expected: Still compile errors from other callers 393 + 394 + --- 395 + 396 + ### Task 5: Update builder.gleam callers 397 + 398 + **Files:** 399 + - Modify: `lexicon_graphql/src/lexicon_graphql/schema/builder.gleam` 400 + 401 + **Step 1: Update build_fields_with_context to accept and pass lexicon_id** 402 + 403 + Find `build_fields_with_context` (around line 119) and update it: 404 + 405 + ```gleam 406 + /// Build GraphQL fields from lexicon properties with parent type context 407 + fn build_fields_with_context( 408 + properties: List(#(String, Property)), 409 + ref_object_types: dict.Dict(String, schema.Type), 410 + parent_type_name: String, 411 + lexicon_id: String, 412 + ) -> List(schema.Field) { 413 + // Add standard AT Proto fields 414 + let standard_fields = [ 415 + schema.field("uri", schema.string_type(), "Record URI", fn(_ctx) { 416 + Ok(value.String("at://did:plc:example/collection/rkey")) 417 + }), 418 + schema.field("cid", schema.string_type(), "Record CID", fn(_ctx) { 419 + Ok(value.String("bafyreicid")) 420 + }), 421 + schema.field("did", schema.string_type(), "DID of record author", fn(_ctx) { 422 + Ok(value.String("did:plc:example")) 423 + }), 424 + schema.field( 425 + "indexedAt", 426 + schema.string_type(), 427 + "When record was indexed", 428 + fn(_ctx) { Ok(value.String("2024-01-01T00:00:00Z")) }, 429 + ), 430 + ] 431 + 432 + // Build fields from lexicon properties with context 433 + let lexicon_fields = 434 + list.map(properties, fn(prop) { 435 + let #(name, property) = prop 436 + let graphql_type = 437 + type_mapper.map_property_type_with_context( 438 + property, 439 + ref_object_types, 440 + parent_type_name, 441 + name, 442 + lexicon_id, 443 + ) 444 + 445 + schema.field(name, graphql_type, "Field from lexicon", fn(_ctx) { 446 + Ok(value.Null) 447 + }) 448 + }) 449 + 450 + // Combine standard and lexicon fields 451 + list.append(standard_fields, lexicon_fields) 452 + } 453 + ``` 454 + 455 + **Step 2: Update parse_lexicon to pass lexicon_id** 456 + 457 + Find `parse_lexicon` (around line 93) and update the call: 458 + 459 + ```gleam 460 + /// Parse a single lexicon into a RecordType 461 + fn parse_lexicon( 462 + lexicon: Lexicon, 463 + ref_object_types: dict.Dict(String, schema.Type), 464 + ) -> Result(RecordType, Nil) { 465 + case lexicon { 466 + types.Lexicon( 467 + id, 468 + types.Defs(option.Some(types.RecordDef("record", _, properties)), _), 469 + ) -> { 470 + let type_name = nsid.to_type_name(id) 471 + let field_name = nsid.to_field_name(id) 472 + let fields = 473 + build_fields_with_context(properties, ref_object_types, type_name, id) 474 + 475 + Ok(RecordType( 476 + nsid: id, 477 + type_name: type_name, 478 + field_name: field_name, 479 + fields: fields, 480 + )) 481 + } 482 + _ -> Error(Nil) 483 + } 484 + } 485 + ``` 486 + 487 + **Step 3: Update build_object_type_fields to pass lexicon_id** 488 + 489 + Find `build_object_type_fields` (around line 268) and update it: 490 + 491 + ```gleam 492 + /// Build fields for object types (simplified - no standard AT Proto fields) 493 + fn build_object_type_fields( 494 + properties: List(#(String, Property)), 495 + ref_object_types: dict.Dict(String, schema.Type), 496 + lexicon_id: String, 497 + ) -> List(schema.Field) { 498 + list.map(properties, fn(prop) { 499 + let #(name, property) = prop 500 + let graphql_type = type_mapper.map_property_type_with_context( 501 + property, 502 + ref_object_types, 503 + "", 504 + name, 505 + lexicon_id, 506 + ) 507 + schema.field(name, graphql_type, "Field from lexicon", fn(_ctx) { 508 + Ok(value.Null) 509 + }) 510 + }) 511 + } 512 + ``` 513 + 514 + **Step 4: Update extract_object_type_lexicons caller** 515 + 516 + Find the call to `build_object_type_fields` in `extract_object_type_lexicons` (around line 257) and update it: 517 + 518 + ```gleam 519 + fn extract_object_type_lexicons( 520 + lexicons: List(Lexicon), 521 + ref_object_types: dict.Dict(String, schema.Type), 522 + ) -> dict.Dict(String, schema.Type) { 523 + list.fold(lexicons, dict.new(), fn(acc, lexicon) { 524 + case lexicon { 525 + types.Lexicon( 526 + id, 527 + types.Defs(option.Some(types.RecordDef("object", _, properties)), _), 528 + ) -> { 529 + let type_name = nsid.to_type_name(id) 530 + let fields = build_object_type_fields(properties, ref_object_types, id) 531 + let object_type = 532 + schema.object_type(type_name, "Object type: " <> id, fields) 533 + dict.insert(acc, id, object_type) 534 + } 535 + _ -> acc 536 + } 537 + }) 538 + } 539 + ``` 540 + 541 + **Step 5: Run build** 542 + 543 + Run: `cd /Users/chadmiller/code/quickslice/lexicon_graphql && gleam build` 544 + 545 + Expected: Build succeeds 546 + 547 + **Step 6: Commit** 548 + 549 + ```bash 550 + git add lexicon_graphql/src/lexicon_graphql/schema/builder.gleam 551 + git commit -m "feat(builder): pass lexicon_id for local ref expansion" 552 + ``` 553 + 554 + --- 555 + 556 + ### Task 6: Update type_mapper_test.gleam 557 + 558 + **Files:** 559 + - Modify: `lexicon_graphql/test/type_mapper_test.gleam` 560 + 561 + **Step 1: Update map_property_union_type_test** 562 + 563 + Find the test around line 153 and update the call to include lexicon_id: 564 + 565 + ```gleam 566 + pub fn map_property_union_type_test() { 567 + // Create object types that the union will reference 568 + let images_type = schema.object_type("AppBskyEmbedImages", "Images embed", []) 569 + let video_type = schema.object_type("AppBskyEmbedVideo", "Video embed", []) 570 + 571 + let object_types = 572 + dict.new() 573 + |> dict.insert("app.bsky.embed.images", images_type) 574 + |> dict.insert("app.bsky.embed.video", video_type) 575 + 576 + let property = 577 + types.Property( 578 + type_: "union", 579 + required: False, 580 + format: option.None, 581 + ref: option.None, 582 + refs: option.Some([ 583 + "app.bsky.embed.images", 584 + "app.bsky.embed.video", 585 + ]), 586 + items: option.None, 587 + ) 588 + 589 + let result = 590 + type_mapper.map_property_type_with_context( 591 + property, 592 + object_types, 593 + "AppBskyFeedPost", 594 + "embed", 595 + "app.bsky.feed.post", 596 + ) 597 + 598 + // Should be a union type, not String 599 + schema.type_name(result) 600 + |> should.equal("AppBskyFeedPostEmbed") 601 + } 602 + ``` 603 + 604 + **Step 2: Run tests** 605 + 606 + Run: `cd /Users/chadmiller/code/quickslice/lexicon_graphql && gleam test` 607 + 608 + Expected: Existing tests pass 609 + 610 + **Step 3: Commit** 611 + 612 + ```bash 613 + git add lexicon_graphql/test/type_mapper_test.gleam 614 + git commit -m "test(type_mapper): update test for new lexicon_id parameter" 615 + ``` 616 + 617 + --- 618 + 619 + ### Task 7: Run full test suite and verify local ref tests pass 620 + 621 + **Files:** 622 + - None (verification only) 623 + 624 + **Step 1: Run all tests** 625 + 626 + Run: `cd /Users/chadmiller/code/quickslice/lexicon_graphql && gleam test` 627 + 628 + Expected: All tests pass including the new `local_ref_test.gleam` tests 629 + 630 + **Step 2: If tests fail, debug** 631 + 632 + Check the SDL output to see what's generated. Add debug output if needed: 633 + 634 + ```gleam 635 + // Temporary debug in test 636 + io.println(serialized) 637 + ``` 638 + 639 + **Step 3: Commit final passing state** 640 + 641 + ```bash 642 + git add -A 643 + git commit -m "feat: resolve local refs (#replyRef) to object types 644 + 645 + Local refs in AT Protocol lexicons (refs starting with #) now properly 646 + resolve to their object type definitions instead of falling back to String. 647 + 648 + - Add expand_ref helper to type_mapper 649 + - Add lexicon_id parameter to map_property_type_with_context 650 + - Update builder.gleam to pass lexicon_id through the call chain 651 + - Handles both direct refs and refs within arrays 652 + 653 + Fixes: reply field in app.bsky.feed.post now resolves to 654 + AppBskyFeedPostReplyRef instead of String" 655 + ``` 656 + 657 + --- 658 + 659 + ### Task 8: Verify with MCP introspection 660 + 661 + **Files:** 662 + - None (verification only) 663 + 664 + **Step 1: Query the quickslice MCP to verify the fix** 665 + 666 + After deploying/rebuilding, use the MCP to introspect the schema: 667 + 668 + ```graphql 669 + { 670 + __type(name: "AppBskyFeedPost") { 671 + fields { 672 + name 673 + type { 674 + name 675 + kind 676 + } 677 + } 678 + } 679 + } 680 + ``` 681 + 682 + Expected: `reply` field shows `AppBskyFeedPostReplyRef` (OBJECT), not `String` (SCALAR) 683 + 684 + --- 685 + 686 + ## Summary 687 + 688 + | Task | Description | Files | 689 + |------|-------------|-------| 690 + | 1 | Add failing tests | `test/local_ref_test.gleam` | 691 + | 2 | Add `expand_ref` helper | `type_mapper.gleam` | 692 + | 3 | Update `map_property_type_with_context` signature | `type_mapper.gleam` | 693 + | 4 | Update `map_property_type` wrapper | `type_mapper.gleam` | 694 + | 5 | Update builder.gleam callers | `builder.gleam` | 695 + | 6 | Update type_mapper tests | `type_mapper_test.gleam` | 696 + | 7 | Verify all tests pass | - | 697 + | 8 | Verify with MCP | - |
+359
dev-docs/plans/2025-12-03-nested-blob-did-propagation.md
··· 1 + # Nested Blob DID Propagation Implementation Plan 2 + 3 + > **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. 4 + 5 + **Goal:** Fix `embed.images[].image.url` returning null by propagating `did` through nested object resolution. 6 + 7 + **Architecture:** Inject `did` into nested objects at record-level field resolution (database.gleam), then propagate and enrich blob fields in nested object types (object_builder.gleam). The `did` flows from record level through intermediate objects to nested blobs. 8 + 9 + **Tech Stack:** Gleam, swell (GraphQL library), lexicon_graphql package 10 + 11 + --- 12 + 13 + ### Task 1: Add `did` injection helper to database.gleam 14 + 15 + **Files:** 16 + - Modify: `lexicon_graphql/src/lexicon_graphql/schema/database.gleam` 17 + 18 + **Step 1: Add helper function after `extract_blob_data` (around line 2094)** 19 + 20 + ```gleam 21 + /// Inject DID into a value for nested blob resolution 22 + /// Recursively adds "did" field to objects that might contain blobs 23 + fn inject_did_into_value(val: value.Value, did: String) -> value.Value { 24 + case val { 25 + value.Object(fields) -> { 26 + // Add did if not already present 27 + let has_did = list.any(fields, fn(f) { f.0 == "did" }) 28 + case has_did { 29 + True -> val 30 + False -> value.Object(list.append(fields, [#("did", value.String(did))])) 31 + } 32 + } 33 + value.Array(items) -> { 34 + value.Array(list.map(items, fn(item) { inject_did_into_value(item, did) })) 35 + } 36 + _ -> val 37 + } 38 + } 39 + ``` 40 + 41 + **Step 2: Run build to verify it compiles** 42 + 43 + Run: `cd /Users/chadmiller/code/quickslice/lexicon_graphql && gleam build` 44 + Expected: Compiles with no errors (warning about unused function is OK) 45 + 46 + **Step 3: Commit** 47 + 48 + ```bash 49 + git add lexicon_graphql/src/lexicon_graphql/schema/database.gleam 50 + git commit -m "feat(database): add inject_did_into_value helper for nested blob resolution" 51 + ``` 52 + 53 + --- 54 + 55 + ### Task 2: Use `did` injection in record field resolver 56 + 57 + **Files:** 58 + - Modify: `lexicon_graphql/src/lexicon_graphql/schema/database.gleam:1278-1285` 59 + 60 + **Step 1: Update the non-blob field resolver branch** 61 + 62 + Find this code (around line 1278-1285): 63 + ```gleam 64 + _ -> { 65 + // Try to extract field from the value object in context 66 + // Use the type-safe version that preserves Int, Float, Boolean types 67 + case get_nested_field_value_from_context(ctx, "value", name) { 68 + Ok(val) -> Ok(val) 69 + Error(_) -> Ok(value.Null) 70 + } 71 + } 72 + ``` 73 + 74 + Replace with: 75 + ```gleam 76 + _ -> { 77 + // Try to extract field from the value object in context 78 + // Use the type-safe version that preserves Int, Float, Boolean types 79 + case get_nested_field_value_from_context(ctx, "value", name) { 80 + Ok(val) -> { 81 + // Inject did into nested objects for blob URL resolution 82 + let did = case get_field_from_context(ctx, "did") { 83 + Ok(d) -> d 84 + Error(_) -> "" 85 + } 86 + Ok(inject_did_into_value(val, did)) 87 + } 88 + Error(_) -> Ok(value.Null) 89 + } 90 + } 91 + ``` 92 + 93 + **Step 2: Run build to verify it compiles** 94 + 95 + Run: `cd /Users/chadmiller/code/quickslice/lexicon_graphql && gleam build` 96 + Expected: Compiles with no errors 97 + 98 + **Step 3: Run tests** 99 + 100 + Run: `cd /Users/chadmiller/code/quickslice/lexicon_graphql && gleam test` 101 + Expected: All tests pass 102 + 103 + **Step 4: Commit** 104 + 105 + ```bash 106 + git add lexicon_graphql/src/lexicon_graphql/schema/database.gleam 107 + git commit -m "feat(database): inject did into nested objects at record level" 108 + ``` 109 + 110 + --- 111 + 112 + ### Task 3: Add blob enrichment helper to object_builder.gleam 113 + 114 + **Files:** 115 + - Modify: `lexicon_graphql/src/lexicon_graphql/internal/graphql/object_builder.gleam` 116 + 117 + **Step 1: Add helper function at the end of the file (before closing)** 118 + 119 + ```gleam 120 + /// Enrich a blob value with did for URL generation 121 + /// Handles both raw blob format and AT Protocol $link format 122 + fn enrich_blob_with_did(val: value.Value, did: String) -> value.Value { 123 + case val { 124 + value.Object(fields) -> { 125 + // Extract ref - handle nested $link format from AT Protocol 126 + let ref = case list.key_find(fields, "ref") { 127 + Ok(value.Object(ref_obj)) -> { 128 + case list.key_find(ref_obj, "$link") { 129 + Ok(value.String(cid)) -> cid 130 + _ -> "" 131 + } 132 + } 133 + Ok(value.String(cid)) -> cid 134 + _ -> "" 135 + } 136 + 137 + let mime_type = case list.key_find(fields, "mimeType") { 138 + Ok(value.String(mt)) -> mt 139 + _ -> "image/jpeg" 140 + } 141 + 142 + let size = case list.key_find(fields, "size") { 143 + Ok(value.Int(s)) -> s 144 + _ -> 0 145 + } 146 + 147 + value.Object([ 148 + #("ref", value.String(ref)), 149 + #("mime_type", value.String(mime_type)), 150 + #("size", value.Int(size)), 151 + #("did", value.String(did)), 152 + ]) 153 + } 154 + _ -> val 155 + } 156 + } 157 + ``` 158 + 159 + **Step 2: Run build to verify it compiles** 160 + 161 + Run: `cd /Users/chadmiller/code/quickslice/lexicon_graphql && gleam build` 162 + Expected: Compiles with no errors (warning about unused function is OK) 163 + 164 + **Step 3: Commit** 165 + 166 + ```bash 167 + git add lexicon_graphql/src/lexicon_graphql/internal/graphql/object_builder.gleam 168 + git commit -m "feat(object_builder): add enrich_blob_with_did helper" 169 + ``` 170 + 171 + --- 172 + 173 + ### Task 4: Add `did` propagation helper to object_builder.gleam 174 + 175 + **Files:** 176 + - Modify: `lexicon_graphql/src/lexicon_graphql/internal/graphql/object_builder.gleam` 177 + 178 + **Step 1: Add helper function after enrich_blob_with_did** 179 + 180 + ```gleam 181 + /// Propagate did through nested objects and arrays 182 + fn propagate_did(val: value.Value, did: String) -> value.Value { 183 + case val { 184 + value.Object(fields) -> { 185 + let has_did = list.any(fields, fn(f) { f.0 == "did" }) 186 + case has_did { 187 + True -> val 188 + False -> value.Object(list.append(fields, [#("did", value.String(did))])) 189 + } 190 + } 191 + value.Array(items) -> { 192 + value.Array(list.map(items, fn(item) { propagate_did(item, did) })) 193 + } 194 + _ -> val 195 + } 196 + } 197 + ``` 198 + 199 + **Step 2: Run build to verify it compiles** 200 + 201 + Run: `cd /Users/chadmiller/code/quickslice/lexicon_graphql && gleam build` 202 + Expected: Compiles with no errors (warning about unused function is OK) 203 + 204 + **Step 3: Commit** 205 + 206 + ```bash 207 + git add lexicon_graphql/src/lexicon_graphql/internal/graphql/object_builder.gleam 208 + git commit -m "feat(object_builder): add propagate_did helper" 209 + ``` 210 + 211 + --- 212 + 213 + ### Task 5: Update field resolver to handle blob fields and propagate did 214 + 215 + **Files:** 216 + - Modify: `lexicon_graphql/src/lexicon_graphql/internal/graphql/object_builder.gleam:67-77` 217 + 218 + **Step 1: Update the field resolver** 219 + 220 + Find this code (around line 67-77): 221 + ```gleam 222 + // Create field with a resolver that extracts the value from context 223 + schema.field(name, field_type, "Field from object definition", fn(ctx) { 224 + case ctx.data { 225 + option.Some(value.Object(fields)) -> { 226 + case list.key_find(fields, name) { 227 + Ok(val) -> Ok(val) 228 + Error(_) -> Ok(value.Null) 229 + } 230 + } 231 + _ -> Ok(value.Null) 232 + } 233 + }) 234 + ``` 235 + 236 + Replace with: 237 + ```gleam 238 + // Create field with a resolver that extracts the value from context 239 + // For blob fields, enrich with did; for nested objects, propagate did 240 + schema.field(name, field_type, "Field from object definition", fn(ctx) { 241 + case ctx.data { 242 + option.Some(value.Object(fields)) -> { 243 + // Get did from parent if available (propagated from record level) 244 + let parent_did = case list.key_find(fields, "did") { 245 + Ok(value.String(d)) -> option.Some(d) 246 + _ -> option.None 247 + } 248 + 249 + case list.key_find(fields, name) { 250 + Ok(val) -> { 251 + case type_, parent_did { 252 + // For blob fields, ensure did is injected 253 + "blob", option.Some(did) -> Ok(enrich_blob_with_did(val, did)) 254 + // For nested objects/arrays, propagate did 255 + _, option.Some(did) -> Ok(propagate_did(val, did)) 256 + // No did available, return as-is 257 + _, option.None -> Ok(val) 258 + } 259 + } 260 + Error(_) -> Ok(value.Null) 261 + } 262 + } 263 + _ -> Ok(value.Null) 264 + } 265 + }) 266 + ``` 267 + 268 + **Step 2: Run build to verify it compiles** 269 + 270 + Run: `cd /Users/chadmiller/code/quickslice/lexicon_graphql && gleam build` 271 + Expected: Compiles with no errors 272 + 273 + **Step 3: Run tests** 274 + 275 + Run: `cd /Users/chadmiller/code/quickslice/lexicon_graphql && gleam test` 276 + Expected: All tests pass 277 + 278 + **Step 4: Commit** 279 + 280 + ```bash 281 + git add lexicon_graphql/src/lexicon_graphql/internal/graphql/object_builder.gleam 282 + git commit -m "feat(object_builder): handle blob fields and propagate did through nested objects" 283 + ``` 284 + 285 + --- 286 + 287 + ### Task 6: Build server and verify fix 288 + 289 + **Files:** 290 + - None (verification only) 291 + 292 + **Step 1: Build server** 293 + 294 + Run: `cd /Users/chadmiller/code/quickslice/server && gleam build` 295 + Expected: Compiles with no errors 296 + 297 + **Step 2: Test via MCP query** 298 + 299 + After restarting the server, run this query: 300 + ```graphql 301 + query { 302 + appBskyFeedPost(first: 5) { 303 + edges { 304 + node { 305 + text 306 + embed { 307 + ... on AppBskyEmbedImages { 308 + images { 309 + image { 310 + url 311 + ref 312 + } 313 + } 314 + } 315 + } 316 + } 317 + } 318 + } 319 + } 320 + ``` 321 + 322 + Expected: `image.url` returns valid CDN URLs like `https://cdn.bsky.app/img/feed_fullsize/plain/did:plc:.../bafyrei...@jpeg` 323 + 324 + **Step 3: Final commit** 325 + 326 + ```bash 327 + git add -A 328 + git commit -m "test: verify nested blob URL resolution works" 329 + ``` 330 + 331 + --- 332 + 333 + ## Verification Query 334 + 335 + After implementation, this query should work without errors: 336 + 337 + ```graphql 338 + query MyQuery { 339 + appBskyFeedPost { 340 + edges { 341 + node { 342 + text 343 + embed { 344 + ... on AppBskyEmbedImages { 345 + images { 346 + aspectRatio 347 + image { 348 + url 349 + } 350 + } 351 + } 352 + } 353 + } 354 + } 355 + } 356 + } 357 + ``` 358 + 359 + The `image.url` field should return valid CDN URLs instead of `null`.
+354
dev-docs/plans/2025-12-03-object-type-build-order.md
··· 1 + # Object Type Build Order Fix Implementation Plan 2 + 3 + > **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. 4 + 5 + **Goal:** Fix union type resolution in `AppBskyRichtextFacet.features` by ensuring `#fragment` object types are built before main object types that reference them. 6 + 7 + **Architecture:** Sort object refs before building so that refs containing `#` (like `app.bsky.richtext.facet#mention`) are processed before refs without `#` (like `app.bsky.richtext.facet`). This ensures dependencies are built first. 8 + 9 + **Tech Stack:** Gleam, lexicon_graphql library, swell GraphQL library 10 + 11 + --- 12 + 13 + ## Background 14 + 15 + When `build_all_object_types` processes refs in arbitrary order (from `dict.keys`), a main object type like `app.bsky.richtext.facet` may be built before its `#fragment` dependencies (`#mention`, `#link`, `#tag`). When the main type's `features` field tries to resolve the union refs, they aren't in the dict yet, causing fallback to `String`. 16 + 17 + **Current behavior:** `features: [String!]!` 18 + **Expected behavior:** `features: [AppBskyRichtextFacetMentionOrAppBskyRichtextFacetLinkOrAppBskyRichtextFacetTag!]!` 19 + 20 + --- 21 + 22 + ### Task 1: Add failing test for object type build order 23 + 24 + **Files:** 25 + - Create: `lexicon_graphql/test/object_build_order_test.gleam` 26 + 27 + **Step 1: Write the failing test** 28 + 29 + ```gleam 30 + /// Tests for object type build order 31 + /// 32 + /// Verifies that #fragment refs are built before main object types 33 + /// so that unions in main types can resolve their member types 34 + import gleam/dict 35 + import gleam/list 36 + import gleam/option.{None, Some} 37 + import gleam/string 38 + import gleeunit/should 39 + import lexicon_graphql/internal/graphql/object_builder 40 + import lexicon_graphql/internal/lexicon/registry 41 + import lexicon_graphql/types 42 + import swell/schema 43 + 44 + /// Test that main object types with union array fields resolve correctly 45 + /// when the union members are #fragment refs in the same lexicon 46 + pub fn union_array_refs_resolve_to_object_types_test() { 47 + // Create a lexicon like app.bsky.richtext.facet with: 48 + // - main object type with features: array of union [#mention, #link] 49 + // - others: mention, link object definitions 50 + let lexicon = 51 + types.Lexicon( 52 + id: "app.bsky.richtext.facet", 53 + defs: types.Defs( 54 + main: Some( 55 + types.RecordDef(type_: "object", key: None, properties: [ 56 + #( 57 + "features", 58 + types.Property( 59 + type_: "array", 60 + required: True, 61 + format: None, 62 + ref: None, 63 + refs: None, 64 + items: Some(types.ArrayItems( 65 + type_: "union", 66 + ref: None, 67 + refs: Some(["#mention", "#link"]), 68 + )), 69 + ), 70 + ), 71 + ]), 72 + ), 73 + others: dict.from_list([ 74 + #( 75 + "mention", 76 + types.Object( 77 + types.ObjectDef(type_: "object", required_fields: ["did"], properties: [ 78 + #( 79 + "did", 80 + types.Property( 81 + type_: "string", 82 + required: True, 83 + format: Some("did"), 84 + ref: None, 85 + refs: None, 86 + items: None, 87 + ), 88 + ), 89 + ]), 90 + ), 91 + ), 92 + #( 93 + "link", 94 + types.Object( 95 + types.ObjectDef(type_: "object", required_fields: ["uri"], properties: [ 96 + #( 97 + "uri", 98 + types.Property( 99 + type_: "string", 100 + required: True, 101 + format: Some("uri"), 102 + ref: None, 103 + refs: None, 104 + items: None, 105 + ), 106 + ), 107 + ]), 108 + ), 109 + ), 110 + ]), 111 + ), 112 + ) 113 + 114 + // Build registry and object types 115 + let reg = registry.from_lexicons([lexicon]) 116 + let object_types = object_builder.build_all_object_types(reg) 117 + 118 + // The main type should exist 119 + let main_type_result = dict.get(object_types, "app.bsky.richtext.facet") 120 + should.be_ok(main_type_result) 121 + 122 + // The #fragment types should exist 123 + let mention_type_result = 124 + dict.get(object_types, "app.bsky.richtext.facet#mention") 125 + should.be_ok(mention_type_result) 126 + 127 + let link_type_result = dict.get(object_types, "app.bsky.richtext.facet#link") 128 + should.be_ok(link_type_result) 129 + 130 + // Check the main type's features field is a union, not String 131 + case main_type_result { 132 + Ok(main_type) -> { 133 + let type_name = schema.type_name(main_type) 134 + should.equal(type_name, "AppBskyRichtextFacet") 135 + 136 + // Get the fields and find "features" 137 + let fields = schema.get_fields(main_type) 138 + let features_field = 139 + list.find(fields, fn(f) { schema.field_name(f) == "features" }) 140 + 141 + case features_field { 142 + Ok(field) -> { 143 + // The field type should be a list containing a union, not [String!] 144 + let field_type = schema.field_type(field) 145 + let inner_type_name = get_list_inner_type_name(field_type) 146 + 147 + // Should NOT be "String" - should be a union type name 148 + should.be_false(inner_type_name == "String") 149 + 150 + // Should contain "Mention" and "Link" in the union name 151 + should.be_true(string.contains(inner_type_name, "Mention")) 152 + should.be_true(string.contains(inner_type_name, "Link")) 153 + } 154 + Error(_) -> should.fail() 155 + } 156 + } 157 + Error(_) -> should.fail() 158 + } 159 + } 160 + 161 + /// Helper to get the inner type name from a list type 162 + /// [NonNull[Union]] -> "UnionName" 163 + fn get_list_inner_type_name(t: schema.Type) -> String { 164 + // Unwrap List -> NonNull -> actual type 165 + case schema.unwrap_type(t) { 166 + Ok(inner) -> 167 + case schema.unwrap_type(inner) { 168 + Ok(innermost) -> schema.type_name(innermost) 169 + Error(_) -> schema.type_name(inner) 170 + } 171 + Error(_) -> schema.type_name(t) 172 + } 173 + } 174 + ``` 175 + 176 + **Step 2: Run test to verify it fails** 177 + 178 + Run: `cd /Users/chadmiller/code/quickslice/lexicon_graphql && gleam test 2>&1 | grep -A 5 "union_array_refs_resolve"` 179 + 180 + Expected: FAIL - the `features` field type name is `String` instead of containing `Mention` and `Link` 181 + 182 + **Step 3: Commit failing test** 183 + 184 + ```bash 185 + git add lexicon_graphql/test/object_build_order_test.gleam 186 + git commit -m "test: add failing test for object type build order" 187 + ``` 188 + 189 + --- 190 + 191 + ### Task 2: Add sort_refs_dependencies_first helper function 192 + 193 + **Files:** 194 + - Modify: `lexicon_graphql/src/lexicon_graphql/internal/graphql/object_builder.gleam` 195 + 196 + **Step 1: Add the helper function after the imports (around line 15)** 197 + 198 + Add after the imports section: 199 + 200 + ```gleam 201 + /// Sort refs so that #fragment refs come before main refs 202 + /// This ensures dependencies are built first 203 + /// e.g., "app.bsky.richtext.facet#mention" before "app.bsky.richtext.facet" 204 + fn sort_refs_dependencies_first(refs: List(String)) -> List(String) { 205 + list.sort(refs, fn(a, b) { 206 + let a_has_hash = string.contains(a, "#") 207 + let b_has_hash = string.contains(b, "#") 208 + case a_has_hash, b_has_hash { 209 + // Both have # or both don't - sort alphabetically for determinism 210 + True, True -> string.compare(a, b) 211 + False, False -> string.compare(a, b) 212 + // # refs come first 213 + True, False -> order.Lt 214 + False, True -> order.Gt 215 + } 216 + }) 217 + } 218 + ``` 219 + 220 + **Step 2: Add the order import** 221 + 222 + At line 7, add the import: 223 + 224 + ```gleam 225 + import gleam/order 226 + ``` 227 + 228 + **Step 3: Run build to verify no syntax errors** 229 + 230 + Run: `cd /Users/chadmiller/code/quickslice/lexicon_graphql && gleam build` 231 + 232 + Expected: Build succeeds (function not yet used) 233 + 234 + **Step 4: Commit** 235 + 236 + ```bash 237 + git add lexicon_graphql/src/lexicon_graphql/internal/graphql/object_builder.gleam 238 + git commit -m "feat(object_builder): add sort_refs_dependencies_first helper" 239 + ``` 240 + 241 + --- 242 + 243 + ### Task 3: Use sorted refs in build_all_object_types 244 + 245 + **Files:** 246 + - Modify: `lexicon_graphql/src/lexicon_graphql/internal/graphql/object_builder.gleam:115-123` 247 + 248 + **Step 1: Update build_all_object_types to sort refs** 249 + 250 + Replace the current implementation (around lines 115-123): 251 + 252 + ```gleam 253 + pub fn build_all_object_types( 254 + registry: lexicon_registry.Registry, 255 + ) -> Dict(String, schema.Type) { 256 + let object_refs = lexicon_registry.get_all_object_refs(registry) 257 + 258 + // Sort refs so #fragment refs are built before main refs 259 + // This ensures union member types exist when main types reference them 260 + let sorted_refs = sort_refs_dependencies_first(object_refs) 261 + 262 + // Build all object types in dependency order 263 + list.fold(sorted_refs, dict.new(), fn(acc, ref) { 264 + case lexicon_registry.get_object_def(registry, ref) { 265 + option.Some(obj_def) -> { 266 + // Generate a GraphQL type name from the ref 267 + // e.g., "social.grain.defs#aspectRatio" -> "SocialGrainDefsAspectRatio" 268 + let type_name = ref_to_type_name(ref) 269 + let lexicon_id = lexicon_registry.lexicon_id_from_ref(ref) 270 + // Pass acc as the object_types_dict so we can resolve refs to previously built types 271 + let object_type = build_object_type(obj_def, type_name, lexicon_id, acc) 272 + dict.insert(acc, ref, object_type) 273 + } 274 + option.None -> acc 275 + } 276 + }) 277 + } 278 + ``` 279 + 280 + **Step 2: Run tests** 281 + 282 + Run: `cd /Users/chadmiller/code/quickslice/lexicon_graphql && gleam test` 283 + 284 + Expected: All tests pass including the new `union_array_refs_resolve_to_object_types_test` 285 + 286 + **Step 3: Commit** 287 + 288 + ```bash 289 + git add lexicon_graphql/src/lexicon_graphql/internal/graphql/object_builder.gleam 290 + git commit -m "fix(object_builder): sort refs to build dependencies first 291 + 292 + Refs containing # (like app.bsky.richtext.facet#mention) are now built 293 + before refs without # (like app.bsky.richtext.facet). This ensures 294 + union member types exist when main object types reference them. 295 + 296 + Fixes: AppBskyRichtextFacet.features now resolves to union type instead of String" 297 + ``` 298 + 299 + --- 300 + 301 + ### Task 4: Verify with MCP introspection 302 + 303 + **Files:** 304 + - None (verification only) 305 + 306 + **Step 1: Query the quickslice MCP to verify the fix** 307 + 308 + ```graphql 309 + { 310 + __type(name: "AppBskyRichtextFacet") { 311 + fields { 312 + name 313 + type { 314 + kind 315 + ofType { 316 + kind 317 + ofType { 318 + kind 319 + ofType { 320 + name 321 + kind 322 + } 323 + } 324 + } 325 + } 326 + } 327 + } 328 + } 329 + ``` 330 + 331 + Expected: `features` field shows a UNION type (not SCALAR/String) 332 + 333 + **Step 2: Verify union member types exist** 334 + 335 + ```graphql 336 + { 337 + mention: __type(name: "AppBskyRichtextFacetMention") { name kind } 338 + link: __type(name: "AppBskyRichtextFacetLink") { name kind } 339 + tag: __type(name: "AppBskyRichtextFacetTag") { name kind } 340 + } 341 + ``` 342 + 343 + Expected: All three types exist as OBJECT types 344 + 345 + --- 346 + 347 + ## Summary 348 + 349 + | Task | Description | Files | 350 + |------|-------------|-------| 351 + | 1 | Add failing test | `test/object_build_order_test.gleam` | 352 + | 2 | Add sort helper | `object_builder.gleam` | 353 + | 3 | Use sorted refs | `object_builder.gleam` | 354 + | 4 | Verify with MCP | - |
+335
dev-docs/plans/2025-12-03-union-type-naming.md
··· 1 + # Union Type Naming Refactor Implementation Plan 2 + 3 + > **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. 4 + 5 + **Goal:** Change array union type names from concatenated style (`AppBskyRichtextFacetMentionOrAppBskyRichtextFacetLinkOrAppBskyRichtextFacetTag`) to contextual style (`AppBskyRichtextFacetFeaturesUnion`). 6 + 7 + **Architecture:** Modify `build_array_union_type` to accept parent type name and field name parameters, then update all callers to pass these values. The union name becomes `{ParentTypeName}{CapitalizedFieldName}Union`. 8 + 9 + **Tech Stack:** Gleam, swell GraphQL library 10 + 11 + --- 12 + 13 + ### Task 1: Add parameters to build_array_union_type 14 + 15 + **Files:** 16 + - Modify: `src/lexicon_graphql/internal/graphql/type_mapper.gleam:180-183` 17 + 18 + **Step 1: Update function signature** 19 + 20 + Change the function signature from: 21 + 22 + ```gleam 23 + fn build_array_union_type( 24 + refs: List(String), 25 + object_types: Dict(String, schema.Type), 26 + ) -> schema.Type { 27 + ``` 28 + 29 + To: 30 + 31 + ```gleam 32 + fn build_array_union_type( 33 + refs: List(String), 34 + object_types: Dict(String, schema.Type), 35 + parent_type_name: String, 36 + field_name: String, 37 + ) -> schema.Type { 38 + ``` 39 + 40 + **Step 2: Update union name generation** 41 + 42 + Replace lines 210-211: 43 + 44 + ```gleam 45 + // Build union name: "TypeAOrTypeBOrTypeC" 46 + let union_name = string.join(type_names, "Or") 47 + ``` 48 + 49 + With: 50 + 51 + ```gleam 52 + // Build union name: ParentTypeNameFieldNameUnion 53 + let capitalized_field = capitalize_first(field_name) 54 + let union_name = parent_type_name <> capitalized_field <> "Union" 55 + ``` 56 + 57 + **Step 3: Run tests to see failures** 58 + 59 + Run: `cd /Users/chadmiller/code/quickslice/lexicon_graphql && gleam test 2>&1 | head -50` 60 + Expected: Compilation error - `build_array_union_type` called with wrong number of arguments 61 + 62 + --- 63 + 64 + ### Task 2: Update map_array_type to accept and pass context 65 + 66 + **Files:** 67 + - Modify: `src/lexicon_graphql/internal/graphql/type_mapper.gleam:117-167` 68 + 69 + **Step 1: Update map_array_type signature** 70 + 71 + Change from: 72 + 73 + ```gleam 74 + pub fn map_array_type( 75 + items: Option(types.ArrayItems), 76 + object_types: Dict(String, schema.Type), 77 + ) -> schema.Type { 78 + ``` 79 + 80 + To: 81 + 82 + ```gleam 83 + pub fn map_array_type( 84 + items: Option(types.ArrayItems), 85 + object_types: Dict(String, schema.Type), 86 + parent_type_name: String, 87 + field_name: String, 88 + ) -> schema.Type { 89 + ``` 90 + 91 + **Step 2: Update the union case to pass context** 92 + 93 + Change lines 152-156: 94 + 95 + ```gleam 96 + "union" -> { 97 + case item_refs { 98 + option.Some(refs) -> { 99 + let union_type = build_array_union_type(refs, object_types) 100 + schema.list_type(schema.non_null(union_type)) 101 + ``` 102 + 103 + To: 104 + 105 + ```gleam 106 + "union" -> { 107 + case item_refs { 108 + option.Some(refs) -> { 109 + let union_type = build_array_union_type(refs, object_types, parent_type_name, field_name) 110 + schema.list_type(schema.non_null(union_type)) 111 + ``` 112 + 113 + **Step 3: Run tests to see failures** 114 + 115 + Run: `cd /Users/chadmiller/code/quickslice/lexicon_graphql && gleam build 2>&1 | head -50` 116 + Expected: Compilation errors - callers of `map_array_type` need updating 117 + 118 + --- 119 + 120 + ### Task 3: Update map_property_type_with_context to pass context 121 + 122 + **Files:** 123 + - Modify: `src/lexicon_graphql/internal/graphql/type_mapper.gleam:305` 124 + 125 + **Step 1: Update the map_array_type call** 126 + 127 + Change line 305: 128 + 129 + ```gleam 130 + map_array_type(expanded_items, object_types) 131 + ``` 132 + 133 + To: 134 + 135 + ```gleam 136 + map_array_type(expanded_items, object_types, parent_type_name, field_name) 137 + ``` 138 + 139 + **Step 2: Run build to check** 140 + 141 + Run: `cd /Users/chadmiller/code/quickslice/lexicon_graphql && gleam build 2>&1 | head -50` 142 + Expected: Compilation errors - object_builder.gleam still needs updating 143 + 144 + --- 145 + 146 + ### Task 4: Update object_builder.gleam to pass context 147 + 148 + **Files:** 149 + - Modify: `src/lexicon_graphql/internal/graphql/object_builder.gleam:37-61` (build_object_type) 150 + - Modify: `src/lexicon_graphql/internal/graphql/object_builder.gleam:64-127` (build_object_fields) 151 + 152 + **Step 1: Update build_object_fields to accept and pass type_name** 153 + 154 + Change function signature from: 155 + 156 + ```gleam 157 + fn build_object_fields( 158 + properties: List(#(String, types.Property)), 159 + lexicon_id: String, 160 + object_types_dict: Dict(String, schema.Type), 161 + ) -> List(schema.Field) { 162 + ``` 163 + 164 + To: 165 + 166 + ```gleam 167 + fn build_object_fields( 168 + properties: List(#(String, types.Property)), 169 + lexicon_id: String, 170 + object_types_dict: Dict(String, schema.Type), 171 + parent_type_name: String, 172 + ) -> List(schema.Field) { 173 + ``` 174 + 175 + **Step 2: Update the array case in build_object_fields** 176 + 177 + Change lines 74-82: 178 + 179 + ```gleam 180 + let graphql_type = case type_ { 181 + "array" -> { 182 + let expanded_items = case items { 183 + option.Some(arr_items) -> 184 + option.Some(expand_array_items(arr_items, lexicon_id)) 185 + option.None -> option.None 186 + } 187 + type_mapper.map_array_type(expanded_items, object_types_dict) 188 + } 189 + ``` 190 + 191 + To: 192 + 193 + ```gleam 194 + let graphql_type = case type_ { 195 + "array" -> { 196 + let expanded_items = case items { 197 + option.Some(arr_items) -> 198 + option.Some(expand_array_items(arr_items, lexicon_id)) 199 + option.None -> option.None 200 + } 201 + type_mapper.map_array_type(expanded_items, object_types_dict, parent_type_name, name) 202 + } 203 + ``` 204 + 205 + **Step 3: Update build_object_type to pass type_name to build_object_fields** 206 + 207 + Change line 45-46: 208 + 209 + ```gleam 210 + let lexicon_fields = 211 + build_object_fields(obj_def.properties, lexicon_id, object_types_dict) 212 + ``` 213 + 214 + To: 215 + 216 + ```gleam 217 + let lexicon_fields = 218 + build_object_fields(obj_def.properties, lexicon_id, object_types_dict, type_name) 219 + ``` 220 + 221 + **Step 4: Run build to check** 222 + 223 + Run: `cd /Users/chadmiller/code/quickslice/lexicon_graphql && gleam build 2>&1 | head -50` 224 + Expected: Compilation errors - tests need updating 225 + 226 + --- 227 + 228 + ### Task 5: Update type_mapper_test.gleam 229 + 230 + **Files:** 231 + - Modify: `test/type_mapper_test.gleam:81, 91, 98` 232 + 233 + **Step 1: Update test calls to map_array_type** 234 + 235 + Change line 81: 236 + 237 + ```gleam 238 + let result = type_mapper.map_array_type(option.Some(items), dict.new()) 239 + ``` 240 + 241 + To: 242 + 243 + ```gleam 244 + let result = type_mapper.map_array_type(option.Some(items), dict.new(), "TestType", "testField") 245 + ``` 246 + 247 + Change line 91: 248 + 249 + ```gleam 250 + let result = type_mapper.map_array_type(option.Some(items), dict.new()) 251 + ``` 252 + 253 + To: 254 + 255 + ```gleam 256 + let result = type_mapper.map_array_type(option.Some(items), dict.new(), "TestType", "testField") 257 + ``` 258 + 259 + Change line 98: 260 + 261 + ```gleam 262 + let result = type_mapper.map_array_type(option.None, dict.new()) 263 + ``` 264 + 265 + To: 266 + 267 + ```gleam 268 + let result = type_mapper.map_array_type(option.None, dict.new(), "TestType", "testField") 269 + ``` 270 + 271 + **Step 2: Run tests** 272 + 273 + Run: `cd /Users/chadmiller/code/quickslice/lexicon_graphql && gleam test 2>&1 | tail -20` 274 + Expected: All tests pass 275 + 276 + --- 277 + 278 + ### Task 6: Update union_resolver_test.gleam to verify new naming 279 + 280 + **Files:** 281 + - Modify: `test/union_resolver_test.gleam` 282 + 283 + **Step 1: Update the test to check for new union name format** 284 + 285 + The union type name should now be `AppBskyRichtextFacetFeaturesUnion` instead of `AppBskyRichtextFacetMentionOrAppBskyRichtextFacetLink`. 286 + 287 + Find and update any assertions that check the union type name. 288 + 289 + **Step 2: Run tests** 290 + 291 + Run: `cd /Users/chadmiller/code/quickslice/lexicon_graphql && gleam test 2>&1 | tail -20` 292 + Expected: All tests pass 293 + 294 + --- 295 + 296 + ### Task 7: Verify schema changes via MCP 297 + 298 + **Step 1: Restart the MCP server and introspect** 299 + 300 + User restarts the server, then run introspection query to verify union names changed. 301 + 302 + **Step 2: Verify union type names** 303 + 304 + Check that: 305 + - `AppBskyRichtextFacetMentionOrAppBskyRichtextFacetLinkOrAppBskyRichtextFacetTag` is now `AppBskyRichtextFacetFeaturesUnion` 306 + - `AppBskyFeedThreadgateMentionRuleOrAppBskyFeedThreadgateFollowerRuleOrAppBskyFeedThreadgateFollowingRuleOrAppBskyFeedThreadgateListRule` is now `AppBskyFeedThreadgateAllowUnion` 307 + 308 + --- 309 + 310 + ### Task 8: Commit changes 311 + 312 + **Step 1: Stage and commit** 313 + 314 + ```bash 315 + cd /Users/chadmiller/code/quickslice/lexicon_graphql 316 + git add src/lexicon_graphql/internal/graphql/type_mapper.gleam \ 317 + src/lexicon_graphql/internal/graphql/object_builder.gleam \ 318 + test/type_mapper_test.gleam \ 319 + test/union_resolver_test.gleam 320 + git commit -m "refactor: use {Parent}{Field}Union naming for array union types 321 + 322 + Change array union type names from concatenated member names to 323 + contextual names based on parent type and field name. 324 + 325 + Before: AppBskyRichtextFacetMentionOrAppBskyRichtextFacetLinkOrAppBskyRichtextFacetTag 326 + After: AppBskyRichtextFacetFeaturesUnion 327 + 328 + This makes union type names shorter, more readable, and consistent 329 + with property union naming convention." 330 + ``` 331 + 332 + **Step 2: Verify clean state** 333 + 334 + Run: `git status` 335 + Expected: `nothing to commit, working tree clean`
+974
dev-docs/plans/2025-12-03-union-type-support.md
··· 1 + # Union Type Support for Property Fields 2 + 3 + > **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. 4 + 5 + **Goal:** Map lexicon union properties (like `embed` in `app.bsky.feed.post`) to proper GraphQL union types instead of String. 6 + 7 + **Architecture:** Extend the Property type to capture `refs` field, generate GraphQL object types for object-type lexicons, and build union types with `$type`-based type resolution for property-level unions (matching the existing array union pattern). 8 + 9 + **Tech Stack:** Gleam, swell (GraphQL library), gleeunit (testing) 10 + 11 + --- 12 + 13 + ## Task 1: Add `refs` field to Property type 14 + 15 + **Files:** 16 + - Modify: `lexicon_graphql/src/lexicon_graphql/types.gleam:45-53` 17 + - Test: `lexicon_graphql/test/lexicon_parser_test.gleam` 18 + 19 + **Step 1: Write the failing test** 20 + 21 + Add to `lexicon_graphql/test/lexicon_parser_test.gleam`: 22 + 23 + ```gleam 24 + // Test parsing lexicon with union property (not array) 25 + pub fn parse_property_union_test() { 26 + let json = 27 + "{ 28 + \"lexicon\": 1, 29 + \"id\": \"app.bsky.feed.post\", 30 + \"defs\": { 31 + \"main\": { 32 + \"type\": \"record\", 33 + \"record\": { 34 + \"type\": \"object\", 35 + \"properties\": { 36 + \"embed\": { 37 + \"type\": \"union\", 38 + \"refs\": [ 39 + \"app.bsky.embed.images\", 40 + \"app.bsky.embed.video\" 41 + ] 42 + } 43 + } 44 + } 45 + } 46 + } 47 + }" 48 + 49 + let result = lexicon_parser.parse_lexicon(json) 50 + should.be_ok(result) 51 + 52 + case result { 53 + Ok(lexicon) -> { 54 + case lexicon.defs.main { 55 + option.Some(types.RecordDef(type_: _, key: _, properties: props)) -> { 56 + case list.find(props, fn(p) { p.0 == "embed" }) { 57 + Ok(#(_, prop)) -> { 58 + should.equal(prop.type_, "union") 59 + should.equal( 60 + prop.refs, 61 + option.Some([ 62 + "app.bsky.embed.images", 63 + "app.bsky.embed.video", 64 + ]), 65 + ) 66 + } 67 + Error(_) -> should.fail() 68 + } 69 + } 70 + option.None -> should.fail() 71 + } 72 + } 73 + Error(_) -> should.fail() 74 + } 75 + } 76 + ``` 77 + 78 + **Step 2: Run test to verify it fails** 79 + 80 + Run: `cd lexicon_graphql && gleam test` 81 + 82 + Expected: Compilation error - `prop.refs` does not exist on Property type 83 + 84 + **Step 3: Update Property type to include refs** 85 + 86 + Modify `lexicon_graphql/src/lexicon_graphql/types.gleam`: 87 + 88 + ```gleam 89 + /// Property definition 90 + pub type Property { 91 + Property( 92 + type_: String, 93 + required: Bool, 94 + format: Option(String), 95 + ref: Option(String), 96 + refs: Option(List(String)), 97 + items: Option(ArrayItems), 98 + ) 99 + } 100 + ``` 101 + 102 + **Step 4: Run test to verify compilation still fails** 103 + 104 + Run: `cd lexicon_graphql && gleam test` 105 + 106 + Expected: Compilation errors in parser.gleam and other files due to missing `refs` argument 107 + 108 + **Step 5: Commit type change** 109 + 110 + ```bash 111 + git add lexicon_graphql/src/lexicon_graphql/types.gleam 112 + git commit -m "feat(types): add refs field to Property for union support" 113 + ``` 114 + 115 + --- 116 + 117 + ## Task 2: Update parser to extract refs from properties 118 + 119 + **Files:** 120 + - Modify: `lexicon_graphql/src/lexicon_graphql/internal/lexicon/parser.gleam:170-213` 121 + - Test: `lexicon_graphql/test/lexicon_parser_test.gleam` 122 + 123 + **Step 1: Update decode_property to extract refs** 124 + 125 + Modify `lexicon_graphql/src/lexicon_graphql/internal/lexicon/parser.gleam`: 126 + 127 + ```gleam 128 + /// Decode a property's type, format, ref, refs, and items fields 129 + fn decode_property( 130 + dyn: decode.Dynamic, 131 + ) -> Result( 132 + #( 133 + String, 134 + option.Option(String), 135 + option.Option(String), 136 + option.Option(List(String)), 137 + option.Option(types.ArrayItems), 138 + ), 139 + List(decode.DecodeError), 140 + ) { 141 + let property_decoder = { 142 + use type_ <- decode.field("type", decode.string) 143 + use format <- decode.optional_field( 144 + "format", 145 + None, 146 + decode.optional(decode.string), 147 + ) 148 + use ref <- decode.optional_field( 149 + "ref", 150 + None, 151 + decode.optional(decode.string), 152 + ) 153 + use refs <- decode.optional_field( 154 + "refs", 155 + None, 156 + decode.optional(decode.list(decode.string)), 157 + ) 158 + use items_dyn <- decode.optional_field( 159 + "items", 160 + None, 161 + decode.optional(decode.dynamic), 162 + ) 163 + 164 + // Decode items if present 165 + let items = case items_dyn { 166 + option.Some(dyn) -> 167 + case decode_array_items(dyn) { 168 + Ok(arr_items) -> option.Some(arr_items) 169 + Error(_) -> None 170 + } 171 + None -> None 172 + } 173 + 174 + decode.success(#(type_, format, ref, refs, items)) 175 + } 176 + decode.run(dyn, property_decoder) 177 + } 178 + ``` 179 + 180 + **Step 2: Update callers to pass refs to Property constructor** 181 + 182 + In `decode_record_object()` (around line 147): 183 + 184 + ```gleam 185 + let #(prop_type, prop_format, prop_ref, prop_refs, prop_items) = case 186 + decode_property(prop_dyn) 187 + { 188 + Ok(#(t, f, r, rs, i)) -> #(t, f, r, rs, i) 189 + Error(_) -> #("string", None, None, None, None) 190 + } 191 + 192 + #( 193 + name, 194 + types.Property( 195 + prop_type, 196 + is_required, 197 + prop_format, 198 + prop_ref, 199 + prop_refs, 200 + prop_items, 201 + ), 202 + ) 203 + ``` 204 + 205 + In `decode_object_def_inner()` (around line 93): 206 + 207 + ```gleam 208 + let #(prop_type, prop_format, prop_ref, prop_refs, prop_items) = case 209 + decode_property(prop_dyn) 210 + { 211 + Ok(#(t, f, r, rs, i)) -> #(t, f, r, rs, i) 212 + Error(_) -> #("string", None, None, None, None) 213 + } 214 + 215 + #( 216 + name, 217 + types.Property( 218 + prop_type, 219 + is_required, 220 + prop_format, 221 + prop_ref, 222 + prop_refs, 223 + prop_items, 224 + ), 225 + ) 226 + ``` 227 + 228 + **Step 3: Run test to verify it passes** 229 + 230 + Run: `cd lexicon_graphql && gleam test` 231 + 232 + Expected: All tests pass including new `parse_property_union_test` 233 + 234 + **Step 4: Commit parser changes** 235 + 236 + ```bash 237 + git add lexicon_graphql/src/lexicon_graphql/internal/lexicon/parser.gleam lexicon_graphql/test/lexicon_parser_test.gleam 238 + git commit -m "feat(parser): extract refs field from union properties" 239 + ``` 240 + 241 + --- 242 + 243 + ## Task 3: Fix compilation errors from Property change 244 + 245 + **Files:** 246 + - Modify: `lexicon_graphql/src/lexicon_graphql/schema/builder.gleam` 247 + - Modify: `lexicon_graphql/src/lexicon_graphql/mutation/builder.gleam` 248 + - Modify: `lexicon_graphql/test/type_mapper_test.gleam` 249 + - Modify: Any other files with Property constructor calls 250 + 251 + **Step 1: Find all Property constructor usages** 252 + 253 + Run: `cd lexicon_graphql && grep -rn "types.Property(" src test` 254 + 255 + **Step 2: Update each Property constructor to include refs** 256 + 257 + For each usage, add `option.None` or appropriate value for `refs` field. 258 + 259 + Example in `type_mapper_test.gleam`: 260 + 261 + ```gleam 262 + pub fn map_property_type_string_test() { 263 + let property = 264 + types.Property( 265 + type_: "string", 266 + required: True, 267 + format: option.None, 268 + ref: option.None, 269 + refs: option.None, 270 + items: option.None, 271 + ) 272 + 273 + let result = type_mapper.map_property_type(property, dict.new()) 274 + 275 + result 276 + |> should.equal(schema.string_type()) 277 + } 278 + ``` 279 + 280 + **Step 3: Run tests to verify compilation passes** 281 + 282 + Run: `cd lexicon_graphql && gleam test` 283 + 284 + Expected: All tests pass 285 + 286 + **Step 4: Commit fixes** 287 + 288 + ```bash 289 + git add -A 290 + git commit -m "fix: update Property constructor calls with refs field" 291 + ``` 292 + 293 + --- 294 + 295 + ## Task 4: Generate GraphQL types for object-type lexicons 296 + 297 + **Files:** 298 + - Modify: `lexicon_graphql/src/lexicon_graphql/schema/builder.gleam` 299 + - Create test: `lexicon_graphql/test/object_type_lexicon_test.gleam` 300 + 301 + **Step 1: Write the failing test** 302 + 303 + Create `lexicon_graphql/test/object_type_lexicon_test.gleam`: 304 + 305 + ```gleam 306 + /// Tests for object-type lexicon GraphQL generation 307 + import gleam/option 308 + import gleeunit/should 309 + import lexicon_graphql/schema/builder 310 + import lexicon_graphql/types 311 + import swell/schema 312 + 313 + pub fn object_type_lexicon_generates_graphql_type_test() { 314 + // Create an object-type lexicon (like app.bsky.embed.images) 315 + let lexicon = 316 + types.Lexicon( 317 + id: "app.bsky.embed.images", 318 + defs: types.Defs( 319 + main: option.Some(types.RecordDef( 320 + type_: "object", 321 + key: option.None, 322 + properties: [ 323 + #( 324 + "images", 325 + types.Property( 326 + type_: "array", 327 + required: True, 328 + format: option.None, 329 + ref: option.None, 330 + refs: option.None, 331 + items: option.Some(types.ArrayItems( 332 + type_: "string", 333 + ref: option.None, 334 + refs: option.None, 335 + )), 336 + ), 337 + ), 338 + ], 339 + )), 340 + others: dict.new(), 341 + ), 342 + ) 343 + 344 + let result = builder.build_schema([lexicon]) 345 + should.be_ok(result) 346 + 347 + case result { 348 + Ok(schema_val) -> { 349 + // The type should exist in the schema 350 + let type_result = schema.get_type(schema_val, "AppBskyEmbedImages") 351 + should.be_ok(type_result) 352 + } 353 + Error(_) -> should.fail() 354 + } 355 + } 356 + ``` 357 + 358 + Note: You'll need to add `import gleam/dict` to the imports. 359 + 360 + **Step 2: Run test to verify it fails** 361 + 362 + Run: `cd lexicon_graphql && gleam test` 363 + 364 + Expected: Test fails - type not found because object-type lexicons aren't generated 365 + 366 + **Step 3: Add object-type lexicon extraction to builder** 367 + 368 + Modify `lexicon_graphql/src/lexicon_graphql/schema/builder.gleam`. 369 + 370 + Add new function to extract object-type lexicons: 371 + 372 + ```gleam 373 + /// Extract object types from object-type lexicons (type: "object" at main level) 374 + /// These are NOT record types - they don't get query fields, just exist as types 375 + fn extract_object_type_lexicons( 376 + lexicons: List(Lexicon), 377 + ref_object_types: dict.Dict(String, schema.Type), 378 + ) -> dict.Dict(String, schema.Type) { 379 + list.fold(lexicons, dict.new(), fn(acc, lexicon) { 380 + case lexicon { 381 + types.Lexicon( 382 + id, 383 + types.Defs(option.Some(types.RecordDef("object", _, properties)), _), 384 + ) -> { 385 + let type_name = nsid.to_type_name(id) 386 + let fields = build_object_fields(properties, ref_object_types) 387 + let object_type = 388 + schema.object_type(type_name, "Object type: " <> id, fields) 389 + dict.insert(acc, id, object_type) 390 + } 391 + _ -> acc 392 + } 393 + }) 394 + } 395 + 396 + /// Build fields for object types (simplified - no standard AT Proto fields) 397 + fn build_object_fields( 398 + properties: List(#(String, Property)), 399 + ref_object_types: dict.Dict(String, schema.Type), 400 + ) -> List(schema.Field) { 401 + list.map(properties, fn(prop) { 402 + let #(name, property) = prop 403 + let graphql_type = type_mapper.map_property_type(property, ref_object_types) 404 + schema.field(name, graphql_type, "Field from lexicon", fn(_ctx) { 405 + Ok(value.Null) 406 + }) 407 + }) 408 + } 409 + ``` 410 + 411 + Update `build_schema` to include object-type lexicons in object_types dict: 412 + 413 + ```gleam 414 + pub fn build_schema(lexicons: List(Lexicon)) -> Result(schema.Schema, String) { 415 + case lexicons { 416 + [] -> Error("Cannot build schema from empty lexicon list") 417 + _ -> { 418 + // First extract ref object types from lexicon "others" (e.g., #artist, #aspectRatio) 419 + let ref_object_types = extract_ref_object_types(lexicons) 420 + 421 + // Extract object-type lexicons (like embed types) 422 + let object_type_lexicons = extract_object_type_lexicons(lexicons, ref_object_types) 423 + 424 + // Merge ref_object_types with object_type_lexicons 425 + let all_object_types = dict.merge(ref_object_types, object_type_lexicons) 426 + 427 + // Extract record types from lexicons, passing all object types for field resolution 428 + let record_types = extract_record_types(lexicons, all_object_types) 429 + 430 + // Build object types dict including record types 431 + let record_object_types = build_object_types_dict(record_types) 432 + let object_types = dict.merge(all_object_types, record_object_types) 433 + 434 + // Build the query type with fields for each record (not object types) 435 + let query_type = build_query_type(record_types, object_types) 436 + 437 + // Build the mutation type with stub resolvers, using shared object types 438 + let mutation_type = 439 + mutation_builder.build_mutation_type( 440 + lexicons, 441 + object_types, 442 + option.None, 443 + option.None, 444 + option.None, 445 + option.None, 446 + ) 447 + 448 + // Create the schema with queries and mutations 449 + Ok(schema.schema(query_type, option.Some(mutation_type))) 450 + } 451 + } 452 + } 453 + ``` 454 + 455 + **Step 4: Run test to verify it passes** 456 + 457 + Run: `cd lexicon_graphql && gleam test` 458 + 459 + Expected: All tests pass 460 + 461 + **Step 5: Commit object-type lexicon support** 462 + 463 + ```bash 464 + git add lexicon_graphql/src/lexicon_graphql/schema/builder.gleam lexicon_graphql/test/object_type_lexicon_test.gleam 465 + git commit -m "feat(builder): generate GraphQL types for object-type lexicons" 466 + ``` 467 + 468 + --- 469 + 470 + ## Task 5: Build union types for property unions 471 + 472 + **Files:** 473 + - Modify: `lexicon_graphql/src/lexicon_graphql/internal/graphql/type_mapper.gleam` 474 + - Test: `lexicon_graphql/test/type_mapper_test.gleam` 475 + 476 + **Step 1: Write the failing test** 477 + 478 + Add to `lexicon_graphql/test/type_mapper_test.gleam`: 479 + 480 + ```gleam 481 + pub fn map_property_union_type_test() { 482 + // Create object types that the union will reference 483 + let images_type = 484 + schema.object_type("AppBskyEmbedImages", "Images embed", []) 485 + let video_type = schema.object_type("AppBskyEmbedVideo", "Video embed", []) 486 + 487 + let object_types = 488 + dict.new() 489 + |> dict.insert("app.bsky.embed.images", images_type) 490 + |> dict.insert("app.bsky.embed.video", video_type) 491 + 492 + let property = 493 + types.Property( 494 + type_: "union", 495 + required: False, 496 + format: option.None, 497 + ref: option.None, 498 + refs: option.Some([ 499 + "app.bsky.embed.images", 500 + "app.bsky.embed.video", 501 + ]), 502 + items: option.None, 503 + ) 504 + 505 + let result = 506 + type_mapper.map_property_type_with_context( 507 + property, 508 + object_types, 509 + "AppBskyFeedPost", 510 + "embed", 511 + ) 512 + 513 + // Should be a union type, not String 514 + schema.type_name(result) 515 + |> should.equal("AppBskyFeedPostEmbed") 516 + } 517 + ``` 518 + 519 + **Step 2: Run test to verify it fails** 520 + 521 + Run: `cd lexicon_graphql && gleam test` 522 + 523 + Expected: Compilation error - `map_property_type_with_context` doesn't exist 524 + 525 + **Step 3: Add map_property_type_with_context function** 526 + 527 + Add to `lexicon_graphql/src/lexicon_graphql/internal/graphql/type_mapper.gleam`: 528 + 529 + ```gleam 530 + /// Maps a lexicon Property to a GraphQL type, with parent context for union naming. 531 + /// Handles arrays, refs, and unions with proper type resolution. 532 + pub fn map_property_type_with_context( 533 + property: types.Property, 534 + object_types: Dict(String, schema.Type), 535 + parent_type_name: String, 536 + field_name: String, 537 + ) -> schema.Type { 538 + case property.type_ { 539 + "array" -> map_array_type(property.items, object_types) 540 + "ref" -> { 541 + case property.ref { 542 + option.Some(ref_str) -> { 543 + case dict.get(object_types, ref_str) { 544 + Ok(obj_type) -> obj_type 545 + Error(_) -> schema.string_type() 546 + } 547 + } 548 + option.None -> schema.string_type() 549 + } 550 + } 551 + "union" -> { 552 + case property.refs { 553 + option.Some(refs) -> 554 + build_property_union_type(refs, object_types, parent_type_name, field_name) 555 + option.None -> schema.string_type() 556 + } 557 + } 558 + _ -> map_type(property.type_) 559 + } 560 + } 561 + 562 + /// Build a union type for a property field. 563 + /// Names the union as ParentTypeNameFieldName (e.g., AppBskyFeedPostEmbed) 564 + fn build_property_union_type( 565 + refs: List(String), 566 + object_types: Dict(String, schema.Type), 567 + parent_type_name: String, 568 + field_name: String, 569 + ) -> schema.Type { 570 + // Build union name: ParentTypeNameFieldName (capitalize field name) 571 + let capitalized_field = capitalize_first(field_name) 572 + let union_name = parent_type_name <> capitalized_field 573 + 574 + // Look up member types from object_types dict 575 + let member_types = 576 + list.filter_map(refs, fn(ref) { 577 + case dict.get(object_types, ref) { 578 + Ok(t) -> Ok(t) 579 + Error(_) -> Error(Nil) 580 + } 581 + }) 582 + 583 + // Type resolver - inspect $type field to determine concrete type 584 + let type_resolver = fn(ctx: schema.Context) -> Result(String, String) { 585 + case get_field_from_context(ctx, "$type") { 586 + Ok(type_nsid) -> Ok(nsid.to_type_name(type_nsid)) 587 + Error(_) -> { 588 + // Fallback: try first type if $type not present 589 + case refs { 590 + [first, ..] -> Ok(ref_to_type_name(first)) 591 + [] -> Error("No types in union and no $type field") 592 + } 593 + } 594 + } 595 + } 596 + 597 + schema.union_type(union_name, "Union type for " <> field_name, member_types, type_resolver) 598 + } 599 + 600 + /// Capitalize the first letter of a string 601 + fn capitalize_first(s: String) -> String { 602 + case string.pop_grapheme(s) { 603 + Ok(#(first, rest)) -> string.uppercase(first) <> rest 604 + Error(_) -> s 605 + } 606 + } 607 + 608 + /// Get a string field value from context data 609 + fn get_field_from_context( 610 + ctx: schema.Context, 611 + field_name: String, 612 + ) -> Result(String, Nil) { 613 + case ctx.data { 614 + option.Some(value.Object(fields)) -> { 615 + case list.key_find(fields, field_name) { 616 + Ok(value.String(val)) -> Ok(val) 617 + _ -> Error(Nil) 618 + } 619 + } 620 + _ -> Error(Nil) 621 + } 622 + } 623 + ``` 624 + 625 + You'll need to add these imports at the top: 626 + 627 + ```gleam 628 + import swell/value 629 + ``` 630 + 631 + **Step 4: Run test to verify it passes** 632 + 633 + Run: `cd lexicon_graphql && gleam test` 634 + 635 + Expected: All tests pass 636 + 637 + **Step 5: Commit union type builder** 638 + 639 + ```bash 640 + git add lexicon_graphql/src/lexicon_graphql/internal/graphql/type_mapper.gleam lexicon_graphql/test/type_mapper_test.gleam 641 + git commit -m "feat(type_mapper): build union types for property unions" 642 + ``` 643 + 644 + --- 645 + 646 + ## Task 6: Update builder to use context-aware property mapping 647 + 648 + **Files:** 649 + - Modify: `lexicon_graphql/src/lexicon_graphql/schema/builder.gleam` 650 + - Test: `lexicon_graphql/test/schema_builder_test.gleam` 651 + 652 + **Step 1: Write the failing test** 653 + 654 + Add to `lexicon_graphql/test/schema_builder_test.gleam`: 655 + 656 + ```gleam 657 + pub fn union_property_generates_union_type_test() { 658 + // Create embed object type lexicon 659 + let embed_images_lexicon = 660 + types.Lexicon( 661 + id: "app.bsky.embed.images", 662 + defs: types.Defs( 663 + main: option.Some(types.RecordDef( 664 + type_: "object", 665 + key: option.None, 666 + properties: [ 667 + #( 668 + "images", 669 + types.Property( 670 + type_: "array", 671 + required: True, 672 + format: option.None, 673 + ref: option.None, 674 + refs: option.None, 675 + items: option.Some(types.ArrayItems( 676 + type_: "string", 677 + ref: option.None, 678 + refs: option.None, 679 + )), 680 + ), 681 + ), 682 + ], 683 + )), 684 + others: dict.new(), 685 + ), 686 + ) 687 + 688 + // Create record with union property 689 + let post_lexicon = 690 + types.Lexicon( 691 + id: "app.bsky.feed.post", 692 + defs: types.Defs( 693 + main: option.Some(types.RecordDef( 694 + type_: "record", 695 + key: option.Some("tid"), 696 + properties: [ 697 + #( 698 + "text", 699 + types.Property( 700 + type_: "string", 701 + required: True, 702 + format: option.None, 703 + ref: option.None, 704 + refs: option.None, 705 + items: option.None, 706 + ), 707 + ), 708 + #( 709 + "embed", 710 + types.Property( 711 + type_: "union", 712 + required: False, 713 + format: option.None, 714 + ref: option.None, 715 + refs: option.Some(["app.bsky.embed.images"]), 716 + items: option.None, 717 + ), 718 + ), 719 + ], 720 + )), 721 + others: dict.new(), 722 + ), 723 + ) 724 + 725 + let result = builder.build_schema([embed_images_lexicon, post_lexicon]) 726 + should.be_ok(result) 727 + 728 + case result { 729 + Ok(schema_val) -> { 730 + // The union type should exist 731 + let union_result = schema.get_type(schema_val, "AppBskyFeedPostEmbed") 732 + should.be_ok(union_result) 733 + } 734 + Error(_) -> should.fail() 735 + } 736 + } 737 + ``` 738 + 739 + Note: Add `import gleam/dict` if not present. 740 + 741 + **Step 2: Run test to verify it fails** 742 + 743 + Run: `cd lexicon_graphql && gleam test` 744 + 745 + Expected: Test fails - union type not found 746 + 747 + **Step 3: Update build_fields to use context-aware mapping** 748 + 749 + Modify `build_fields` in `lexicon_graphql/src/lexicon_graphql/schema/builder.gleam`: 750 + 751 + ```gleam 752 + /// Build GraphQL fields from lexicon properties 753 + fn build_fields_with_context( 754 + properties: List(#(String, Property)), 755 + ref_object_types: dict.Dict(String, schema.Type), 756 + parent_type_name: String, 757 + ) -> List(schema.Field) { 758 + // Add standard AT Proto fields 759 + let standard_fields = [ 760 + schema.field("uri", schema.string_type(), "Record URI", fn(_ctx) { 761 + Ok(value.String("at://did:plc:example/collection/rkey")) 762 + }), 763 + schema.field("cid", schema.string_type(), "Record CID", fn(_ctx) { 764 + Ok(value.String("bafyreicid")) 765 + }), 766 + schema.field("did", schema.string_type(), "DID of record author", fn(_ctx) { 767 + Ok(value.String("did:plc:example")) 768 + }), 769 + schema.field( 770 + "indexedAt", 771 + schema.string_type(), 772 + "When record was indexed", 773 + fn(_ctx) { Ok(value.String("2024-01-01T00:00:00Z")) }, 774 + ), 775 + ] 776 + 777 + // Build fields from lexicon properties with context 778 + let lexicon_fields = 779 + list.map(properties, fn(prop) { 780 + let #(name, property) = prop 781 + let graphql_type = 782 + type_mapper.map_property_type_with_context( 783 + property, 784 + ref_object_types, 785 + parent_type_name, 786 + name, 787 + ) 788 + 789 + schema.field(name, graphql_type, "Field from lexicon", fn(_ctx) { 790 + Ok(value.Null) 791 + }) 792 + }) 793 + 794 + // Combine standard and lexicon fields 795 + list.append(standard_fields, lexicon_fields) 796 + } 797 + ``` 798 + 799 + Update `parse_lexicon` to use the new function: 800 + 801 + ```gleam 802 + fn parse_lexicon( 803 + lexicon: Lexicon, 804 + ref_object_types: dict.Dict(String, schema.Type), 805 + ) -> Result(RecordType, Nil) { 806 + case lexicon { 807 + types.Lexicon( 808 + id, 809 + types.Defs(option.Some(types.RecordDef("record", _, properties)), _), 810 + ) -> { 811 + let type_name = nsid.to_type_name(id) 812 + let field_name = nsid.to_field_name(id) 813 + let fields = build_fields_with_context(properties, ref_object_types, type_name) 814 + 815 + Ok(RecordType( 816 + nsid: id, 817 + type_name: type_name, 818 + field_name: field_name, 819 + fields: fields, 820 + )) 821 + } 822 + _ -> Error(Nil) 823 + } 824 + } 825 + ``` 826 + 827 + **Step 4: Run test to verify it passes** 828 + 829 + Run: `cd lexicon_graphql && gleam test` 830 + 831 + Expected: All tests pass 832 + 833 + **Step 5: Commit builder updates** 834 + 835 + ```bash 836 + git add lexicon_graphql/src/lexicon_graphql/schema/builder.gleam lexicon_graphql/test/schema_builder_test.gleam 837 + git commit -m "feat(builder): use context-aware property mapping for unions" 838 + ``` 839 + 840 + --- 841 + 842 + ## Task 7: Update database.gleam to use context-aware mapping 843 + 844 + **Files:** 845 + - Modify: `lexicon_graphql/src/lexicon_graphql/schema/database.gleam` 846 + 847 + **Step 1: Find build_fields usage in database.gleam** 848 + 849 + Run: `grep -n "build_fields\|map_property_type" lexicon_graphql/src/lexicon_graphql/schema/database.gleam` 850 + 851 + **Step 2: Update to use map_property_type_with_context** 852 + 853 + Update all calls to `type_mapper.map_property_type` to use `type_mapper.map_property_type_with_context` with appropriate parent_type_name and field_name arguments. 854 + 855 + **Step 3: Run all tests** 856 + 857 + Run: `cd lexicon_graphql && gleam test` 858 + 859 + Expected: All tests pass 860 + 861 + **Step 4: Commit database updates** 862 + 863 + ```bash 864 + git add lexicon_graphql/src/lexicon_graphql/schema/database.gleam 865 + git commit -m "feat(database): use context-aware property mapping for unions" 866 + ``` 867 + 868 + --- 869 + 870 + ## Task 8: Integration test with real Bluesky lexicons 871 + 872 + **Files:** 873 + - Create: `lexicon_graphql/test/union_integration_test.gleam` 874 + 875 + **Step 1: Write integration test using MCP** 876 + 877 + ```gleam 878 + /// Integration tests for union type support with real Bluesky lexicons 879 + import gleam/option 880 + import gleeunit/should 881 + import lexicon_graphql/schema/builder 882 + import swell/schema 883 + 884 + // This test verifies the full pipeline works with app.bsky.feed.post embed field 885 + pub fn embed_union_integration_test() { 886 + // This would use actual lexicon JSON from the MCP server 887 + // For now, create a representative test case 888 + 889 + // The test verifies: 890 + // 1. app.bsky.embed.* types are generated as GraphQL object types 891 + // 2. app.bsky.feed.post.embed becomes AppBskyFeedPostEmbed union 892 + // 3. The union includes all embed types as members 893 + // 4. The $type resolver correctly maps to concrete types 894 + 895 + should.be_true(True) // Placeholder - expand with actual lexicon data 896 + } 897 + ``` 898 + 899 + **Step 2: Run integration test** 900 + 901 + Run: `cd lexicon_graphql && gleam test` 902 + 903 + **Step 3: Commit integration test** 904 + 905 + ```bash 906 + git add lexicon_graphql/test/union_integration_test.gleam 907 + git commit -m "test: add integration test for union type support" 908 + ``` 909 + 910 + --- 911 + 912 + ## Task 9: Clean up and remove old map_property_type calls 913 + 914 + **Files:** 915 + - Modify: Multiple files using old function 916 + 917 + **Step 1: Search for remaining map_property_type calls** 918 + 919 + Run: `grep -rn "map_property_type\b" lexicon_graphql/src` 920 + 921 + **Step 2: Update or remove old function** 922 + 923 + Keep `map_property_type` for backwards compatibility but have it delegate to the new function with empty context: 924 + 925 + ```gleam 926 + /// Maps a lexicon Property to a GraphQL type. 927 + /// Handles arrays specially by looking at the items field. 928 + /// DEPRECATED: Use map_property_type_with_context for union support. 929 + pub fn map_property_type( 930 + property: types.Property, 931 + object_types: Dict(String, schema.Type), 932 + ) -> schema.Type { 933 + map_property_type_with_context(property, object_types, "", "") 934 + } 935 + ``` 936 + 937 + **Step 3: Run all tests** 938 + 939 + Run: `cd lexicon_graphql && gleam test` 940 + 941 + Expected: All tests pass 942 + 943 + **Step 4: Final commit** 944 + 945 + ```bash 946 + git add -A 947 + git commit -m "refactor: deprecate map_property_type in favor of context-aware version" 948 + ``` 949 + 950 + --- 951 + 952 + ## Verification 953 + 954 + After completing all tasks: 955 + 956 + 1. Run full test suite: `cd lexicon_graphql && gleam test` 957 + 2. Use the MCP to introspect schema and verify `embed` is a union type 958 + 3. Verify union members include `AppBskyEmbedImages`, `AppBskyEmbedVideo`, etc. 959 + 960 + --- 961 + 962 + ## Summary 963 + 964 + | Task | Description | 965 + |------|-------------| 966 + | 1 | Add `refs` field to Property type | 967 + | 2 | Update parser to extract refs | 968 + | 3 | Fix compilation errors from Property change | 969 + | 4 | Generate GraphQL types for object-type lexicons | 970 + | 5 | Build union types for property unions | 971 + | 6 | Update builder to use context-aware mapping | 972 + | 7 | Update database.gleam to use context-aware mapping | 973 + | 8 | Integration test with real lexicons | 974 + | 9 | Clean up and deprecate old function |
+75
examples/02-following-feed/README.md
··· 1 + # Statusphere HTML Example 2 + 3 + A single-file HTML example demonstrating quickslice's GraphQL API with OAuth authentication. 4 + 5 + ## Features 6 + 7 + - OAuth PKCE authentication flow 8 + - Post status updates (emoji) 9 + - View recent statuses from the network 10 + - Display user profiles 11 + 12 + ## Prerequisites 13 + 14 + 1. Quickslice server running at `http://localhost:8080` 15 + 2. A registered OAuth client 16 + 17 + ## Setup 18 + 19 + ### 1. Start Quickslice 20 + 21 + ```bash 22 + cd /path/to/quickslice 23 + make run 24 + ``` 25 + 26 + ### 2. Register an OAuth Client 27 + 28 + Navigate to the admin settings page at `http://localhost:8080/admin/settings` and register a new OAuth client with: 29 + 30 + - **Name:** Statusphere HTML Example 31 + - **Token Endpoint Auth Method:** Public 32 + - **Redirect URIs:** `http://127.0.0.1:3000/` 33 + 34 + **Important:** Set the redirect URI to match where you'll serve this HTML file. 35 + 36 + ### 3. Serve the HTML File 37 + 38 + ```bash 39 + npx http-server . -p 3000 40 + # Open http://127.0.0.1:3000 41 + ``` 42 + 43 + ### 4. Login 44 + 45 + 1. Enter your OAuth Client ID 46 + 2. Enter your Bluesky handle (e.g., `you.bsky.social`) 47 + 3. Click "Login with Bluesky" 48 + 4. Authorize the app on your AT Protocol PDS 49 + 5. You'll be redirected back and logged in 50 + 51 + ## Usage 52 + 53 + - Click any emoji to set your status 54 + - View recent statuses from the network 55 + - Click "Logout" to clear your session 56 + 57 + ## Security Notes 58 + 59 + - Tokens are stored in `sessionStorage` (cleared when tab closes) 60 + - No external dependencies - all code is inline 61 + - Uses PKCE for secure OAuth flow 62 + - CSP header restricts connections to localhost:8080 63 + 64 + ## Troubleshooting 65 + 66 + **"Failed to load statuses"** 67 + - Ensure quickslice server is running at localhost:8080 68 + 69 + **OAuth redirect fails** 70 + - Verify redirect URI matches exactly in OAuth client config 71 + - Check that the client ID is correct 72 + 73 + **Can't post status** 74 + - Ensure you're logged in (session may have expired) 75 + - Check browser console for error details
+941
examples/02-following-feed/index.html
··· 1 + <!DOCTYPE html> 2 + <html lang="en"> 3 + <head> 4 + <meta charset="UTF-8"> 5 + <meta name="viewport" content="width=device-width, initial-scale=1.0"> 6 + <meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; connect-src http://localhost:8080; img-src 'self' https: data:;"> 7 + <title>Statusphere</title> 8 + <style> 9 + /* CSS Reset */ 10 + *, *::before, *::after { 11 + box-sizing: border-box; 12 + } 13 + * { 14 + margin: 0; 15 + } 16 + body { 17 + line-height: 1.5; 18 + -webkit-font-smoothing: antialiased; 19 + } 20 + input, button { 21 + font: inherit; 22 + } 23 + 24 + /* CSS Variables */ 25 + :root { 26 + --primary-500: #0078ff; 27 + --primary-400: #339dff; 28 + --primary-600: #0060cc; 29 + --gray-100: #f5f5f5; 30 + --gray-200: #e5e5e5; 31 + --gray-500: #737373; 32 + --gray-700: #404040; 33 + --gray-900: #171717; 34 + --border-color: #e5e5e5; 35 + --error-bg: #fef2f2; 36 + --error-border: #fecaca; 37 + --error-text: #dc2626; 38 + } 39 + 40 + /* Layout */ 41 + body { 42 + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; 43 + background: var(--gray-100); 44 + color: var(--gray-900); 45 + min-height: 100vh; 46 + padding: 2rem 1rem; 47 + } 48 + 49 + #app { 50 + max-width: 600px; 51 + margin: 0 auto; 52 + } 53 + 54 + /* Header */ 55 + header { 56 + text-align: center; 57 + margin-bottom: 2rem; 58 + } 59 + 60 + header h1 { 61 + font-size: 2.5rem; 62 + color: var(--primary-500); 63 + margin-bottom: 0.25rem; 64 + } 65 + 66 + .tagline { 67 + color: var(--gray-500); 68 + font-size: 1rem; 69 + } 70 + 71 + /* Cards */ 72 + .card { 73 + background: white; 74 + border-radius: 0.5rem; 75 + padding: 1.5rem; 76 + margin-bottom: 1rem; 77 + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1); 78 + } 79 + 80 + /* Auth Section */ 81 + .login-form { 82 + display: flex; 83 + flex-direction: column; 84 + gap: 1rem; 85 + } 86 + 87 + .form-group { 88 + display: flex; 89 + flex-direction: column; 90 + gap: 0.25rem; 91 + } 92 + 93 + .form-group label { 94 + font-size: 0.875rem; 95 + font-weight: 500; 96 + color: var(--gray-700); 97 + } 98 + 99 + .form-group input { 100 + padding: 0.75rem; 101 + border: 1px solid var(--border-color); 102 + border-radius: 0.375rem; 103 + font-size: 1rem; 104 + } 105 + 106 + .form-group input:focus { 107 + outline: none; 108 + border-color: var(--primary-500); 109 + box-shadow: 0 0 0 3px rgba(0, 120, 255, 0.1); 110 + } 111 + 112 + .btn { 113 + padding: 0.75rem 1.5rem; 114 + border: none; 115 + border-radius: 0.375rem; 116 + font-size: 1rem; 117 + font-weight: 500; 118 + cursor: pointer; 119 + transition: background-color 0.15s; 120 + } 121 + 122 + .btn-primary { 123 + background: var(--primary-500); 124 + color: white; 125 + } 126 + 127 + .btn-primary:hover { 128 + background: var(--primary-600); 129 + } 130 + 131 + .btn-primary:disabled { 132 + background: var(--gray-200); 133 + color: var(--gray-500); 134 + cursor: not-allowed; 135 + } 136 + 137 + .btn-secondary { 138 + background: var(--gray-200); 139 + color: var(--gray-700); 140 + } 141 + 142 + .btn-secondary:hover { 143 + background: var(--border-color); 144 + } 145 + 146 + /* User Card */ 147 + .user-card { 148 + display: flex; 149 + align-items: center; 150 + justify-content: space-between; 151 + } 152 + 153 + .user-info { 154 + display: flex; 155 + align-items: center; 156 + gap: 0.75rem; 157 + } 158 + 159 + .user-avatar { 160 + width: 48px; 161 + height: 48px; 162 + border-radius: 50%; 163 + background: var(--gray-200); 164 + display: flex; 165 + align-items: center; 166 + justify-content: center; 167 + font-size: 1.5rem; 168 + } 169 + 170 + .user-avatar img { 171 + width: 100%; 172 + height: 100%; 173 + border-radius: 50%; 174 + object-fit: cover; 175 + } 176 + 177 + .user-name { 178 + font-weight: 600; 179 + } 180 + 181 + .user-handle { 182 + font-size: 0.875rem; 183 + color: var(--gray-500); 184 + } 185 + 186 + /* Emoji Picker */ 187 + .emoji-grid { 188 + display: grid; 189 + grid-template-columns: repeat(9, 1fr); 190 + gap: 0.5rem; 191 + } 192 + 193 + .emoji-btn { 194 + width: 100%; 195 + aspect-ratio: 1; 196 + font-size: 1.5rem; 197 + border: 2px solid var(--border-color); 198 + border-radius: 50%; 199 + background: white; 200 + cursor: pointer; 201 + transition: all 0.15s; 202 + display: flex; 203 + align-items: center; 204 + justify-content: center; 205 + } 206 + 207 + .emoji-btn:hover { 208 + background: rgba(0, 120, 255, 0.1); 209 + border-color: var(--primary-400); 210 + } 211 + 212 + .emoji-btn.selected { 213 + border-color: var(--primary-500); 214 + box-shadow: 0 0 0 3px rgba(0, 120, 255, 0.2); 215 + } 216 + 217 + .emoji-btn:disabled { 218 + opacity: 0.5; 219 + cursor: not-allowed; 220 + } 221 + 222 + .emoji-btn:disabled:hover { 223 + background: white; 224 + border-color: var(--border-color); 225 + } 226 + 227 + /* Status Feed */ 228 + .feed-title { 229 + font-size: 1.125rem; 230 + font-weight: 600; 231 + margin-bottom: 1rem; 232 + color: var(--gray-700); 233 + } 234 + 235 + .status-list { 236 + list-style: none; 237 + padding: 0; 238 + } 239 + 240 + .status-item { 241 + position: relative; 242 + padding-left: 2rem; 243 + padding-bottom: 1.5rem; 244 + } 245 + 246 + .status-item::before { 247 + content: ""; 248 + position: absolute; 249 + left: 0.75rem; 250 + top: 1.5rem; 251 + bottom: 0; 252 + width: 2px; 253 + background: var(--border-color); 254 + } 255 + 256 + .status-item:last-child::before { 257 + display: none; 258 + } 259 + 260 + .status-item:last-child { 261 + padding-bottom: 0; 262 + } 263 + 264 + .status-emoji { 265 + position: absolute; 266 + left: 0; 267 + top: 0; 268 + font-size: 1.5rem; 269 + } 270 + 271 + .status-content { 272 + padding-top: 0.25rem; 273 + } 274 + 275 + .status-author { 276 + color: var(--primary-500); 277 + text-decoration: none; 278 + font-weight: 500; 279 + } 280 + 281 + .status-author:hover { 282 + text-decoration: underline; 283 + } 284 + 285 + .status-text { 286 + color: var(--gray-700); 287 + } 288 + 289 + .status-date { 290 + font-size: 0.875rem; 291 + color: var(--gray-500); 292 + } 293 + 294 + /* Error Banner */ 295 + #error-banner { 296 + position: fixed; 297 + top: 1rem; 298 + left: 50%; 299 + transform: translateX(-50%); 300 + background: var(--error-bg); 301 + border: 1px solid var(--error-border); 302 + color: var(--error-text); 303 + padding: 0.75rem 1rem; 304 + border-radius: 0.375rem; 305 + display: flex; 306 + align-items: center; 307 + gap: 0.75rem; 308 + max-width: 90%; 309 + z-index: 100; 310 + } 311 + 312 + #error-banner.hidden { 313 + display: none; 314 + } 315 + 316 + #error-banner button { 317 + background: none; 318 + border: none; 319 + color: var(--error-text); 320 + cursor: pointer; 321 + font-size: 1.25rem; 322 + line-height: 1; 323 + } 324 + 325 + /* Loading State */ 326 + .loading { 327 + text-align: center; 328 + color: var(--gray-500); 329 + padding: 2rem; 330 + } 331 + 332 + /* Responsive */ 333 + @media (max-width: 480px) { 334 + .emoji-grid { 335 + grid-template-columns: repeat(6, 1fr); 336 + } 337 + 338 + .emoji-btn { 339 + font-size: 1.25rem; 340 + } 341 + } 342 + 343 + /* Hidden utility */ 344 + .hidden { 345 + display: none !important; 346 + } 347 + </style> 348 + </head> 349 + <body> 350 + <div id="app"> 351 + <header> 352 + <h1>Statusphere</h1> 353 + <p class="tagline">Set your status on the Atmosphere</p> 354 + </header> 355 + <main> 356 + <div id="auth-section"></div> 357 + <div id="emoji-picker"></div> 358 + <div id="status-feed"></div> 359 + </main> 360 + <div id="error-banner" class="hidden"></div> 361 + </div> 362 + <script> 363 + // ============================================================================= 364 + // CONSTANTS 365 + // ============================================================================= 366 + 367 + const GRAPHQL_URL = 'http://localhost:8080/graphql'; 368 + const OAUTH_AUTHORIZE_URL = 'http://localhost:8080/oauth/authorize'; 369 + const OAUTH_TOKEN_URL = 'http://localhost:8080/oauth/token'; 370 + 371 + const EMOJIS = [ 372 + '👍', '👎', '💙', '😧', '😤', '🙃', '😉', '😎', '🤩', 373 + '🥳', '😭', '😱', '🥺', '😡', '💀', '🤖', '👻', '👽', 374 + '🎃', '🤡', '💩', '🔥', '⭐', '🌈', '🍕', '🎉', '💯' 375 + ]; 376 + 377 + const STORAGE_KEYS = { 378 + accessToken: 'qs_access_token', 379 + refreshToken: 'qs_refresh_token', 380 + userDid: 'qs_user_did', 381 + codeVerifier: 'qs_code_verifier', 382 + oauthState: 'qs_oauth_state', 383 + clientId: 'qs_client_id' 384 + }; 385 + 386 + // ============================================================================= 387 + // STORAGE UTILITIES 388 + // ============================================================================= 389 + 390 + const storage = { 391 + get(key) { 392 + return sessionStorage.getItem(key); 393 + }, 394 + set(key, value) { 395 + sessionStorage.setItem(key, value); 396 + }, 397 + remove(key) { 398 + sessionStorage.removeItem(key); 399 + }, 400 + clear() { 401 + Object.values(STORAGE_KEYS).forEach(key => sessionStorage.removeItem(key)); 402 + } 403 + }; 404 + 405 + // ============================================================================= 406 + // PKCE UTILITIES 407 + // ============================================================================= 408 + 409 + function base64UrlEncode(buffer) { 410 + const bytes = new Uint8Array(buffer); 411 + let binary = ''; 412 + for (let i = 0; i < bytes.length; i++) { 413 + binary += String.fromCharCode(bytes[i]); 414 + } 415 + return btoa(binary) 416 + .replace(/\+/g, '-') 417 + .replace(/\//g, '_') 418 + .replace(/=+$/, ''); 419 + } 420 + 421 + async function generateCodeVerifier() { 422 + const randomBytes = new Uint8Array(32); 423 + crypto.getRandomValues(randomBytes); 424 + return base64UrlEncode(randomBytes); 425 + } 426 + 427 + async function generateCodeChallenge(verifier) { 428 + const encoder = new TextEncoder(); 429 + const data = encoder.encode(verifier); 430 + const hash = await crypto.subtle.digest('SHA-256', data); 431 + return base64UrlEncode(hash); 432 + } 433 + 434 + function generateState() { 435 + const randomBytes = new Uint8Array(16); 436 + crypto.getRandomValues(randomBytes); 437 + return base64UrlEncode(randomBytes); 438 + } 439 + 440 + // ============================================================================= 441 + // OAUTH FUNCTIONS 442 + // ============================================================================= 443 + 444 + async function initiateLogin(clientId, handle) { 445 + const codeVerifier = await generateCodeVerifier(); 446 + const codeChallenge = await generateCodeChallenge(codeVerifier); 447 + const state = generateState(); 448 + 449 + // Store for callback 450 + storage.set(STORAGE_KEYS.codeVerifier, codeVerifier); 451 + storage.set(STORAGE_KEYS.oauthState, state); 452 + storage.set(STORAGE_KEYS.clientId, clientId); 453 + 454 + // Build redirect URI (current page without query params) 455 + const redirectUri = window.location.origin + window.location.pathname; 456 + 457 + // Build authorization URL 458 + const params = new URLSearchParams({ 459 + client_id: clientId, 460 + redirect_uri: redirectUri, 461 + response_type: 'code', 462 + code_challenge: codeChallenge, 463 + code_challenge_method: 'S256', 464 + state: state, 465 + login_hint: handle 466 + }); 467 + 468 + window.location.href = `${OAUTH_AUTHORIZE_URL}?${params.toString()}`; 469 + } 470 + 471 + async function handleOAuthCallback() { 472 + const params = new URLSearchParams(window.location.search); 473 + const code = params.get('code'); 474 + const state = params.get('state'); 475 + const error = params.get('error'); 476 + 477 + if (error) { 478 + throw new Error(`OAuth error: ${error} - ${params.get('error_description') || ''}`); 479 + } 480 + 481 + if (!code || !state) { 482 + return false; // Not a callback 483 + } 484 + 485 + // Verify state 486 + const storedState = storage.get(STORAGE_KEYS.oauthState); 487 + if (state !== storedState) { 488 + throw new Error('OAuth state mismatch - possible CSRF attack'); 489 + } 490 + 491 + // Get stored values 492 + const codeVerifier = storage.get(STORAGE_KEYS.codeVerifier); 493 + const clientId = storage.get(STORAGE_KEYS.clientId); 494 + const redirectUri = window.location.origin + window.location.pathname; 495 + 496 + if (!codeVerifier || !clientId) { 497 + throw new Error('Missing OAuth session data'); 498 + } 499 + 500 + // Exchange code for tokens 501 + const tokenResponse = await fetch(OAUTH_TOKEN_URL, { 502 + method: 'POST', 503 + headers: { 504 + 'Content-Type': 'application/x-www-form-urlencoded' 505 + }, 506 + body: new URLSearchParams({ 507 + grant_type: 'authorization_code', 508 + code: code, 509 + redirect_uri: redirectUri, 510 + client_id: clientId, 511 + code_verifier: codeVerifier 512 + }) 513 + }); 514 + 515 + if (!tokenResponse.ok) { 516 + const errorData = await tokenResponse.json().catch(() => ({})); 517 + throw new Error(`Token exchange failed: ${errorData.error_description || tokenResponse.statusText}`); 518 + } 519 + 520 + const tokens = await tokenResponse.json(); 521 + 522 + // Store tokens 523 + storage.set(STORAGE_KEYS.accessToken, tokens.access_token); 524 + if (tokens.refresh_token) { 525 + storage.set(STORAGE_KEYS.refreshToken, tokens.refresh_token); 526 + } 527 + 528 + // Extract DID from token response (sub claim) or we'll fetch it later 529 + if (tokens.sub) { 530 + storage.set(STORAGE_KEYS.userDid, tokens.sub); 531 + } 532 + 533 + // Clean up OAuth state 534 + storage.remove(STORAGE_KEYS.codeVerifier); 535 + storage.remove(STORAGE_KEYS.oauthState); 536 + 537 + // Clear URL params 538 + window.history.replaceState({}, document.title, window.location.pathname); 539 + 540 + return true; 541 + } 542 + 543 + function logout() { 544 + storage.clear(); 545 + window.location.reload(); 546 + } 547 + 548 + function isLoggedIn() { 549 + return !!storage.get(STORAGE_KEYS.accessToken); 550 + } 551 + 552 + function getAccessToken() { 553 + return storage.get(STORAGE_KEYS.accessToken); 554 + } 555 + 556 + function getUserDid() { 557 + return storage.get(STORAGE_KEYS.userDid); 558 + } 559 + 560 + // ============================================================================= 561 + // GRAPHQL UTILITIES 562 + // ============================================================================= 563 + 564 + async function graphqlQuery(query, variables = {}, requireAuth = false) { 565 + const headers = { 566 + 'Content-Type': 'application/json' 567 + }; 568 + 569 + if (requireAuth) { 570 + const token = getAccessToken(); 571 + if (!token) { 572 + throw new Error('Not authenticated'); 573 + } 574 + headers['Authorization'] = `Bearer ${token}`; 575 + } 576 + 577 + const response = await fetch(GRAPHQL_URL, { 578 + method: 'POST', 579 + headers, 580 + body: JSON.stringify({ query, variables }) 581 + }); 582 + 583 + if (!response.ok) { 584 + throw new Error(`GraphQL request failed: ${response.statusText}`); 585 + } 586 + 587 + const result = await response.json(); 588 + 589 + if (result.errors && result.errors.length > 0) { 590 + throw new Error(`GraphQL error: ${result.errors[0].message}`); 591 + } 592 + 593 + return result.data; 594 + } 595 + 596 + // ============================================================================= 597 + // DATA FETCHING 598 + // ============================================================================= 599 + 600 + async function fetchStatuses() { 601 + const query = ` 602 + query GetStatuses { 603 + xyzStatusphereStatus( 604 + first: 20 605 + sortBy: [{ field: "createdAt", direction: DESC }] 606 + ) { 607 + edges { 608 + node { 609 + uri 610 + did 611 + status 612 + createdAt 613 + appBskyActorProfileByDid { 614 + actorHandle 615 + displayName 616 + } 617 + } 618 + } 619 + } 620 + } 621 + `; 622 + 623 + const data = await graphqlQuery(query); 624 + return data.xyzStatusphereStatus?.edges?.map(e => e.node) || []; 625 + } 626 + 627 + async function fetchViewer() { 628 + const query = ` 629 + query { 630 + viewer { 631 + did 632 + handle 633 + appBskyActorProfileByDid { 634 + displayName 635 + avatar { url } 636 + } 637 + } 638 + } 639 + `; 640 + 641 + const data = await graphqlQuery(query, {}, true); 642 + return data?.viewer; 643 + } 644 + 645 + async function postStatus(emoji) { 646 + const mutation = ` 647 + mutation CreateStatus($status: String!, $createdAt: DateTime!) { 648 + createXyzStatusphereStatus( 649 + input: { status: $status, createdAt: $createdAt } 650 + ) { 651 + uri 652 + status 653 + createdAt 654 + } 655 + } 656 + `; 657 + 658 + const variables = { 659 + status: emoji, 660 + createdAt: new Date().toISOString() 661 + }; 662 + 663 + const data = await graphqlQuery(mutation, variables, true); 664 + return data.createXyzStatusphereStatus; 665 + } 666 + 667 + // ============================================================================= 668 + // UI RENDERING 669 + // ============================================================================= 670 + 671 + function showError(message) { 672 + const banner = document.getElementById('error-banner'); 673 + banner.innerHTML = ` 674 + <span>${escapeHtml(message)}</span> 675 + <button onclick="hideError()">&times;</button> 676 + `; 677 + banner.classList.remove('hidden'); 678 + } 679 + 680 + function hideError() { 681 + document.getElementById('error-banner').classList.add('hidden'); 682 + } 683 + 684 + function escapeHtml(text) { 685 + const div = document.createElement('div'); 686 + div.textContent = text; 687 + return div.innerHTML; 688 + } 689 + 690 + function formatDate(dateString) { 691 + const date = new Date(dateString); 692 + const now = new Date(); 693 + const isToday = date.toDateString() === now.toDateString(); 694 + 695 + if (isToday) { 696 + return 'today'; 697 + } 698 + 699 + return date.toLocaleDateString('en-US', { 700 + month: 'short', 701 + day: 'numeric', 702 + year: date.getFullYear() !== now.getFullYear() ? 'numeric' : undefined 703 + }); 704 + } 705 + 706 + function renderLoginForm() { 707 + const container = document.getElementById('auth-section'); 708 + const savedClientId = storage.get(STORAGE_KEYS.clientId) || ''; 709 + 710 + container.innerHTML = ` 711 + <div class="card"> 712 + <form class="login-form" onsubmit="handleLogin(event)"> 713 + <div class="form-group"> 714 + <label for="client-id">OAuth Client ID</label> 715 + <input 716 + type="text" 717 + id="client-id" 718 + placeholder="your-client-id" 719 + value="${escapeHtml(savedClientId)}" 720 + required 721 + > 722 + </div> 723 + <div class="form-group"> 724 + <label for="handle">Bluesky Handle</label> 725 + <input 726 + type="text" 727 + id="handle" 728 + placeholder="you.bsky.social" 729 + required 730 + > 731 + </div> 732 + <button type="submit" class="btn btn-primary">Login with Bluesky</button> 733 + </form> 734 + <p style="margin-top: 1rem; font-size: 0.875rem; color: var(--gray-500); text-align: center;"> 735 + Don't have a Bluesky account? <a href="https://bsky.app" target="_blank">Sign up</a> 736 + </p> 737 + </div> 738 + `; 739 + } 740 + 741 + function renderUserCard(profile) { 742 + const container = document.getElementById('auth-section'); 743 + const displayName = profile?.displayName || 'User'; 744 + const handle = profile?.actorHandle || 'unknown'; 745 + const avatarUrl = profile?.avatar?.url; 746 + 747 + container.innerHTML = ` 748 + <div class="card user-card"> 749 + <div class="user-info"> 750 + <div class="user-avatar"> 751 + ${avatarUrl 752 + ? `<img src="${escapeHtml(avatarUrl)}" alt="Avatar">` 753 + : '👤'} 754 + </div> 755 + <div> 756 + <div class="user-name">Hi, ${escapeHtml(displayName)}!</div> 757 + <div class="user-handle">@${escapeHtml(handle)}</div> 758 + </div> 759 + </div> 760 + <button class="btn btn-secondary" onclick="logout()">Logout</button> 761 + </div> 762 + `; 763 + } 764 + 765 + function renderEmojiPicker(currentStatus, enabled = true) { 766 + const container = document.getElementById('emoji-picker'); 767 + 768 + container.innerHTML = ` 769 + <div class="card"> 770 + <div class="emoji-grid"> 771 + ${EMOJIS.map(emoji => ` 772 + <button 773 + class="emoji-btn ${emoji === currentStatus ? 'selected' : ''}" 774 + onclick="selectStatus('${emoji}')" 775 + ${!enabled ? 'disabled' : ''} 776 + title="${enabled ? 'Set status' : 'Login to set status'}" 777 + > 778 + ${emoji} 779 + </button> 780 + `).join('')} 781 + </div> 782 + </div> 783 + `; 784 + } 785 + 786 + function renderStatusFeed(statuses) { 787 + const container = document.getElementById('status-feed'); 788 + 789 + if (statuses.length === 0) { 790 + container.innerHTML = ` 791 + <div class="card"> 792 + <p class="loading">No statuses yet. Be the first to post!</p> 793 + </div> 794 + `; 795 + return; 796 + } 797 + 798 + container.innerHTML = ` 799 + <div class="card"> 800 + <h2 class="feed-title">Recent Statuses</h2> 801 + <ul class="status-list"> 802 + ${statuses.map(status => { 803 + const handle = status.appBskyActorProfileByDid?.actorHandle || status.did; 804 + const displayHandle = handle.startsWith('did:') ? handle.substring(0, 20) + '...' : handle; 805 + const profileUrl = handle.startsWith('did:') 806 + ? `https://bsky.app/profile/${status.did}` 807 + : `https://bsky.app/profile/${handle}`; 808 + 809 + return ` 810 + <li class="status-item"> 811 + <span class="status-emoji">${status.status}</span> 812 + <div class="status-content"> 813 + <span class="status-text"> 814 + <a href="${profileUrl}" target="_blank" class="status-author">@${escapeHtml(displayHandle)}</a> 815 + is feeling ${status.status} 816 + </span> 817 + <div class="status-date">${formatDate(status.createdAt)}</div> 818 + </div> 819 + </li> 820 + `; 821 + }).join('')} 822 + </ul> 823 + </div> 824 + `; 825 + } 826 + 827 + function renderLoading(container) { 828 + document.getElementById(container).innerHTML = ` 829 + <div class="card"> 830 + <p class="loading">Loading...</p> 831 + </div> 832 + `; 833 + } 834 + 835 + // ============================================================================= 836 + // EVENT HANDLERS 837 + // ============================================================================= 838 + 839 + async function handleLogin(event) { 840 + event.preventDefault(); 841 + 842 + const clientId = document.getElementById('client-id').value.trim(); 843 + const handle = document.getElementById('handle').value.trim(); 844 + 845 + if (!clientId || !handle) { 846 + showError('Please enter both Client ID and Handle'); 847 + return; 848 + } 849 + 850 + try { 851 + await initiateLogin(clientId, handle); 852 + } catch (error) { 853 + showError(`Login failed: ${error.message}`); 854 + } 855 + } 856 + 857 + async function selectStatus(emoji) { 858 + if (!isLoggedIn()) { 859 + showError('Please login to set your status'); 860 + return; 861 + } 862 + 863 + try { 864 + // Disable buttons while posting 865 + document.querySelectorAll('.emoji-btn').forEach(btn => btn.disabled = true); 866 + 867 + await postStatus(emoji); 868 + 869 + // Refresh the page to show new status 870 + window.location.reload(); 871 + } catch (error) { 872 + showError(`Failed to post status: ${error.message}`); 873 + // Re-enable buttons 874 + document.querySelectorAll('.emoji-btn').forEach(btn => btn.disabled = false); 875 + } 876 + } 877 + 878 + // ============================================================================= 879 + // MAIN INITIALIZATION 880 + // ============================================================================= 881 + 882 + async function main() { 883 + try { 884 + // Check if this is an OAuth callback 885 + const isCallback = await handleOAuthCallback(); 886 + if (isCallback) { 887 + console.log('OAuth callback handled successfully'); 888 + } 889 + } catch (error) { 890 + showError(`Authentication failed: ${error.message}`); 891 + storage.clear(); 892 + } 893 + 894 + // Render auth section 895 + if (isLoggedIn()) { 896 + try { 897 + const viewer = await fetchViewer(); 898 + if (viewer) { 899 + const profile = { 900 + did: viewer.did, 901 + actorHandle: viewer.handle, 902 + displayName: viewer.appBskyActorProfileByDid?.displayName, 903 + avatar: viewer.appBskyActorProfileByDid?.avatar, 904 + }; 905 + renderUserCard(profile); 906 + } else { 907 + renderUserCard(null); 908 + } 909 + } catch (error) { 910 + console.error('Failed to fetch viewer:', error); 911 + renderUserCard(null); 912 + } 913 + } else { 914 + renderLoginForm(); 915 + } 916 + 917 + // Render emoji picker (enabled only if logged in) 918 + renderEmojiPicker(null, isLoggedIn()); 919 + 920 + // Fetch and render statuses 921 + renderLoading('status-feed'); 922 + try { 923 + const statuses = await fetchStatuses(); 924 + renderStatusFeed(statuses); 925 + } catch (error) { 926 + console.error('Failed to fetch statuses:', error); 927 + document.getElementById('status-feed').innerHTML = ` 928 + <div class="card"> 929 + <p class="loading" style="color: var(--error-text);"> 930 + Failed to load statuses. Is the quickslice server running at localhost:8080? 931 + </p> 932 + </div> 933 + `; 934 + } 935 + } 936 + 937 + // Run on page load 938 + main(); 939 + </script> 940 + </body> 941 + </html>
+7
examples/02-following-feed/lexicons.json
··· 1 + { 2 + "lexicons": [ 3 + "app.bsky.feed.post", 4 + "app.bsky.graph.follow", 5 + "app.bsky.actor.profile" 6 + ] 7 + }
examples/02-following-feed/lexicons.zip

This is a binary file and will not be displayed.

+882
examples/02-following-feed/lexicons/app/bsky/actor/defs.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "app.bsky.actor.defs", 4 + "defs": { 5 + "nux": { 6 + "type": "object", 7 + "required": [ 8 + "id", 9 + "completed" 10 + ], 11 + "properties": { 12 + "id": { 13 + "type": "string", 14 + "maxLength": 100 15 + }, 16 + "data": { 17 + "type": "string", 18 + "maxLength": 3000, 19 + "description": "Arbitrary data for the NUX. The structure is defined by the NUX itself. Limited to 300 characters.", 20 + "maxGraphemes": 300 21 + }, 22 + "completed": { 23 + "type": "boolean", 24 + "default": false 25 + }, 26 + "expiresAt": { 27 + "type": "string", 28 + "format": "datetime", 29 + "description": "The date and time at which the NUX will expire and should be considered completed." 30 + } 31 + }, 32 + "description": "A new user experiences (NUX) storage object" 33 + }, 34 + "mutedWord": { 35 + "type": "object", 36 + "required": [ 37 + "value", 38 + "targets" 39 + ], 40 + "properties": { 41 + "id": { 42 + "type": "string" 43 + }, 44 + "value": { 45 + "type": "string", 46 + "maxLength": 10000, 47 + "description": "The muted word itself.", 48 + "maxGraphemes": 1000 49 + }, 50 + "targets": { 51 + "type": "array", 52 + "items": { 53 + "ref": "app.bsky.actor.defs#mutedWordTarget", 54 + "type": "ref" 55 + }, 56 + "description": "The intended targets of the muted word." 57 + }, 58 + "expiresAt": { 59 + "type": "string", 60 + "format": "datetime", 61 + "description": "The date and time at which the muted word will expire and no longer be applied." 62 + }, 63 + "actorTarget": { 64 + "type": "string", 65 + "default": "all", 66 + "description": "Groups of users to apply the muted word to. If undefined, applies to all users.", 67 + "knownValues": [ 68 + "all", 69 + "exclude-following" 70 + ] 71 + } 72 + }, 73 + "description": "A word that the account owner has muted." 74 + }, 75 + "savedFeed": { 76 + "type": "object", 77 + "required": [ 78 + "id", 79 + "type", 80 + "value", 81 + "pinned" 82 + ], 83 + "properties": { 84 + "id": { 85 + "type": "string" 86 + }, 87 + "type": { 88 + "type": "string", 89 + "knownValues": [ 90 + "feed", 91 + "list", 92 + "timeline" 93 + ] 94 + }, 95 + "value": { 96 + "type": "string" 97 + }, 98 + "pinned": { 99 + "type": "boolean" 100 + } 101 + } 102 + }, 103 + "statusView": { 104 + "type": "object", 105 + "required": [ 106 + "status", 107 + "record" 108 + ], 109 + "properties": { 110 + "embed": { 111 + "refs": [ 112 + "app.bsky.embed.external#view" 113 + ], 114 + "type": "union", 115 + "description": "An optional embed associated with the status." 116 + }, 117 + "record": { 118 + "type": "unknown" 119 + }, 120 + "status": { 121 + "type": "string", 122 + "description": "The status for the account.", 123 + "knownValues": [ 124 + "app.bsky.actor.status#live" 125 + ] 126 + }, 127 + "isActive": { 128 + "type": "boolean", 129 + "description": "True if the status is not expired, false if it is expired. Only present if expiration was set." 130 + }, 131 + "expiresAt": { 132 + "type": "string", 133 + "format": "datetime", 134 + "description": "The date when this status will expire. The application might choose to no longer return the status after expiration." 135 + } 136 + } 137 + }, 138 + "preferences": { 139 + "type": "array", 140 + "items": { 141 + "refs": [ 142 + "#adultContentPref", 143 + "#contentLabelPref", 144 + "#savedFeedsPref", 145 + "#savedFeedsPrefV2", 146 + "#personalDetailsPref", 147 + "#feedViewPref", 148 + "#threadViewPref", 149 + "#interestsPref", 150 + "#mutedWordsPref", 151 + "#hiddenPostsPref", 152 + "#bskyAppStatePref", 153 + "#labelersPref", 154 + "#postInteractionSettingsPref", 155 + "#verificationPrefs" 156 + ], 157 + "type": "union" 158 + } 159 + }, 160 + "profileView": { 161 + "type": "object", 162 + "required": [ 163 + "did", 164 + "handle" 165 + ], 166 + "properties": { 167 + "did": { 168 + "type": "string", 169 + "format": "did" 170 + }, 171 + "debug": { 172 + "type": "unknown", 173 + "description": "Debug information for internal development" 174 + }, 175 + "avatar": { 176 + "type": "string", 177 + "format": "uri" 178 + }, 179 + "handle": { 180 + "type": "string", 181 + "format": "handle" 182 + }, 183 + "labels": { 184 + "type": "array", 185 + "items": { 186 + "ref": "com.atproto.label.defs#label", 187 + "type": "ref" 188 + } 189 + }, 190 + "status": { 191 + "ref": "#statusView", 192 + "type": "ref" 193 + }, 194 + "viewer": { 195 + "ref": "#viewerState", 196 + "type": "ref" 197 + }, 198 + "pronouns": { 199 + "type": "string" 200 + }, 201 + "createdAt": { 202 + "type": "string", 203 + "format": "datetime" 204 + }, 205 + "indexedAt": { 206 + "type": "string", 207 + "format": "datetime" 208 + }, 209 + "associated": { 210 + "ref": "#profileAssociated", 211 + "type": "ref" 212 + }, 213 + "description": { 214 + "type": "string", 215 + "maxLength": 2560, 216 + "maxGraphemes": 256 217 + }, 218 + "displayName": { 219 + "type": "string", 220 + "maxLength": 640, 221 + "maxGraphemes": 64 222 + }, 223 + "verification": { 224 + "ref": "#verificationState", 225 + "type": "ref" 226 + } 227 + } 228 + }, 229 + "viewerState": { 230 + "type": "object", 231 + "properties": { 232 + "muted": { 233 + "type": "boolean" 234 + }, 235 + "blocking": { 236 + "type": "string", 237 + "format": "at-uri" 238 + }, 239 + "blockedBy": { 240 + "type": "boolean" 241 + }, 242 + "following": { 243 + "type": "string", 244 + "format": "at-uri" 245 + }, 246 + "followedBy": { 247 + "type": "string", 248 + "format": "at-uri" 249 + }, 250 + "mutedByList": { 251 + "ref": "app.bsky.graph.defs#listViewBasic", 252 + "type": "ref" 253 + }, 254 + "blockingByList": { 255 + "ref": "app.bsky.graph.defs#listViewBasic", 256 + "type": "ref" 257 + }, 258 + "knownFollowers": { 259 + "ref": "#knownFollowers", 260 + "type": "ref", 261 + "description": "This property is present only in selected cases, as an optimization." 262 + }, 263 + "activitySubscription": { 264 + "ref": "app.bsky.notification.defs#activitySubscription", 265 + "type": "ref", 266 + "description": "This property is present only in selected cases, as an optimization." 267 + } 268 + }, 269 + "description": "Metadata about the requesting account's relationship with the subject account. Only has meaningful content for authed requests." 270 + }, 271 + "feedViewPref": { 272 + "type": "object", 273 + "required": [ 274 + "feed" 275 + ], 276 + "properties": { 277 + "feed": { 278 + "type": "string", 279 + "description": "The URI of the feed, or an identifier which describes the feed." 280 + }, 281 + "hideReplies": { 282 + "type": "boolean", 283 + "description": "Hide replies in the feed." 284 + }, 285 + "hideReposts": { 286 + "type": "boolean", 287 + "description": "Hide reposts in the feed." 288 + }, 289 + "hideQuotePosts": { 290 + "type": "boolean", 291 + "description": "Hide quote posts in the feed." 292 + }, 293 + "hideRepliesByLikeCount": { 294 + "type": "integer", 295 + "description": "Hide replies in the feed if they do not have this number of likes." 296 + }, 297 + "hideRepliesByUnfollowed": { 298 + "type": "boolean", 299 + "default": true, 300 + "description": "Hide replies in the feed if they are not by followed users." 301 + } 302 + } 303 + }, 304 + "labelersPref": { 305 + "type": "object", 306 + "required": [ 307 + "labelers" 308 + ], 309 + "properties": { 310 + "labelers": { 311 + "type": "array", 312 + "items": { 313 + "ref": "#labelerPrefItem", 314 + "type": "ref" 315 + } 316 + } 317 + } 318 + }, 319 + "interestsPref": { 320 + "type": "object", 321 + "required": [ 322 + "tags" 323 + ], 324 + "properties": { 325 + "tags": { 326 + "type": "array", 327 + "items": { 328 + "type": "string", 329 + "maxLength": 640, 330 + "maxGraphemes": 64 331 + }, 332 + "maxLength": 100, 333 + "description": "A list of tags which describe the account owner's interests gathered during onboarding." 334 + } 335 + } 336 + }, 337 + "knownFollowers": { 338 + "type": "object", 339 + "required": [ 340 + "count", 341 + "followers" 342 + ], 343 + "properties": { 344 + "count": { 345 + "type": "integer" 346 + }, 347 + "followers": { 348 + "type": "array", 349 + "items": { 350 + "ref": "#profileViewBasic", 351 + "type": "ref" 352 + }, 353 + "maxLength": 5, 354 + "minLength": 0 355 + } 356 + }, 357 + "description": "The subject's followers whom you also follow" 358 + }, 359 + "mutedWordsPref": { 360 + "type": "object", 361 + "required": [ 362 + "items" 363 + ], 364 + "properties": { 365 + "items": { 366 + "type": "array", 367 + "items": { 368 + "ref": "app.bsky.actor.defs#mutedWord", 369 + "type": "ref" 370 + }, 371 + "description": "A list of words the account owner has muted." 372 + } 373 + } 374 + }, 375 + "savedFeedsPref": { 376 + "type": "object", 377 + "required": [ 378 + "pinned", 379 + "saved" 380 + ], 381 + "properties": { 382 + "saved": { 383 + "type": "array", 384 + "items": { 385 + "type": "string", 386 + "format": "at-uri" 387 + } 388 + }, 389 + "pinned": { 390 + "type": "array", 391 + "items": { 392 + "type": "string", 393 + "format": "at-uri" 394 + } 395 + }, 396 + "timelineIndex": { 397 + "type": "integer" 398 + } 399 + } 400 + }, 401 + "threadViewPref": { 402 + "type": "object", 403 + "properties": { 404 + "sort": { 405 + "type": "string", 406 + "description": "Sorting mode for threads.", 407 + "knownValues": [ 408 + "oldest", 409 + "newest", 410 + "most-likes", 411 + "random", 412 + "hotness" 413 + ] 414 + } 415 + } 416 + }, 417 + "hiddenPostsPref": { 418 + "type": "object", 419 + "required": [ 420 + "items" 421 + ], 422 + "properties": { 423 + "items": { 424 + "type": "array", 425 + "items": { 426 + "type": "string", 427 + "format": "at-uri" 428 + }, 429 + "description": "A list of URIs of posts the account owner has hidden." 430 + } 431 + } 432 + }, 433 + "labelerPrefItem": { 434 + "type": "object", 435 + "required": [ 436 + "did" 437 + ], 438 + "properties": { 439 + "did": { 440 + "type": "string", 441 + "format": "did" 442 + } 443 + } 444 + }, 445 + "mutedWordTarget": { 446 + "type": "string", 447 + "maxLength": 640, 448 + "knownValues": [ 449 + "content", 450 + "tag" 451 + ], 452 + "maxGraphemes": 64 453 + }, 454 + "adultContentPref": { 455 + "type": "object", 456 + "required": [ 457 + "enabled" 458 + ], 459 + "properties": { 460 + "enabled": { 461 + "type": "boolean", 462 + "default": false 463 + } 464 + } 465 + }, 466 + "bskyAppStatePref": { 467 + "type": "object", 468 + "properties": { 469 + "nuxs": { 470 + "type": "array", 471 + "items": { 472 + "ref": "app.bsky.actor.defs#nux", 473 + "type": "ref" 474 + }, 475 + "maxLength": 100, 476 + "description": "Storage for NUXs the user has encountered." 477 + }, 478 + "queuedNudges": { 479 + "type": "array", 480 + "items": { 481 + "type": "string", 482 + "maxLength": 100 483 + }, 484 + "maxLength": 1000, 485 + "description": "An array of tokens which identify nudges (modals, popups, tours, highlight dots) that should be shown to the user." 486 + }, 487 + "activeProgressGuide": { 488 + "ref": "#bskyAppProgressGuide", 489 + "type": "ref" 490 + } 491 + }, 492 + "description": "A grab bag of state that's specific to the bsky.app program. Third-party apps shouldn't use this." 493 + }, 494 + "contentLabelPref": { 495 + "type": "object", 496 + "required": [ 497 + "label", 498 + "visibility" 499 + ], 500 + "properties": { 501 + "label": { 502 + "type": "string" 503 + }, 504 + "labelerDid": { 505 + "type": "string", 506 + "format": "did", 507 + "description": "Which labeler does this preference apply to? If undefined, applies globally." 508 + }, 509 + "visibility": { 510 + "type": "string", 511 + "knownValues": [ 512 + "ignore", 513 + "show", 514 + "warn", 515 + "hide" 516 + ] 517 + } 518 + } 519 + }, 520 + "profileViewBasic": { 521 + "type": "object", 522 + "required": [ 523 + "did", 524 + "handle" 525 + ], 526 + "properties": { 527 + "did": { 528 + "type": "string", 529 + "format": "did" 530 + }, 531 + "debug": { 532 + "type": "unknown", 533 + "description": "Debug information for internal development" 534 + }, 535 + "avatar": { 536 + "type": "string", 537 + "format": "uri" 538 + }, 539 + "handle": { 540 + "type": "string", 541 + "format": "handle" 542 + }, 543 + "labels": { 544 + "type": "array", 545 + "items": { 546 + "ref": "com.atproto.label.defs#label", 547 + "type": "ref" 548 + } 549 + }, 550 + "status": { 551 + "ref": "#statusView", 552 + "type": "ref" 553 + }, 554 + "viewer": { 555 + "ref": "#viewerState", 556 + "type": "ref" 557 + }, 558 + "pronouns": { 559 + "type": "string" 560 + }, 561 + "createdAt": { 562 + "type": "string", 563 + "format": "datetime" 564 + }, 565 + "associated": { 566 + "ref": "#profileAssociated", 567 + "type": "ref" 568 + }, 569 + "displayName": { 570 + "type": "string", 571 + "maxLength": 640, 572 + "maxGraphemes": 64 573 + }, 574 + "verification": { 575 + "ref": "#verificationState", 576 + "type": "ref" 577 + } 578 + } 579 + }, 580 + "savedFeedsPrefV2": { 581 + "type": "object", 582 + "required": [ 583 + "items" 584 + ], 585 + "properties": { 586 + "items": { 587 + "type": "array", 588 + "items": { 589 + "ref": "app.bsky.actor.defs#savedFeed", 590 + "type": "ref" 591 + } 592 + } 593 + } 594 + }, 595 + "verificationView": { 596 + "type": "object", 597 + "required": [ 598 + "issuer", 599 + "uri", 600 + "isValid", 601 + "createdAt" 602 + ], 603 + "properties": { 604 + "uri": { 605 + "type": "string", 606 + "format": "at-uri", 607 + "description": "The AT-URI of the verification record." 608 + }, 609 + "issuer": { 610 + "type": "string", 611 + "format": "did", 612 + "description": "The user who issued this verification." 613 + }, 614 + "isValid": { 615 + "type": "boolean", 616 + "description": "True if the verification passes validation, otherwise false." 617 + }, 618 + "createdAt": { 619 + "type": "string", 620 + "format": "datetime", 621 + "description": "Timestamp when the verification was created." 622 + } 623 + }, 624 + "description": "An individual verification for an associated subject." 625 + }, 626 + "profileAssociated": { 627 + "type": "object", 628 + "properties": { 629 + "chat": { 630 + "ref": "#profileAssociatedChat", 631 + "type": "ref" 632 + }, 633 + "lists": { 634 + "type": "integer" 635 + }, 636 + "labeler": { 637 + "type": "boolean" 638 + }, 639 + "feedgens": { 640 + "type": "integer" 641 + }, 642 + "starterPacks": { 643 + "type": "integer" 644 + }, 645 + "activitySubscription": { 646 + "ref": "#profileAssociatedActivitySubscription", 647 + "type": "ref" 648 + } 649 + } 650 + }, 651 + "verificationPrefs": { 652 + "type": "object", 653 + "required": [], 654 + "properties": { 655 + "hideBadges": { 656 + "type": "boolean", 657 + "default": false, 658 + "description": "Hide the blue check badges for verified accounts and trusted verifiers." 659 + } 660 + }, 661 + "description": "Preferences for how verified accounts appear in the app." 662 + }, 663 + "verificationState": { 664 + "type": "object", 665 + "required": [ 666 + "verifications", 667 + "verifiedStatus", 668 + "trustedVerifierStatus" 669 + ], 670 + "properties": { 671 + "verifications": { 672 + "type": "array", 673 + "items": { 674 + "ref": "#verificationView", 675 + "type": "ref" 676 + }, 677 + "description": "All verifications issued by trusted verifiers on behalf of this user. Verifications by untrusted verifiers are not included." 678 + }, 679 + "verifiedStatus": { 680 + "type": "string", 681 + "description": "The user's status as a verified account.", 682 + "knownValues": [ 683 + "valid", 684 + "invalid", 685 + "none" 686 + ] 687 + }, 688 + "trustedVerifierStatus": { 689 + "type": "string", 690 + "description": "The user's status as a trusted verifier.", 691 + "knownValues": [ 692 + "valid", 693 + "invalid", 694 + "none" 695 + ] 696 + } 697 + }, 698 + "description": "Represents the verification information about the user this object is attached to." 699 + }, 700 + "personalDetailsPref": { 701 + "type": "object", 702 + "properties": { 703 + "birthDate": { 704 + "type": "string", 705 + "format": "datetime", 706 + "description": "The birth date of account owner." 707 + } 708 + } 709 + }, 710 + "profileViewDetailed": { 711 + "type": "object", 712 + "required": [ 713 + "did", 714 + "handle" 715 + ], 716 + "properties": { 717 + "did": { 718 + "type": "string", 719 + "format": "did" 720 + }, 721 + "debug": { 722 + "type": "unknown", 723 + "description": "Debug information for internal development" 724 + }, 725 + "avatar": { 726 + "type": "string", 727 + "format": "uri" 728 + }, 729 + "banner": { 730 + "type": "string", 731 + "format": "uri" 732 + }, 733 + "handle": { 734 + "type": "string", 735 + "format": "handle" 736 + }, 737 + "labels": { 738 + "type": "array", 739 + "items": { 740 + "ref": "com.atproto.label.defs#label", 741 + "type": "ref" 742 + } 743 + }, 744 + "status": { 745 + "ref": "#statusView", 746 + "type": "ref" 747 + }, 748 + "viewer": { 749 + "ref": "#viewerState", 750 + "type": "ref" 751 + }, 752 + "website": { 753 + "type": "string", 754 + "format": "uri" 755 + }, 756 + "pronouns": { 757 + "type": "string" 758 + }, 759 + "createdAt": { 760 + "type": "string", 761 + "format": "datetime" 762 + }, 763 + "indexedAt": { 764 + "type": "string", 765 + "format": "datetime" 766 + }, 767 + "associated": { 768 + "ref": "#profileAssociated", 769 + "type": "ref" 770 + }, 771 + "pinnedPost": { 772 + "ref": "com.atproto.repo.strongRef", 773 + "type": "ref" 774 + }, 775 + "postsCount": { 776 + "type": "integer" 777 + }, 778 + "description": { 779 + "type": "string", 780 + "maxLength": 2560, 781 + "maxGraphemes": 256 782 + }, 783 + "displayName": { 784 + "type": "string", 785 + "maxLength": 640, 786 + "maxGraphemes": 64 787 + }, 788 + "followsCount": { 789 + "type": "integer" 790 + }, 791 + "verification": { 792 + "ref": "#verificationState", 793 + "type": "ref" 794 + }, 795 + "followersCount": { 796 + "type": "integer" 797 + }, 798 + "joinedViaStarterPack": { 799 + "ref": "app.bsky.graph.defs#starterPackViewBasic", 800 + "type": "ref" 801 + } 802 + } 803 + }, 804 + "bskyAppProgressGuide": { 805 + "type": "object", 806 + "required": [ 807 + "guide" 808 + ], 809 + "properties": { 810 + "guide": { 811 + "type": "string", 812 + "maxLength": 100 813 + } 814 + }, 815 + "description": "If set, an active progress guide. Once completed, can be set to undefined. Should have unspecced fields tracking progress." 816 + }, 817 + "profileAssociatedChat": { 818 + "type": "object", 819 + "required": [ 820 + "allowIncoming" 821 + ], 822 + "properties": { 823 + "allowIncoming": { 824 + "type": "string", 825 + "knownValues": [ 826 + "all", 827 + "none", 828 + "following" 829 + ] 830 + } 831 + } 832 + }, 833 + "postInteractionSettingsPref": { 834 + "type": "object", 835 + "required": [], 836 + "properties": { 837 + "threadgateAllowRules": { 838 + "type": "array", 839 + "items": { 840 + "refs": [ 841 + "app.bsky.feed.threadgate#mentionRule", 842 + "app.bsky.feed.threadgate#followerRule", 843 + "app.bsky.feed.threadgate#followingRule", 844 + "app.bsky.feed.threadgate#listRule" 845 + ], 846 + "type": "union" 847 + }, 848 + "maxLength": 5, 849 + "description": "Matches threadgate record. List of rules defining who can reply to this users posts. If value is an empty array, no one can reply. If value is undefined, anyone can reply." 850 + }, 851 + "postgateEmbeddingRules": { 852 + "type": "array", 853 + "items": { 854 + "refs": [ 855 + "app.bsky.feed.postgate#disableRule" 856 + ], 857 + "type": "union" 858 + }, 859 + "maxLength": 5, 860 + "description": "Matches postgate record. List of rules defining who can embed this users posts. If value is an empty array or is undefined, no particular rules apply and anyone can embed." 861 + } 862 + }, 863 + "description": "Default post interaction settings for the account. These values should be applied as default values when creating new posts. These refs should mirror the threadgate and postgate records exactly." 864 + }, 865 + "profileAssociatedActivitySubscription": { 866 + "type": "object", 867 + "required": [ 868 + "allowSubscriptions" 869 + ], 870 + "properties": { 871 + "allowSubscriptions": { 872 + "type": "string", 873 + "knownValues": [ 874 + "followers", 875 + "mutuals", 876 + "none" 877 + ] 878 + } 879 + } 880 + } 881 + } 882 + }
+74
examples/02-following-feed/lexicons/app/bsky/actor/profile.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "app.bsky.actor.profile", 4 + "defs": { 5 + "main": { 6 + "key": "literal:self", 7 + "type": "record", 8 + "record": { 9 + "type": "object", 10 + "properties": { 11 + "avatar": { 12 + "type": "blob", 13 + "accept": [ 14 + "image/png", 15 + "image/jpeg" 16 + ], 17 + "maxSize": 1000000, 18 + "description": "Small image to be displayed next to posts from account. AKA, 'profile picture'" 19 + }, 20 + "banner": { 21 + "type": "blob", 22 + "accept": [ 23 + "image/png", 24 + "image/jpeg" 25 + ], 26 + "maxSize": 1000000, 27 + "description": "Larger horizontal image to display behind profile view." 28 + }, 29 + "labels": { 30 + "refs": [ 31 + "com.atproto.label.defs#selfLabels" 32 + ], 33 + "type": "union", 34 + "description": "Self-label values, specific to the Bluesky application, on the overall account." 35 + }, 36 + "website": { 37 + "type": "string", 38 + "format": "uri" 39 + }, 40 + "pronouns": { 41 + "type": "string", 42 + "maxLength": 200, 43 + "description": "Free-form pronouns text.", 44 + "maxGraphemes": 20 45 + }, 46 + "createdAt": { 47 + "type": "string", 48 + "format": "datetime" 49 + }, 50 + "pinnedPost": { 51 + "ref": "com.atproto.repo.strongRef", 52 + "type": "ref" 53 + }, 54 + "description": { 55 + "type": "string", 56 + "maxLength": 2560, 57 + "description": "Free-form profile description text.", 58 + "maxGraphemes": 256 59 + }, 60 + "displayName": { 61 + "type": "string", 62 + "maxLength": 640, 63 + "maxGraphemes": 64 64 + }, 65 + "joinedViaStarterPack": { 66 + "ref": "com.atproto.repo.strongRef", 67 + "type": "ref" 68 + } 69 + } 70 + }, 71 + "description": "A declaration of a Bluesky account profile." 72 + } 73 + } 74 + }
+24
examples/02-following-feed/lexicons/app/bsky/embed/defs.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "app.bsky.embed.defs", 4 + "defs": { 5 + "aspectRatio": { 6 + "type": "object", 7 + "required": [ 8 + "width", 9 + "height" 10 + ], 11 + "properties": { 12 + "width": { 13 + "type": "integer", 14 + "minimum": 1 15 + }, 16 + "height": { 17 + "type": "integer", 18 + "minimum": 1 19 + } 20 + }, 21 + "description": "width:height represents an aspect ratio. It may be approximate, and may not correspond to absolute dimensions in any given unit." 22 + } 23 + } 24 + }
+82
examples/02-following-feed/lexicons/app/bsky/embed/external.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "app.bsky.embed.external", 4 + "defs": { 5 + "main": { 6 + "type": "object", 7 + "required": [ 8 + "external" 9 + ], 10 + "properties": { 11 + "external": { 12 + "ref": "#external", 13 + "type": "ref" 14 + } 15 + }, 16 + "description": "A representation of some externally linked content (eg, a URL and 'card'), embedded in a Bluesky record (eg, a post)." 17 + }, 18 + "view": { 19 + "type": "object", 20 + "required": [ 21 + "external" 22 + ], 23 + "properties": { 24 + "external": { 25 + "ref": "#viewExternal", 26 + "type": "ref" 27 + } 28 + } 29 + }, 30 + "external": { 31 + "type": "object", 32 + "required": [ 33 + "uri", 34 + "title", 35 + "description" 36 + ], 37 + "properties": { 38 + "uri": { 39 + "type": "string", 40 + "format": "uri" 41 + }, 42 + "thumb": { 43 + "type": "blob", 44 + "accept": [ 45 + "image/*" 46 + ], 47 + "maxSize": 1000000 48 + }, 49 + "title": { 50 + "type": "string" 51 + }, 52 + "description": { 53 + "type": "string" 54 + } 55 + } 56 + }, 57 + "viewExternal": { 58 + "type": "object", 59 + "required": [ 60 + "uri", 61 + "title", 62 + "description" 63 + ], 64 + "properties": { 65 + "uri": { 66 + "type": "string", 67 + "format": "uri" 68 + }, 69 + "thumb": { 70 + "type": "string", 71 + "format": "uri" 72 + }, 73 + "title": { 74 + "type": "string" 75 + }, 76 + "description": { 77 + "type": "string" 78 + } 79 + } 80 + } 81 + } 82 + }
+91
examples/02-following-feed/lexicons/app/bsky/embed/images.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "app.bsky.embed.images", 4 + "description": "A set of images embedded in a Bluesky record (eg, a post).", 5 + "defs": { 6 + "main": { 7 + "type": "object", 8 + "required": [ 9 + "images" 10 + ], 11 + "properties": { 12 + "images": { 13 + "type": "array", 14 + "items": { 15 + "ref": "#image", 16 + "type": "ref" 17 + }, 18 + "maxLength": 4 19 + } 20 + } 21 + }, 22 + "view": { 23 + "type": "object", 24 + "required": [ 25 + "images" 26 + ], 27 + "properties": { 28 + "images": { 29 + "type": "array", 30 + "items": { 31 + "ref": "#viewImage", 32 + "type": "ref" 33 + }, 34 + "maxLength": 4 35 + } 36 + } 37 + }, 38 + "image": { 39 + "type": "object", 40 + "required": [ 41 + "image", 42 + "alt" 43 + ], 44 + "properties": { 45 + "alt": { 46 + "type": "string", 47 + "description": "Alt text description of the image, for accessibility." 48 + }, 49 + "image": { 50 + "type": "blob", 51 + "accept": [ 52 + "image/*" 53 + ], 54 + "maxSize": 1000000 55 + }, 56 + "aspectRatio": { 57 + "ref": "app.bsky.embed.defs#aspectRatio", 58 + "type": "ref" 59 + } 60 + } 61 + }, 62 + "viewImage": { 63 + "type": "object", 64 + "required": [ 65 + "thumb", 66 + "fullsize", 67 + "alt" 68 + ], 69 + "properties": { 70 + "alt": { 71 + "type": "string", 72 + "description": "Alt text description of the image, for accessibility." 73 + }, 74 + "thumb": { 75 + "type": "string", 76 + "format": "uri", 77 + "description": "Fully-qualified URL where a thumbnail of the image can be fetched. For example, CDN location provided by the App View." 78 + }, 79 + "fullsize": { 80 + "type": "string", 81 + "format": "uri", 82 + "description": "Fully-qualified URL where a large version of the image can be fetched. May or may not be the exact original blob. For example, CDN location provided by the App View." 83 + }, 84 + "aspectRatio": { 85 + "ref": "app.bsky.embed.defs#aspectRatio", 86 + "type": "ref" 87 + } 88 + } 89 + } 90 + } 91 + }
+160
examples/02-following-feed/lexicons/app/bsky/embed/record.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "app.bsky.embed.record", 4 + "description": "A representation of a record embedded in a Bluesky record (eg, a post). For example, a quote-post, or sharing a feed generator record.", 5 + "defs": { 6 + "main": { 7 + "type": "object", 8 + "required": [ 9 + "record" 10 + ], 11 + "properties": { 12 + "record": { 13 + "ref": "com.atproto.repo.strongRef", 14 + "type": "ref" 15 + } 16 + } 17 + }, 18 + "view": { 19 + "type": "object", 20 + "required": [ 21 + "record" 22 + ], 23 + "properties": { 24 + "record": { 25 + "refs": [ 26 + "#viewRecord", 27 + "#viewNotFound", 28 + "#viewBlocked", 29 + "#viewDetached", 30 + "app.bsky.feed.defs#generatorView", 31 + "app.bsky.graph.defs#listView", 32 + "app.bsky.labeler.defs#labelerView", 33 + "app.bsky.graph.defs#starterPackViewBasic" 34 + ], 35 + "type": "union" 36 + } 37 + } 38 + }, 39 + "viewRecord": { 40 + "type": "object", 41 + "required": [ 42 + "uri", 43 + "cid", 44 + "author", 45 + "value", 46 + "indexedAt" 47 + ], 48 + "properties": { 49 + "cid": { 50 + "type": "string", 51 + "format": "cid" 52 + }, 53 + "uri": { 54 + "type": "string", 55 + "format": "at-uri" 56 + }, 57 + "value": { 58 + "type": "unknown", 59 + "description": "The record data itself." 60 + }, 61 + "author": { 62 + "ref": "app.bsky.actor.defs#profileViewBasic", 63 + "type": "ref" 64 + }, 65 + "embeds": { 66 + "type": "array", 67 + "items": { 68 + "refs": [ 69 + "app.bsky.embed.images#view", 70 + "app.bsky.embed.video#view", 71 + "app.bsky.embed.external#view", 72 + "app.bsky.embed.record#view", 73 + "app.bsky.embed.recordWithMedia#view" 74 + ], 75 + "type": "union" 76 + } 77 + }, 78 + "labels": { 79 + "type": "array", 80 + "items": { 81 + "ref": "com.atproto.label.defs#label", 82 + "type": "ref" 83 + } 84 + }, 85 + "indexedAt": { 86 + "type": "string", 87 + "format": "datetime" 88 + }, 89 + "likeCount": { 90 + "type": "integer" 91 + }, 92 + "quoteCount": { 93 + "type": "integer" 94 + }, 95 + "replyCount": { 96 + "type": "integer" 97 + }, 98 + "repostCount": { 99 + "type": "integer" 100 + } 101 + } 102 + }, 103 + "viewBlocked": { 104 + "type": "object", 105 + "required": [ 106 + "uri", 107 + "blocked", 108 + "author" 109 + ], 110 + "properties": { 111 + "uri": { 112 + "type": "string", 113 + "format": "at-uri" 114 + }, 115 + "author": { 116 + "ref": "app.bsky.feed.defs#blockedAuthor", 117 + "type": "ref" 118 + }, 119 + "blocked": { 120 + "type": "boolean", 121 + "const": true 122 + } 123 + } 124 + }, 125 + "viewDetached": { 126 + "type": "object", 127 + "required": [ 128 + "uri", 129 + "detached" 130 + ], 131 + "properties": { 132 + "uri": { 133 + "type": "string", 134 + "format": "at-uri" 135 + }, 136 + "detached": { 137 + "type": "boolean", 138 + "const": true 139 + } 140 + } 141 + }, 142 + "viewNotFound": { 143 + "type": "object", 144 + "required": [ 145 + "uri", 146 + "notFound" 147 + ], 148 + "properties": { 149 + "uri": { 150 + "type": "string", 151 + "format": "at-uri" 152 + }, 153 + "notFound": { 154 + "type": "boolean", 155 + "const": true 156 + } 157 + } 158 + } 159 + } 160 + }
+49
examples/02-following-feed/lexicons/app/bsky/embed/recordWithMedia.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "app.bsky.embed.recordWithMedia", 4 + "description": "A representation of a record embedded in a Bluesky record (eg, a post), alongside other compatible embeds. For example, a quote post and image, or a quote post and external URL card.", 5 + "defs": { 6 + "main": { 7 + "type": "object", 8 + "required": [ 9 + "record", 10 + "media" 11 + ], 12 + "properties": { 13 + "media": { 14 + "refs": [ 15 + "app.bsky.embed.images", 16 + "app.bsky.embed.video", 17 + "app.bsky.embed.external" 18 + ], 19 + "type": "union" 20 + }, 21 + "record": { 22 + "ref": "app.bsky.embed.record", 23 + "type": "ref" 24 + } 25 + } 26 + }, 27 + "view": { 28 + "type": "object", 29 + "required": [ 30 + "record", 31 + "media" 32 + ], 33 + "properties": { 34 + "media": { 35 + "refs": [ 36 + "app.bsky.embed.images#view", 37 + "app.bsky.embed.video#view", 38 + "app.bsky.embed.external#view" 39 + ], 40 + "type": "union" 41 + }, 42 + "record": { 43 + "ref": "app.bsky.embed.record#view", 44 + "type": "ref" 45 + } 46 + } 47 + } 48 + } 49 + }
+91
examples/02-following-feed/lexicons/app/bsky/embed/video.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "app.bsky.embed.video", 4 + "description": "A video embedded in a Bluesky record (eg, a post).", 5 + "defs": { 6 + "main": { 7 + "type": "object", 8 + "required": [ 9 + "video" 10 + ], 11 + "properties": { 12 + "alt": { 13 + "type": "string", 14 + "maxLength": 10000, 15 + "description": "Alt text description of the video, for accessibility.", 16 + "maxGraphemes": 1000 17 + }, 18 + "video": { 19 + "type": "blob", 20 + "accept": [ 21 + "video/mp4" 22 + ], 23 + "maxSize": 100000000, 24 + "description": "The mp4 video file. May be up to 100mb, formerly limited to 50mb." 25 + }, 26 + "captions": { 27 + "type": "array", 28 + "items": { 29 + "ref": "#caption", 30 + "type": "ref" 31 + }, 32 + "maxLength": 20 33 + }, 34 + "aspectRatio": { 35 + "ref": "app.bsky.embed.defs#aspectRatio", 36 + "type": "ref" 37 + } 38 + } 39 + }, 40 + "view": { 41 + "type": "object", 42 + "required": [ 43 + "cid", 44 + "playlist" 45 + ], 46 + "properties": { 47 + "alt": { 48 + "type": "string", 49 + "maxLength": 10000, 50 + "maxGraphemes": 1000 51 + }, 52 + "cid": { 53 + "type": "string", 54 + "format": "cid" 55 + }, 56 + "playlist": { 57 + "type": "string", 58 + "format": "uri" 59 + }, 60 + "thumbnail": { 61 + "type": "string", 62 + "format": "uri" 63 + }, 64 + "aspectRatio": { 65 + "ref": "app.bsky.embed.defs#aspectRatio", 66 + "type": "ref" 67 + } 68 + } 69 + }, 70 + "caption": { 71 + "type": "object", 72 + "required": [ 73 + "lang", 74 + "file" 75 + ], 76 + "properties": { 77 + "file": { 78 + "type": "blob", 79 + "accept": [ 80 + "text/vtt" 81 + ], 82 + "maxSize": 20000 83 + }, 84 + "lang": { 85 + "type": "string", 86 + "format": "language" 87 + } 88 + } 89 + } 90 + } 91 + }
+543
examples/02-following-feed/lexicons/app/bsky/feed/defs.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "app.bsky.feed.defs", 4 + "defs": { 5 + "postView": { 6 + "type": "object", 7 + "required": [ 8 + "uri", 9 + "cid", 10 + "author", 11 + "record", 12 + "indexedAt" 13 + ], 14 + "properties": { 15 + "cid": { 16 + "type": "string", 17 + "format": "cid" 18 + }, 19 + "uri": { 20 + "type": "string", 21 + "format": "at-uri" 22 + }, 23 + "debug": { 24 + "type": "unknown", 25 + "description": "Debug information for internal development" 26 + }, 27 + "embed": { 28 + "refs": [ 29 + "app.bsky.embed.images#view", 30 + "app.bsky.embed.video#view", 31 + "app.bsky.embed.external#view", 32 + "app.bsky.embed.record#view", 33 + "app.bsky.embed.recordWithMedia#view" 34 + ], 35 + "type": "union" 36 + }, 37 + "author": { 38 + "ref": "app.bsky.actor.defs#profileViewBasic", 39 + "type": "ref" 40 + }, 41 + "labels": { 42 + "type": "array", 43 + "items": { 44 + "ref": "com.atproto.label.defs#label", 45 + "type": "ref" 46 + } 47 + }, 48 + "record": { 49 + "type": "unknown" 50 + }, 51 + "viewer": { 52 + "ref": "#viewerState", 53 + "type": "ref" 54 + }, 55 + "indexedAt": { 56 + "type": "string", 57 + "format": "datetime" 58 + }, 59 + "likeCount": { 60 + "type": "integer" 61 + }, 62 + "quoteCount": { 63 + "type": "integer" 64 + }, 65 + "replyCount": { 66 + "type": "integer" 67 + }, 68 + "threadgate": { 69 + "ref": "#threadgateView", 70 + "type": "ref" 71 + }, 72 + "repostCount": { 73 + "type": "integer" 74 + }, 75 + "bookmarkCount": { 76 + "type": "integer" 77 + } 78 + } 79 + }, 80 + "replyRef": { 81 + "type": "object", 82 + "required": [ 83 + "root", 84 + "parent" 85 + ], 86 + "properties": { 87 + "root": { 88 + "refs": [ 89 + "#postView", 90 + "#notFoundPost", 91 + "#blockedPost" 92 + ], 93 + "type": "union" 94 + }, 95 + "parent": { 96 + "refs": [ 97 + "#postView", 98 + "#notFoundPost", 99 + "#blockedPost" 100 + ], 101 + "type": "union" 102 + }, 103 + "grandparentAuthor": { 104 + "ref": "app.bsky.actor.defs#profileViewBasic", 105 + "type": "ref", 106 + "description": "When parent is a reply to another post, this is the author of that post." 107 + } 108 + } 109 + }, 110 + "reasonPin": { 111 + "type": "object", 112 + "properties": {} 113 + }, 114 + "blockedPost": { 115 + "type": "object", 116 + "required": [ 117 + "uri", 118 + "blocked", 119 + "author" 120 + ], 121 + "properties": { 122 + "uri": { 123 + "type": "string", 124 + "format": "at-uri" 125 + }, 126 + "author": { 127 + "ref": "#blockedAuthor", 128 + "type": "ref" 129 + }, 130 + "blocked": { 131 + "type": "boolean", 132 + "const": true 133 + } 134 + } 135 + }, 136 + "interaction": { 137 + "type": "object", 138 + "properties": { 139 + "item": { 140 + "type": "string", 141 + "format": "at-uri" 142 + }, 143 + "event": { 144 + "type": "string", 145 + "knownValues": [ 146 + "app.bsky.feed.defs#requestLess", 147 + "app.bsky.feed.defs#requestMore", 148 + "app.bsky.feed.defs#clickthroughItem", 149 + "app.bsky.feed.defs#clickthroughAuthor", 150 + "app.bsky.feed.defs#clickthroughReposter", 151 + "app.bsky.feed.defs#clickthroughEmbed", 152 + "app.bsky.feed.defs#interactionSeen", 153 + "app.bsky.feed.defs#interactionLike", 154 + "app.bsky.feed.defs#interactionRepost", 155 + "app.bsky.feed.defs#interactionReply", 156 + "app.bsky.feed.defs#interactionQuote", 157 + "app.bsky.feed.defs#interactionShare" 158 + ] 159 + }, 160 + "reqId": { 161 + "type": "string", 162 + "maxLength": 100, 163 + "description": "Unique identifier per request that may be passed back alongside interactions." 164 + }, 165 + "feedContext": { 166 + "type": "string", 167 + "maxLength": 2000, 168 + "description": "Context on a feed item that was originally supplied by the feed generator on getFeedSkeleton." 169 + } 170 + } 171 + }, 172 + "requestLess": { 173 + "type": "token", 174 + "description": "Request that less content like the given feed item be shown in the feed" 175 + }, 176 + "requestMore": { 177 + "type": "token", 178 + "description": "Request that more content like the given feed item be shown in the feed" 179 + }, 180 + "viewerState": { 181 + "type": "object", 182 + "properties": { 183 + "like": { 184 + "type": "string", 185 + "format": "at-uri" 186 + }, 187 + "pinned": { 188 + "type": "boolean" 189 + }, 190 + "repost": { 191 + "type": "string", 192 + "format": "at-uri" 193 + }, 194 + "bookmarked": { 195 + "type": "boolean" 196 + }, 197 + "threadMuted": { 198 + "type": "boolean" 199 + }, 200 + "replyDisabled": { 201 + "type": "boolean" 202 + }, 203 + "embeddingDisabled": { 204 + "type": "boolean" 205 + } 206 + }, 207 + "description": "Metadata about the requesting account's relationship with the subject content. Only has meaningful content for authed requests." 208 + }, 209 + "feedViewPost": { 210 + "type": "object", 211 + "required": [ 212 + "post" 213 + ], 214 + "properties": { 215 + "post": { 216 + "ref": "#postView", 217 + "type": "ref" 218 + }, 219 + "reply": { 220 + "ref": "#replyRef", 221 + "type": "ref" 222 + }, 223 + "reqId": { 224 + "type": "string", 225 + "maxLength": 100, 226 + "description": "Unique identifier per request that may be passed back alongside interactions." 227 + }, 228 + "reason": { 229 + "refs": [ 230 + "#reasonRepost", 231 + "#reasonPin" 232 + ], 233 + "type": "union" 234 + }, 235 + "feedContext": { 236 + "type": "string", 237 + "maxLength": 2000, 238 + "description": "Context provided by feed generator that may be passed back alongside interactions." 239 + } 240 + } 241 + }, 242 + "notFoundPost": { 243 + "type": "object", 244 + "required": [ 245 + "uri", 246 + "notFound" 247 + ], 248 + "properties": { 249 + "uri": { 250 + "type": "string", 251 + "format": "at-uri" 252 + }, 253 + "notFound": { 254 + "type": "boolean", 255 + "const": true 256 + } 257 + } 258 + }, 259 + "reasonRepost": { 260 + "type": "object", 261 + "required": [ 262 + "by", 263 + "indexedAt" 264 + ], 265 + "properties": { 266 + "by": { 267 + "ref": "app.bsky.actor.defs#profileViewBasic", 268 + "type": "ref" 269 + }, 270 + "cid": { 271 + "type": "string", 272 + "format": "cid" 273 + }, 274 + "uri": { 275 + "type": "string", 276 + "format": "at-uri" 277 + }, 278 + "indexedAt": { 279 + "type": "string", 280 + "format": "datetime" 281 + } 282 + } 283 + }, 284 + "blockedAuthor": { 285 + "type": "object", 286 + "required": [ 287 + "did" 288 + ], 289 + "properties": { 290 + "did": { 291 + "type": "string", 292 + "format": "did" 293 + }, 294 + "viewer": { 295 + "ref": "app.bsky.actor.defs#viewerState", 296 + "type": "ref" 297 + } 298 + } 299 + }, 300 + "generatorView": { 301 + "type": "object", 302 + "required": [ 303 + "uri", 304 + "cid", 305 + "did", 306 + "creator", 307 + "displayName", 308 + "indexedAt" 309 + ], 310 + "properties": { 311 + "cid": { 312 + "type": "string", 313 + "format": "cid" 314 + }, 315 + "did": { 316 + "type": "string", 317 + "format": "did" 318 + }, 319 + "uri": { 320 + "type": "string", 321 + "format": "at-uri" 322 + }, 323 + "avatar": { 324 + "type": "string", 325 + "format": "uri" 326 + }, 327 + "labels": { 328 + "type": "array", 329 + "items": { 330 + "ref": "com.atproto.label.defs#label", 331 + "type": "ref" 332 + } 333 + }, 334 + "viewer": { 335 + "ref": "#generatorViewerState", 336 + "type": "ref" 337 + }, 338 + "creator": { 339 + "ref": "app.bsky.actor.defs#profileView", 340 + "type": "ref" 341 + }, 342 + "indexedAt": { 343 + "type": "string", 344 + "format": "datetime" 345 + }, 346 + "likeCount": { 347 + "type": "integer", 348 + "minimum": 0 349 + }, 350 + "contentMode": { 351 + "type": "string", 352 + "knownValues": [ 353 + "app.bsky.feed.defs#contentModeUnspecified", 354 + "app.bsky.feed.defs#contentModeVideo" 355 + ] 356 + }, 357 + "description": { 358 + "type": "string", 359 + "maxLength": 3000, 360 + "maxGraphemes": 300 361 + }, 362 + "displayName": { 363 + "type": "string" 364 + }, 365 + "descriptionFacets": { 366 + "type": "array", 367 + "items": { 368 + "ref": "app.bsky.richtext.facet", 369 + "type": "ref" 370 + } 371 + }, 372 + "acceptsInteractions": { 373 + "type": "boolean" 374 + } 375 + } 376 + }, 377 + "threadContext": { 378 + "type": "object", 379 + "properties": { 380 + "rootAuthorLike": { 381 + "type": "string", 382 + "format": "at-uri" 383 + } 384 + }, 385 + "description": "Metadata about this post within the context of the thread it is in." 386 + }, 387 + "threadViewPost": { 388 + "type": "object", 389 + "required": [ 390 + "post" 391 + ], 392 + "properties": { 393 + "post": { 394 + "ref": "#postView", 395 + "type": "ref" 396 + }, 397 + "parent": { 398 + "refs": [ 399 + "#threadViewPost", 400 + "#notFoundPost", 401 + "#blockedPost" 402 + ], 403 + "type": "union" 404 + }, 405 + "replies": { 406 + "type": "array", 407 + "items": { 408 + "refs": [ 409 + "#threadViewPost", 410 + "#notFoundPost", 411 + "#blockedPost" 412 + ], 413 + "type": "union" 414 + } 415 + }, 416 + "threadContext": { 417 + "ref": "#threadContext", 418 + "type": "ref" 419 + } 420 + } 421 + }, 422 + "threadgateView": { 423 + "type": "object", 424 + "properties": { 425 + "cid": { 426 + "type": "string", 427 + "format": "cid" 428 + }, 429 + "uri": { 430 + "type": "string", 431 + "format": "at-uri" 432 + }, 433 + "lists": { 434 + "type": "array", 435 + "items": { 436 + "ref": "app.bsky.graph.defs#listViewBasic", 437 + "type": "ref" 438 + } 439 + }, 440 + "record": { 441 + "type": "unknown" 442 + } 443 + } 444 + }, 445 + "interactionLike": { 446 + "type": "token", 447 + "description": "User liked the feed item" 448 + }, 449 + "interactionSeen": { 450 + "type": "token", 451 + "description": "Feed item was seen by user" 452 + }, 453 + "clickthroughItem": { 454 + "type": "token", 455 + "description": "User clicked through to the feed item" 456 + }, 457 + "contentModeVideo": { 458 + "type": "token", 459 + "description": "Declares the feed generator returns posts containing app.bsky.embed.video embeds." 460 + }, 461 + "interactionQuote": { 462 + "type": "token", 463 + "description": "User quoted the feed item" 464 + }, 465 + "interactionReply": { 466 + "type": "token", 467 + "description": "User replied to the feed item" 468 + }, 469 + "interactionShare": { 470 + "type": "token", 471 + "description": "User shared the feed item" 472 + }, 473 + "skeletonFeedPost": { 474 + "type": "object", 475 + "required": [ 476 + "post" 477 + ], 478 + "properties": { 479 + "post": { 480 + "type": "string", 481 + "format": "at-uri" 482 + }, 483 + "reason": { 484 + "refs": [ 485 + "#skeletonReasonRepost", 486 + "#skeletonReasonPin" 487 + ], 488 + "type": "union" 489 + }, 490 + "feedContext": { 491 + "type": "string", 492 + "maxLength": 2000, 493 + "description": "Context that will be passed through to client and may be passed to feed generator back alongside interactions." 494 + } 495 + } 496 + }, 497 + "clickthroughEmbed": { 498 + "type": "token", 499 + "description": "User clicked through to the embedded content of the feed item" 500 + }, 501 + "interactionRepost": { 502 + "type": "token", 503 + "description": "User reposted the feed item" 504 + }, 505 + "skeletonReasonPin": { 506 + "type": "object", 507 + "properties": {} 508 + }, 509 + "clickthroughAuthor": { 510 + "type": "token", 511 + "description": "User clicked through to the author of the feed item" 512 + }, 513 + "clickthroughReposter": { 514 + "type": "token", 515 + "description": "User clicked through to the reposter of the feed item" 516 + }, 517 + "generatorViewerState": { 518 + "type": "object", 519 + "properties": { 520 + "like": { 521 + "type": "string", 522 + "format": "at-uri" 523 + } 524 + } 525 + }, 526 + "skeletonReasonRepost": { 527 + "type": "object", 528 + "required": [ 529 + "repost" 530 + ], 531 + "properties": { 532 + "repost": { 533 + "type": "string", 534 + "format": "at-uri" 535 + } 536 + } 537 + }, 538 + "contentModeUnspecified": { 539 + "type": "token", 540 + "description": "Declares the feed generator returns any types of posts." 541 + } 542 + } 543 + }
+144
examples/02-following-feed/lexicons/app/bsky/feed/post.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "app.bsky.feed.post", 4 + "defs": { 5 + "main": { 6 + "key": "tid", 7 + "type": "record", 8 + "record": { 9 + "type": "object", 10 + "required": [ 11 + "text", 12 + "createdAt" 13 + ], 14 + "properties": { 15 + "tags": { 16 + "type": "array", 17 + "items": { 18 + "type": "string", 19 + "maxLength": 640, 20 + "maxGraphemes": 64 21 + }, 22 + "maxLength": 8, 23 + "description": "Additional hashtags, in addition to any included in post text and facets." 24 + }, 25 + "text": { 26 + "type": "string", 27 + "maxLength": 3000, 28 + "description": "The primary post content. May be an empty string, if there are embeds.", 29 + "maxGraphemes": 300 30 + }, 31 + "embed": { 32 + "refs": [ 33 + "app.bsky.embed.images", 34 + "app.bsky.embed.video", 35 + "app.bsky.embed.external", 36 + "app.bsky.embed.record", 37 + "app.bsky.embed.recordWithMedia" 38 + ], 39 + "type": "union" 40 + }, 41 + "langs": { 42 + "type": "array", 43 + "items": { 44 + "type": "string", 45 + "format": "language" 46 + }, 47 + "maxLength": 3, 48 + "description": "Indicates human language of post primary text content." 49 + }, 50 + "reply": { 51 + "ref": "#replyRef", 52 + "type": "ref" 53 + }, 54 + "facets": { 55 + "type": "array", 56 + "items": { 57 + "ref": "app.bsky.richtext.facet", 58 + "type": "ref" 59 + }, 60 + "description": "Annotations of text (mentions, URLs, hashtags, etc)" 61 + }, 62 + "labels": { 63 + "refs": [ 64 + "com.atproto.label.defs#selfLabels" 65 + ], 66 + "type": "union", 67 + "description": "Self-label values for this post. Effectively content warnings." 68 + }, 69 + "entities": { 70 + "type": "array", 71 + "items": { 72 + "ref": "#entity", 73 + "type": "ref" 74 + }, 75 + "description": "DEPRECATED: replaced by app.bsky.richtext.facet." 76 + }, 77 + "createdAt": { 78 + "type": "string", 79 + "format": "datetime", 80 + "description": "Client-declared timestamp when this post was originally created." 81 + } 82 + } 83 + }, 84 + "description": "Record containing a Bluesky post." 85 + }, 86 + "entity": { 87 + "type": "object", 88 + "required": [ 89 + "index", 90 + "type", 91 + "value" 92 + ], 93 + "properties": { 94 + "type": { 95 + "type": "string", 96 + "description": "Expected values are 'mention' and 'link'." 97 + }, 98 + "index": { 99 + "ref": "#textSlice", 100 + "type": "ref" 101 + }, 102 + "value": { 103 + "type": "string" 104 + } 105 + }, 106 + "description": "Deprecated: use facets instead." 107 + }, 108 + "replyRef": { 109 + "type": "object", 110 + "required": [ 111 + "root", 112 + "parent" 113 + ], 114 + "properties": { 115 + "root": { 116 + "ref": "com.atproto.repo.strongRef", 117 + "type": "ref" 118 + }, 119 + "parent": { 120 + "ref": "com.atproto.repo.strongRef", 121 + "type": "ref" 122 + } 123 + } 124 + }, 125 + "textSlice": { 126 + "type": "object", 127 + "required": [ 128 + "start", 129 + "end" 130 + ], 131 + "properties": { 132 + "end": { 133 + "type": "integer", 134 + "minimum": 0 135 + }, 136 + "start": { 137 + "type": "integer", 138 + "minimum": 0 139 + } 140 + }, 141 + "description": "Deprecated. Use app.bsky.richtext instead -- A text segment. Start is inclusive, end is exclusive. Indices are for utf16-encoded strings." 142 + } 143 + } 144 + }
+54
examples/02-following-feed/lexicons/app/bsky/feed/postgate.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "app.bsky.feed.postgate", 4 + "defs": { 5 + "main": { 6 + "key": "tid", 7 + "type": "record", 8 + "record": { 9 + "type": "object", 10 + "required": [ 11 + "post", 12 + "createdAt" 13 + ], 14 + "properties": { 15 + "post": { 16 + "type": "string", 17 + "format": "at-uri", 18 + "description": "Reference (AT-URI) to the post record." 19 + }, 20 + "createdAt": { 21 + "type": "string", 22 + "format": "datetime" 23 + }, 24 + "embeddingRules": { 25 + "type": "array", 26 + "items": { 27 + "refs": [ 28 + "#disableRule" 29 + ], 30 + "type": "union" 31 + }, 32 + "maxLength": 5, 33 + "description": "List of rules defining who can embed this post. If value is an empty array or is undefined, no particular rules apply and anyone can embed." 34 + }, 35 + "detachedEmbeddingUris": { 36 + "type": "array", 37 + "items": { 38 + "type": "string", 39 + "format": "at-uri" 40 + }, 41 + "maxLength": 50, 42 + "description": "List of AT-URIs embedding this post that the author has detached from." 43 + } 44 + } 45 + }, 46 + "description": "Record defining interaction rules for a post. The record key (rkey) of the postgate record must match the record key of the post, and that record must be in the same repository." 47 + }, 48 + "disableRule": { 49 + "type": "object", 50 + "properties": {}, 51 + "description": "Disables embedding of this post." 52 + } 53 + } 54 + }
+80
examples/02-following-feed/lexicons/app/bsky/feed/threadgate.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "app.bsky.feed.threadgate", 4 + "defs": { 5 + "main": { 6 + "key": "tid", 7 + "type": "record", 8 + "record": { 9 + "type": "object", 10 + "required": [ 11 + "post", 12 + "createdAt" 13 + ], 14 + "properties": { 15 + "post": { 16 + "type": "string", 17 + "format": "at-uri", 18 + "description": "Reference (AT-URI) to the post record." 19 + }, 20 + "allow": { 21 + "type": "array", 22 + "items": { 23 + "refs": [ 24 + "#mentionRule", 25 + "#followerRule", 26 + "#followingRule", 27 + "#listRule" 28 + ], 29 + "type": "union" 30 + }, 31 + "maxLength": 5, 32 + "description": "List of rules defining who can reply to this post. If value is an empty array, no one can reply. If value is undefined, anyone can reply." 33 + }, 34 + "createdAt": { 35 + "type": "string", 36 + "format": "datetime" 37 + }, 38 + "hiddenReplies": { 39 + "type": "array", 40 + "items": { 41 + "type": "string", 42 + "format": "at-uri" 43 + }, 44 + "maxLength": 300, 45 + "description": "List of hidden reply URIs." 46 + } 47 + } 48 + }, 49 + "description": "Record defining interaction gating rules for a thread (aka, reply controls). The record key (rkey) of the threadgate record must match the record key of the thread's root post, and that record must be in the same repository." 50 + }, 51 + "listRule": { 52 + "type": "object", 53 + "required": [ 54 + "list" 55 + ], 56 + "properties": { 57 + "list": { 58 + "type": "string", 59 + "format": "at-uri" 60 + } 61 + }, 62 + "description": "Allow replies from actors on a list." 63 + }, 64 + "mentionRule": { 65 + "type": "object", 66 + "properties": {}, 67 + "description": "Allow replies from actors mentioned in your post." 68 + }, 69 + "followerRule": { 70 + "type": "object", 71 + "properties": {}, 72 + "description": "Allow replies from actors who follow you." 73 + }, 74 + "followingRule": { 75 + "type": "object", 76 + "properties": {}, 77 + "description": "Allow replies from actors you follow." 78 + } 79 + } 80 + }
+332
examples/02-following-feed/lexicons/app/bsky/graph/defs.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "app.bsky.graph.defs", 4 + "defs": { 5 + "modlist": { 6 + "type": "token", 7 + "description": "A list of actors to apply an aggregate moderation action (mute/block) on." 8 + }, 9 + "listView": { 10 + "type": "object", 11 + "required": [ 12 + "uri", 13 + "cid", 14 + "creator", 15 + "name", 16 + "purpose", 17 + "indexedAt" 18 + ], 19 + "properties": { 20 + "cid": { 21 + "type": "string", 22 + "format": "cid" 23 + }, 24 + "uri": { 25 + "type": "string", 26 + "format": "at-uri" 27 + }, 28 + "name": { 29 + "type": "string", 30 + "maxLength": 64, 31 + "minLength": 1 32 + }, 33 + "avatar": { 34 + "type": "string", 35 + "format": "uri" 36 + }, 37 + "labels": { 38 + "type": "array", 39 + "items": { 40 + "ref": "com.atproto.label.defs#label", 41 + "type": "ref" 42 + } 43 + }, 44 + "viewer": { 45 + "ref": "#listViewerState", 46 + "type": "ref" 47 + }, 48 + "creator": { 49 + "ref": "app.bsky.actor.defs#profileView", 50 + "type": "ref" 51 + }, 52 + "purpose": { 53 + "ref": "#listPurpose", 54 + "type": "ref" 55 + }, 56 + "indexedAt": { 57 + "type": "string", 58 + "format": "datetime" 59 + }, 60 + "description": { 61 + "type": "string", 62 + "maxLength": 3000, 63 + "maxGraphemes": 300 64 + }, 65 + "listItemCount": { 66 + "type": "integer", 67 + "minimum": 0 68 + }, 69 + "descriptionFacets": { 70 + "type": "array", 71 + "items": { 72 + "ref": "app.bsky.richtext.facet", 73 + "type": "ref" 74 + } 75 + } 76 + } 77 + }, 78 + "curatelist": { 79 + "type": "token", 80 + "description": "A list of actors used for curation purposes such as list feeds or interaction gating." 81 + }, 82 + "listPurpose": { 83 + "type": "string", 84 + "knownValues": [ 85 + "app.bsky.graph.defs#modlist", 86 + "app.bsky.graph.defs#curatelist", 87 + "app.bsky.graph.defs#referencelist" 88 + ] 89 + }, 90 + "listItemView": { 91 + "type": "object", 92 + "required": [ 93 + "uri", 94 + "subject" 95 + ], 96 + "properties": { 97 + "uri": { 98 + "type": "string", 99 + "format": "at-uri" 100 + }, 101 + "subject": { 102 + "ref": "app.bsky.actor.defs#profileView", 103 + "type": "ref" 104 + } 105 + } 106 + }, 107 + "relationship": { 108 + "type": "object", 109 + "required": [ 110 + "did" 111 + ], 112 + "properties": { 113 + "did": { 114 + "type": "string", 115 + "format": "did" 116 + }, 117 + "following": { 118 + "type": "string", 119 + "format": "at-uri", 120 + "description": "if the actor follows this DID, this is the AT-URI of the follow record" 121 + }, 122 + "followedBy": { 123 + "type": "string", 124 + "format": "at-uri", 125 + "description": "if the actor is followed by this DID, contains the AT-URI of the follow record" 126 + } 127 + }, 128 + "description": "lists the bi-directional graph relationships between one actor (not indicated in the object), and the target actors (the DID included in the object)" 129 + }, 130 + "listViewBasic": { 131 + "type": "object", 132 + "required": [ 133 + "uri", 134 + "cid", 135 + "name", 136 + "purpose" 137 + ], 138 + "properties": { 139 + "cid": { 140 + "type": "string", 141 + "format": "cid" 142 + }, 143 + "uri": { 144 + "type": "string", 145 + "format": "at-uri" 146 + }, 147 + "name": { 148 + "type": "string", 149 + "maxLength": 64, 150 + "minLength": 1 151 + }, 152 + "avatar": { 153 + "type": "string", 154 + "format": "uri" 155 + }, 156 + "labels": { 157 + "type": "array", 158 + "items": { 159 + "ref": "com.atproto.label.defs#label", 160 + "type": "ref" 161 + } 162 + }, 163 + "viewer": { 164 + "ref": "#listViewerState", 165 + "type": "ref" 166 + }, 167 + "purpose": { 168 + "ref": "#listPurpose", 169 + "type": "ref" 170 + }, 171 + "indexedAt": { 172 + "type": "string", 173 + "format": "datetime" 174 + }, 175 + "listItemCount": { 176 + "type": "integer", 177 + "minimum": 0 178 + } 179 + } 180 + }, 181 + "notFoundActor": { 182 + "type": "object", 183 + "required": [ 184 + "actor", 185 + "notFound" 186 + ], 187 + "properties": { 188 + "actor": { 189 + "type": "string", 190 + "format": "at-identifier" 191 + }, 192 + "notFound": { 193 + "type": "boolean", 194 + "const": true 195 + } 196 + }, 197 + "description": "indicates that a handle or DID could not be resolved" 198 + }, 199 + "referencelist": { 200 + "type": "token", 201 + "description": "A list of actors used for only for reference purposes such as within a starter pack." 202 + }, 203 + "listViewerState": { 204 + "type": "object", 205 + "properties": { 206 + "muted": { 207 + "type": "boolean" 208 + }, 209 + "blocked": { 210 + "type": "string", 211 + "format": "at-uri" 212 + } 213 + } 214 + }, 215 + "starterPackView": { 216 + "type": "object", 217 + "required": [ 218 + "uri", 219 + "cid", 220 + "record", 221 + "creator", 222 + "indexedAt" 223 + ], 224 + "properties": { 225 + "cid": { 226 + "type": "string", 227 + "format": "cid" 228 + }, 229 + "uri": { 230 + "type": "string", 231 + "format": "at-uri" 232 + }, 233 + "list": { 234 + "ref": "#listViewBasic", 235 + "type": "ref" 236 + }, 237 + "feeds": { 238 + "type": "array", 239 + "items": { 240 + "ref": "app.bsky.feed.defs#generatorView", 241 + "type": "ref" 242 + }, 243 + "maxLength": 3 244 + }, 245 + "labels": { 246 + "type": "array", 247 + "items": { 248 + "ref": "com.atproto.label.defs#label", 249 + "type": "ref" 250 + } 251 + }, 252 + "record": { 253 + "type": "unknown" 254 + }, 255 + "creator": { 256 + "ref": "app.bsky.actor.defs#profileViewBasic", 257 + "type": "ref" 258 + }, 259 + "indexedAt": { 260 + "type": "string", 261 + "format": "datetime" 262 + }, 263 + "joinedWeekCount": { 264 + "type": "integer", 265 + "minimum": 0 266 + }, 267 + "listItemsSample": { 268 + "type": "array", 269 + "items": { 270 + "ref": "#listItemView", 271 + "type": "ref" 272 + }, 273 + "maxLength": 12 274 + }, 275 + "joinedAllTimeCount": { 276 + "type": "integer", 277 + "minimum": 0 278 + } 279 + } 280 + }, 281 + "starterPackViewBasic": { 282 + "type": "object", 283 + "required": [ 284 + "uri", 285 + "cid", 286 + "record", 287 + "creator", 288 + "indexedAt" 289 + ], 290 + "properties": { 291 + "cid": { 292 + "type": "string", 293 + "format": "cid" 294 + }, 295 + "uri": { 296 + "type": "string", 297 + "format": "at-uri" 298 + }, 299 + "labels": { 300 + "type": "array", 301 + "items": { 302 + "ref": "com.atproto.label.defs#label", 303 + "type": "ref" 304 + } 305 + }, 306 + "record": { 307 + "type": "unknown" 308 + }, 309 + "creator": { 310 + "ref": "app.bsky.actor.defs#profileViewBasic", 311 + "type": "ref" 312 + }, 313 + "indexedAt": { 314 + "type": "string", 315 + "format": "datetime" 316 + }, 317 + "listItemCount": { 318 + "type": "integer", 319 + "minimum": 0 320 + }, 321 + "joinedWeekCount": { 322 + "type": "integer", 323 + "minimum": 0 324 + }, 325 + "joinedAllTimeCount": { 326 + "type": "integer", 327 + "minimum": 0 328 + } 329 + } 330 + } 331 + } 332 + }
+32
examples/02-following-feed/lexicons/app/bsky/graph/follow.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "app.bsky.graph.follow", 4 + "defs": { 5 + "main": { 6 + "key": "tid", 7 + "type": "record", 8 + "record": { 9 + "type": "object", 10 + "required": [ 11 + "subject", 12 + "createdAt" 13 + ], 14 + "properties": { 15 + "via": { 16 + "ref": "com.atproto.repo.strongRef", 17 + "type": "ref" 18 + }, 19 + "subject": { 20 + "type": "string", 21 + "format": "did" 22 + }, 23 + "createdAt": { 24 + "type": "string", 25 + "format": "datetime" 26 + } 27 + } 28 + }, 29 + "description": "Record declaring a social 'follow' relationship of another account. Duplicate follows will be ignored by the AppView." 30 + } 31 + } 32 + }
+152
examples/02-following-feed/lexicons/app/bsky/labeler/defs.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "app.bsky.labeler.defs", 4 + "defs": { 5 + "labelerView": { 6 + "type": "object", 7 + "required": [ 8 + "uri", 9 + "cid", 10 + "creator", 11 + "indexedAt" 12 + ], 13 + "properties": { 14 + "cid": { 15 + "type": "string", 16 + "format": "cid" 17 + }, 18 + "uri": { 19 + "type": "string", 20 + "format": "at-uri" 21 + }, 22 + "labels": { 23 + "type": "array", 24 + "items": { 25 + "ref": "com.atproto.label.defs#label", 26 + "type": "ref" 27 + } 28 + }, 29 + "viewer": { 30 + "ref": "#labelerViewerState", 31 + "type": "ref" 32 + }, 33 + "creator": { 34 + "ref": "app.bsky.actor.defs#profileView", 35 + "type": "ref" 36 + }, 37 + "indexedAt": { 38 + "type": "string", 39 + "format": "datetime" 40 + }, 41 + "likeCount": { 42 + "type": "integer", 43 + "minimum": 0 44 + } 45 + } 46 + }, 47 + "labelerPolicies": { 48 + "type": "object", 49 + "required": [ 50 + "labelValues" 51 + ], 52 + "properties": { 53 + "labelValues": { 54 + "type": "array", 55 + "items": { 56 + "ref": "com.atproto.label.defs#labelValue", 57 + "type": "ref" 58 + }, 59 + "description": "The label values which this labeler publishes. May include global or custom labels." 60 + }, 61 + "labelValueDefinitions": { 62 + "type": "array", 63 + "items": { 64 + "ref": "com.atproto.label.defs#labelValueDefinition", 65 + "type": "ref" 66 + }, 67 + "description": "Label values created by this labeler and scoped exclusively to it. Labels defined here will override global label definitions for this labeler." 68 + } 69 + } 70 + }, 71 + "labelerViewerState": { 72 + "type": "object", 73 + "properties": { 74 + "like": { 75 + "type": "string", 76 + "format": "at-uri" 77 + } 78 + } 79 + }, 80 + "labelerViewDetailed": { 81 + "type": "object", 82 + "required": [ 83 + "uri", 84 + "cid", 85 + "creator", 86 + "policies", 87 + "indexedAt" 88 + ], 89 + "properties": { 90 + "cid": { 91 + "type": "string", 92 + "format": "cid" 93 + }, 94 + "uri": { 95 + "type": "string", 96 + "format": "at-uri" 97 + }, 98 + "labels": { 99 + "type": "array", 100 + "items": { 101 + "ref": "com.atproto.label.defs#label", 102 + "type": "ref" 103 + } 104 + }, 105 + "viewer": { 106 + "ref": "#labelerViewerState", 107 + "type": "ref" 108 + }, 109 + "creator": { 110 + "ref": "app.bsky.actor.defs#profileView", 111 + "type": "ref" 112 + }, 113 + "policies": { 114 + "ref": "app.bsky.labeler.defs#labelerPolicies", 115 + "type": "ref" 116 + }, 117 + "indexedAt": { 118 + "type": "string", 119 + "format": "datetime" 120 + }, 121 + "likeCount": { 122 + "type": "integer", 123 + "minimum": 0 124 + }, 125 + "reasonTypes": { 126 + "type": "array", 127 + "items": { 128 + "ref": "com.atproto.moderation.defs#reasonType", 129 + "type": "ref" 130 + }, 131 + "description": "The set of report reason 'codes' which are in-scope for this service to review and action. These usually align to policy categories. If not defined (distinct from empty array), all reason types are allowed." 132 + }, 133 + "subjectTypes": { 134 + "type": "array", 135 + "items": { 136 + "ref": "com.atproto.moderation.defs#subjectType", 137 + "type": "ref" 138 + }, 139 + "description": "The set of subject types (account, record, etc) this service accepts reports on." 140 + }, 141 + "subjectCollections": { 142 + "type": "array", 143 + "items": { 144 + "type": "string", 145 + "format": "nsid" 146 + }, 147 + "description": "Set of record types (collection NSIDs) which can be reported to this service. If not defined (distinct from empty array), default is any record type." 148 + } 149 + } 150 + } 151 + } 152 + }
+172
examples/02-following-feed/lexicons/app/bsky/notification/defs.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "app.bsky.notification.defs", 4 + "defs": { 5 + "preference": { 6 + "type": "object", 7 + "required": [ 8 + "list", 9 + "push" 10 + ], 11 + "properties": { 12 + "list": { 13 + "type": "boolean" 14 + }, 15 + "push": { 16 + "type": "boolean" 17 + } 18 + } 19 + }, 20 + "preferences": { 21 + "type": "object", 22 + "required": [ 23 + "chat", 24 + "follow", 25 + "like", 26 + "likeViaRepost", 27 + "mention", 28 + "quote", 29 + "reply", 30 + "repost", 31 + "repostViaRepost", 32 + "starterpackJoined", 33 + "subscribedPost", 34 + "unverified", 35 + "verified" 36 + ], 37 + "properties": { 38 + "chat": { 39 + "ref": "#chatPreference", 40 + "type": "ref" 41 + }, 42 + "like": { 43 + "ref": "#filterablePreference", 44 + "type": "ref" 45 + }, 46 + "quote": { 47 + "ref": "#filterablePreference", 48 + "type": "ref" 49 + }, 50 + "reply": { 51 + "ref": "#filterablePreference", 52 + "type": "ref" 53 + }, 54 + "follow": { 55 + "ref": "#filterablePreference", 56 + "type": "ref" 57 + }, 58 + "repost": { 59 + "ref": "#filterablePreference", 60 + "type": "ref" 61 + }, 62 + "mention": { 63 + "ref": "#filterablePreference", 64 + "type": "ref" 65 + }, 66 + "verified": { 67 + "ref": "#preference", 68 + "type": "ref" 69 + }, 70 + "unverified": { 71 + "ref": "#preference", 72 + "type": "ref" 73 + }, 74 + "likeViaRepost": { 75 + "ref": "#filterablePreference", 76 + "type": "ref" 77 + }, 78 + "subscribedPost": { 79 + "ref": "#preference", 80 + "type": "ref" 81 + }, 82 + "repostViaRepost": { 83 + "ref": "#filterablePreference", 84 + "type": "ref" 85 + }, 86 + "starterpackJoined": { 87 + "ref": "#preference", 88 + "type": "ref" 89 + } 90 + } 91 + }, 92 + "recordDeleted": { 93 + "type": "object", 94 + "properties": {} 95 + }, 96 + "chatPreference": { 97 + "type": "object", 98 + "required": [ 99 + "include", 100 + "push" 101 + ], 102 + "properties": { 103 + "push": { 104 + "type": "boolean" 105 + }, 106 + "include": { 107 + "type": "string", 108 + "knownValues": [ 109 + "all", 110 + "accepted" 111 + ] 112 + } 113 + } 114 + }, 115 + "activitySubscription": { 116 + "type": "object", 117 + "required": [ 118 + "post", 119 + "reply" 120 + ], 121 + "properties": { 122 + "post": { 123 + "type": "boolean" 124 + }, 125 + "reply": { 126 + "type": "boolean" 127 + } 128 + } 129 + }, 130 + "filterablePreference": { 131 + "type": "object", 132 + "required": [ 133 + "include", 134 + "list", 135 + "push" 136 + ], 137 + "properties": { 138 + "list": { 139 + "type": "boolean" 140 + }, 141 + "push": { 142 + "type": "boolean" 143 + }, 144 + "include": { 145 + "type": "string", 146 + "knownValues": [ 147 + "all", 148 + "follows" 149 + ] 150 + } 151 + } 152 + }, 153 + "subjectActivitySubscription": { 154 + "type": "object", 155 + "required": [ 156 + "subject", 157 + "activitySubscription" 158 + ], 159 + "properties": { 160 + "subject": { 161 + "type": "string", 162 + "format": "did" 163 + }, 164 + "activitySubscription": { 165 + "ref": "#activitySubscription", 166 + "type": "ref" 167 + } 168 + }, 169 + "description": "Object used to store activity subscription data in stash." 170 + } 171 + } 172 + }
+89
examples/02-following-feed/lexicons/app/bsky/richtext/facet.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "app.bsky.richtext.facet", 4 + "defs": { 5 + "tag": { 6 + "type": "object", 7 + "required": [ 8 + "tag" 9 + ], 10 + "properties": { 11 + "tag": { 12 + "type": "string", 13 + "maxLength": 640, 14 + "maxGraphemes": 64 15 + } 16 + }, 17 + "description": "Facet feature for a hashtag. The text usually includes a '#' prefix, but the facet reference should not (except in the case of 'double hash tags')." 18 + }, 19 + "link": { 20 + "type": "object", 21 + "required": [ 22 + "uri" 23 + ], 24 + "properties": { 25 + "uri": { 26 + "type": "string", 27 + "format": "uri" 28 + } 29 + }, 30 + "description": "Facet feature for a URL. The text URL may have been simplified or truncated, but the facet reference should be a complete URL." 31 + }, 32 + "main": { 33 + "type": "object", 34 + "required": [ 35 + "index", 36 + "features" 37 + ], 38 + "properties": { 39 + "index": { 40 + "ref": "#byteSlice", 41 + "type": "ref" 42 + }, 43 + "features": { 44 + "type": "array", 45 + "items": { 46 + "refs": [ 47 + "#mention", 48 + "#link", 49 + "#tag" 50 + ], 51 + "type": "union" 52 + } 53 + } 54 + }, 55 + "description": "Annotation of a sub-string within rich text." 56 + }, 57 + "mention": { 58 + "type": "object", 59 + "required": [ 60 + "did" 61 + ], 62 + "properties": { 63 + "did": { 64 + "type": "string", 65 + "format": "did" 66 + } 67 + }, 68 + "description": "Facet feature for mention of another account. The text is usually a handle, including a '@' prefix, but the facet reference is a DID." 69 + }, 70 + "byteSlice": { 71 + "type": "object", 72 + "required": [ 73 + "byteStart", 74 + "byteEnd" 75 + ], 76 + "properties": { 77 + "byteEnd": { 78 + "type": "integer", 79 + "minimum": 0 80 + }, 81 + "byteStart": { 82 + "type": "integer", 83 + "minimum": 0 84 + } 85 + }, 86 + "description": "Specifies the sub-string range a facet feature applies to. Start index is inclusive, end index is exclusive. Indices are zero-indexed, counting bytes of the UTF-8 encoded text. NOTE: some languages, like Javascript, use UTF-16 or Unicode codepoints for string slice indexing; in these languages, convert to byte arrays before working with facets." 87 + } 88 + } 89 + }
+192
examples/02-following-feed/lexicons/com/atproto/label/defs.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "com.atproto.label.defs", 4 + "defs": { 5 + "label": { 6 + "type": "object", 7 + "required": [ 8 + "src", 9 + "uri", 10 + "val", 11 + "cts" 12 + ], 13 + "properties": { 14 + "cid": { 15 + "type": "string", 16 + "format": "cid", 17 + "description": "Optionally, CID specifying the specific version of 'uri' resource this label applies to." 18 + }, 19 + "cts": { 20 + "type": "string", 21 + "format": "datetime", 22 + "description": "Timestamp when this label was created." 23 + }, 24 + "exp": { 25 + "type": "string", 26 + "format": "datetime", 27 + "description": "Timestamp at which this label expires (no longer applies)." 28 + }, 29 + "neg": { 30 + "type": "boolean", 31 + "description": "If true, this is a negation label, overwriting a previous label." 32 + }, 33 + "sig": { 34 + "type": "bytes", 35 + "description": "Signature of dag-cbor encoded label." 36 + }, 37 + "src": { 38 + "type": "string", 39 + "format": "did", 40 + "description": "DID of the actor who created this label." 41 + }, 42 + "uri": { 43 + "type": "string", 44 + "format": "uri", 45 + "description": "AT URI of the record, repository (account), or other resource that this label applies to." 46 + }, 47 + "val": { 48 + "type": "string", 49 + "maxLength": 128, 50 + "description": "The short string name of the value or type of this label." 51 + }, 52 + "ver": { 53 + "type": "integer", 54 + "description": "The AT Protocol version of the label object." 55 + } 56 + }, 57 + "description": "Metadata tag on an atproto resource (eg, repo or record)." 58 + }, 59 + "selfLabel": { 60 + "type": "object", 61 + "required": [ 62 + "val" 63 + ], 64 + "properties": { 65 + "val": { 66 + "type": "string", 67 + "maxLength": 128, 68 + "description": "The short string name of the value or type of this label." 69 + } 70 + }, 71 + "description": "Metadata tag on an atproto record, published by the author within the record. Note that schemas should use #selfLabels, not #selfLabel." 72 + }, 73 + "labelValue": { 74 + "type": "string", 75 + "knownValues": [ 76 + "!hide", 77 + "!no-promote", 78 + "!warn", 79 + "!no-unauthenticated", 80 + "dmca-violation", 81 + "doxxing", 82 + "porn", 83 + "sexual", 84 + "nudity", 85 + "nsfl", 86 + "gore" 87 + ] 88 + }, 89 + "selfLabels": { 90 + "type": "object", 91 + "required": [ 92 + "values" 93 + ], 94 + "properties": { 95 + "values": { 96 + "type": "array", 97 + "items": { 98 + "ref": "#selfLabel", 99 + "type": "ref" 100 + }, 101 + "maxLength": 10 102 + } 103 + }, 104 + "description": "Metadata tags on an atproto record, published by the author within the record." 105 + }, 106 + "labelValueDefinition": { 107 + "type": "object", 108 + "required": [ 109 + "identifier", 110 + "severity", 111 + "blurs", 112 + "locales" 113 + ], 114 + "properties": { 115 + "blurs": { 116 + "type": "string", 117 + "description": "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.", 118 + "knownValues": [ 119 + "content", 120 + "media", 121 + "none" 122 + ] 123 + }, 124 + "locales": { 125 + "type": "array", 126 + "items": { 127 + "ref": "#labelValueDefinitionStrings", 128 + "type": "ref" 129 + } 130 + }, 131 + "severity": { 132 + "type": "string", 133 + "description": "How should a client visually convey this label? 'inform' means neutral and informational; 'alert' means negative and warning; 'none' means show nothing.", 134 + "knownValues": [ 135 + "inform", 136 + "alert", 137 + "none" 138 + ] 139 + }, 140 + "adultOnly": { 141 + "type": "boolean", 142 + "description": "Does the user need to have adult content enabled in order to configure this label?" 143 + }, 144 + "identifier": { 145 + "type": "string", 146 + "maxLength": 100, 147 + "description": "The value of the label being defined. Must only include lowercase ascii and the '-' character ([a-z-]+).", 148 + "maxGraphemes": 100 149 + }, 150 + "defaultSetting": { 151 + "type": "string", 152 + "default": "warn", 153 + "description": "The default setting for this label.", 154 + "knownValues": [ 155 + "ignore", 156 + "warn", 157 + "hide" 158 + ] 159 + } 160 + }, 161 + "description": "Declares a label value and its expected interpretations and behaviors." 162 + }, 163 + "labelValueDefinitionStrings": { 164 + "type": "object", 165 + "required": [ 166 + "lang", 167 + "name", 168 + "description" 169 + ], 170 + "properties": { 171 + "lang": { 172 + "type": "string", 173 + "format": "language", 174 + "description": "The code of the language these strings are written in." 175 + }, 176 + "name": { 177 + "type": "string", 178 + "maxLength": 640, 179 + "description": "A short human-readable name for the label.", 180 + "maxGraphemes": 64 181 + }, 182 + "description": { 183 + "type": "string", 184 + "maxLength": 100000, 185 + "description": "A longer description of what the label means and why it might be applied.", 186 + "maxGraphemes": 10000 187 + } 188 + }, 189 + "description": "Strings which describe the label in the UI, localized into a specific language." 190 + } 191 + } 192 + }
+95
examples/02-following-feed/lexicons/com/atproto/moderation/defs.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "com.atproto.moderation.defs", 4 + "defs": { 5 + "reasonRude": { 6 + "type": "token", 7 + "description": "Rude, harassing, explicit, or otherwise unwelcoming behavior. Prefer new lexicon definition `tools.ozone.report.defs#reasonHarassmentOther`." 8 + }, 9 + "reasonSpam": { 10 + "type": "token", 11 + "description": "Spam: frequent unwanted promotion, replies, mentions. Prefer new lexicon definition `tools.ozone.report.defs#reasonMisleadingSpam`." 12 + }, 13 + "reasonType": { 14 + "type": "string", 15 + "knownValues": [ 16 + "com.atproto.moderation.defs#reasonSpam", 17 + "com.atproto.moderation.defs#reasonViolation", 18 + "com.atproto.moderation.defs#reasonMisleading", 19 + "com.atproto.moderation.defs#reasonSexual", 20 + "com.atproto.moderation.defs#reasonRude", 21 + "com.atproto.moderation.defs#reasonOther", 22 + "com.atproto.moderation.defs#reasonAppeal", 23 + "tools.ozone.report.defs#reasonAppeal", 24 + "tools.ozone.report.defs#reasonOther", 25 + "tools.ozone.report.defs#reasonViolenceAnimal", 26 + "tools.ozone.report.defs#reasonViolenceThreats", 27 + "tools.ozone.report.defs#reasonViolenceGraphicContent", 28 + "tools.ozone.report.defs#reasonViolenceGlorification", 29 + "tools.ozone.report.defs#reasonViolenceExtremistContent", 30 + "tools.ozone.report.defs#reasonViolenceTrafficking", 31 + "tools.ozone.report.defs#reasonViolenceOther", 32 + "tools.ozone.report.defs#reasonSexualAbuseContent", 33 + "tools.ozone.report.defs#reasonSexualNCII", 34 + "tools.ozone.report.defs#reasonSexualDeepfake", 35 + "tools.ozone.report.defs#reasonSexualAnimal", 36 + "tools.ozone.report.defs#reasonSexualUnlabeled", 37 + "tools.ozone.report.defs#reasonSexualOther", 38 + "tools.ozone.report.defs#reasonChildSafetyCSAM", 39 + "tools.ozone.report.defs#reasonChildSafetyGroom", 40 + "tools.ozone.report.defs#reasonChildSafetyPrivacy", 41 + "tools.ozone.report.defs#reasonChildSafetyHarassment", 42 + "tools.ozone.report.defs#reasonChildSafetyOther", 43 + "tools.ozone.report.defs#reasonHarassmentTroll", 44 + "tools.ozone.report.defs#reasonHarassmentTargeted", 45 + "tools.ozone.report.defs#reasonHarassmentHateSpeech", 46 + "tools.ozone.report.defs#reasonHarassmentDoxxing", 47 + "tools.ozone.report.defs#reasonHarassmentOther", 48 + "tools.ozone.report.defs#reasonMisleadingBot", 49 + "tools.ozone.report.defs#reasonMisleadingImpersonation", 50 + "tools.ozone.report.defs#reasonMisleadingSpam", 51 + "tools.ozone.report.defs#reasonMisleadingScam", 52 + "tools.ozone.report.defs#reasonMisleadingElections", 53 + "tools.ozone.report.defs#reasonMisleadingOther", 54 + "tools.ozone.report.defs#reasonRuleSiteSecurity", 55 + "tools.ozone.report.defs#reasonRuleProhibitedSales", 56 + "tools.ozone.report.defs#reasonRuleBanEvasion", 57 + "tools.ozone.report.defs#reasonRuleOther", 58 + "tools.ozone.report.defs#reasonSelfHarmContent", 59 + "tools.ozone.report.defs#reasonSelfHarmED", 60 + "tools.ozone.report.defs#reasonSelfHarmStunts", 61 + "tools.ozone.report.defs#reasonSelfHarmSubstances", 62 + "tools.ozone.report.defs#reasonSelfHarmOther" 63 + ] 64 + }, 65 + "reasonOther": { 66 + "type": "token", 67 + "description": "Reports not falling under another report category. Prefer new lexicon definition `tools.ozone.report.defs#reasonOther`." 68 + }, 69 + "subjectType": { 70 + "type": "string", 71 + "description": "Tag describing a type of subject that might be reported.", 72 + "knownValues": [ 73 + "account", 74 + "record", 75 + "chat" 76 + ] 77 + }, 78 + "reasonAppeal": { 79 + "type": "token", 80 + "description": "Appeal a previously taken moderation action" 81 + }, 82 + "reasonSexual": { 83 + "type": "token", 84 + "description": "Unwanted or mislabeled sexual content. Prefer new lexicon definition `tools.ozone.report.defs#reasonSexualUnlabeled`." 85 + }, 86 + "reasonViolation": { 87 + "type": "token", 88 + "description": "Direct violation of server rules, laws, terms of service. Prefer new lexicon definition `tools.ozone.report.defs#reasonRuleOther`." 89 + }, 90 + "reasonMisleading": { 91 + "type": "token", 92 + "description": "Misleading identity, affiliation, or content. Prefer new lexicon definition `tools.ozone.report.defs#reasonMisleadingOther`." 93 + } 94 + } 95 + }
+24
examples/02-following-feed/lexicons/com/atproto/repo/strongRef.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "com.atproto.repo.strongRef", 4 + "description": "A URI with a content-hash fingerprint.", 5 + "defs": { 6 + "main": { 7 + "type": "object", 8 + "required": [ 9 + "uri", 10 + "cid" 11 + ], 12 + "properties": { 13 + "cid": { 14 + "type": "string", 15 + "format": "cid" 16 + }, 17 + "uri": { 18 + "type": "string", 19 + "format": "at-uri" 20 + } 21 + } 22 + } 23 + } 24 + }
+2 -2
lexicon_graphql/manifest.toml
··· 11 11 { name = "gleam_community_colour", version = "2.0.2", build_tools = ["gleam"], requirements = ["gleam_json", "gleam_stdlib"], otp_app = "gleam_community_colour", source = "hex", outer_checksum = "E34DD2C896AC3792151EDA939DA435FF3B69922F33415ED3C4406C932FBE9634" }, 12 12 { name = "gleam_json", version = "3.1.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_json", source = "hex", outer_checksum = "44FDAA8847BE8FC48CA7A1C089706BD54BADCC4C45B237A992EDDF9F2CDB2836" }, 13 13 { name = "gleam_regexp", version = "1.1.1", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_regexp", source = "hex", outer_checksum = "9C215C6CA84A5B35BB934A9B61A9A306EC743153BE2B0425A0D032E477B062A9" }, 14 - { name = "gleam_stdlib", version = "0.67.0", build_tools = ["gleam"], requirements = [], otp_app = "gleam_stdlib", source = "hex", outer_checksum = "6368313DB35963DC02F677A513BB0D95D58A34ED0A9436C8116820BF94BE3511" }, 14 + { name = "gleam_stdlib", version = "0.67.1", build_tools = ["gleam"], requirements = [], otp_app = "gleam_stdlib", source = "hex", outer_checksum = "6CE3E4189A8B8EC2F73AB61A2FBDE49F159D6C9C61C49E3B3082E439F260D3D0" }, 15 15 { name = "gleeunit", version = "1.9.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleeunit", source = "hex", outer_checksum = "DA9553CE58B67924B3C631F96FE3370C49EB6D6DC6B384EC4862CC4AAA718F3C" }, 16 16 { name = "glexer", version = "2.3.0", build_tools = ["gleam"], requirements = ["gleam_stdlib", "splitter"], otp_app = "glexer", source = "hex", outer_checksum = "40A1FB0919FA080AD6C5809B4C7DBA545841CAAC8168FACDFA0B0667C22475CC" }, 17 17 { name = "justin", version = "1.0.1", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "justin", source = "hex", outer_checksum = "7FA0C6DB78640C6DC5FBFD59BF3456009F3F8B485BF6825E97E1EB44E9A1E2CD" }, 18 18 { name = "rank", version = "1.0.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "rank", source = "hex", outer_checksum = "5660E361F0E49CBB714CC57CC4C89C63415D8986F05B2DA0C719D5642FAD91C9" }, 19 19 { name = "simplifile", version = "2.3.1", build_tools = ["gleam"], requirements = ["filepath", "gleam_stdlib"], otp_app = "simplifile", source = "hex", outer_checksum = "957E0E5B75927659F1D2A1B7B75D7B9BA96FAA8D0C53EA71C4AD9CD0C6B848F6" }, 20 20 { name = "splitter", version = "1.2.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "splitter", source = "hex", outer_checksum = "3DFD6B6C49E61EDAF6F7B27A42054A17CFF6CA2135FF553D0CB61C234D281DD0" }, 21 - { name = "swell", version = "2.1.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "swell", source = "hex", outer_checksum = "0C58555BC0A77BEBB47945B0AAB808A4888EAC1E6BCE9E1AC01C6AC73FFAE50F" }, 21 + { name = "swell", version = "2.1.1", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "swell", source = "hex", outer_checksum = "64CFC91A6851487D07E85B02D8405F5EA06EAA74C6742915F5A78531D6237F16" }, 22 22 { name = "term_size", version = "1.0.1", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "term_size", source = "hex", outer_checksum = "D00BD2BC8FB3EBB7E6AE076F3F1FF2AC9D5ED1805F004D0896C784D06C6645F1" }, 23 23 { name = "trie_again", version = "1.1.4", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "trie_again", source = "hex", outer_checksum = "E3BD66B4E126EF567EA8C4944EAB216413392ADF6C16C36047AF79EE5EF13466" }, 24 24 ]
+171 -11
lexicon_graphql/src/lexicon_graphql/internal/graphql/object_builder.gleam
··· 6 6 import gleam/dict.{type Dict} 7 7 import gleam/list 8 8 import gleam/option 9 + import gleam/order 9 10 import gleam/string 10 11 import lexicon_graphql/internal/graphql/type_mapper 11 12 import lexicon_graphql/internal/lexicon/nsid ··· 14 15 import swell/schema 15 16 import swell/value 16 17 18 + /// Sort refs so that #fragment refs come before main refs 19 + /// This ensures dependencies are built first 20 + /// e.g., "app.bsky.richtext.facet#mention" before "app.bsky.richtext.facet" 21 + fn sort_refs_dependencies_first(refs: List(String)) -> List(String) { 22 + list.sort(refs, fn(a, b) { 23 + let a_has_hash = string.contains(a, "#") 24 + let b_has_hash = string.contains(b, "#") 25 + case a_has_hash, b_has_hash { 26 + // Both have # or both don't - sort alphabetically for determinism 27 + True, True -> string.compare(a, b) 28 + False, False -> string.compare(a, b) 29 + // # refs come first 30 + True, False -> order.Lt 31 + False, True -> order.Gt 32 + } 33 + }) 34 + } 35 + 17 36 /// Build a GraphQL object type from an ObjectDef 18 37 /// object_types_dict is used to resolve refs to other object types 19 38 pub fn build_object_type( 20 39 obj_def: types.ObjectDef, 21 40 type_name: String, 41 + lexicon_id: String, 22 42 object_types_dict: Dict(String, schema.Type), 23 43 ) -> schema.Type { 24 - let fields = build_object_fields(obj_def.properties, object_types_dict) 44 + let lexicon_fields = 45 + build_object_fields( 46 + obj_def.properties, 47 + lexicon_id, 48 + object_types_dict, 49 + type_name, 50 + ) 51 + 52 + // GraphQL requires at least one field - add placeholder for empty objects 53 + let fields = case lexicon_fields { 54 + [] -> [ 55 + schema.field( 56 + "_", 57 + schema.boolean_type(), 58 + "Placeholder field for empty object type", 59 + fn(_ctx) { Ok(value.Boolean(True)) }, 60 + ), 61 + ] 62 + _ -> lexicon_fields 63 + } 25 64 26 65 schema.object_type(type_name, "Object type from lexicon definition", fields) 27 66 } ··· 29 68 /// Build GraphQL fields from object properties 30 69 fn build_object_fields( 31 70 properties: List(#(String, types.Property)), 71 + lexicon_id: String, 32 72 object_types_dict: Dict(String, schema.Type), 73 + parent_type_name: String, 33 74 ) -> List(schema.Field) { 34 75 list.map(properties, fn(prop) { 35 - let #(name, types.Property(type_, required, format, ref, _items)) = prop 76 + let #(name, types.Property(type_, required, format, ref, _refs, items)) = 77 + prop 36 78 37 - // Map the type, using the object_types_dict to resolve refs 38 - let graphql_type = 39 - type_mapper.map_type_with_registry(type_, format, ref, object_types_dict) 79 + // Map the type, handling arrays specially to resolve item refs 80 + let graphql_type = case type_ { 81 + "array" -> { 82 + let expanded_items = case items { 83 + option.Some(arr_items) -> 84 + option.Some(expand_array_items(arr_items, lexicon_id)) 85 + option.None -> option.None 86 + } 87 + type_mapper.map_array_type( 88 + expanded_items, 89 + object_types_dict, 90 + parent_type_name, 91 + name, 92 + ) 93 + } 94 + _ -> 95 + type_mapper.map_type_with_registry( 96 + type_, 97 + format, 98 + ref, 99 + object_types_dict, 100 + ) 101 + } 40 102 41 103 // Make required fields non-null 42 104 let field_type = case required { ··· 45 107 } 46 108 47 109 // Create field with a resolver that extracts the value from context 110 + // For blob fields, enrich with did; for nested objects, propagate did 48 111 schema.field(name, field_type, "Field from object definition", fn(ctx) { 49 112 case ctx.data { 50 113 option.Some(value.Object(fields)) -> { 114 + // Get did from parent if available (propagated from record level) 115 + let parent_did = case list.key_find(fields, "did") { 116 + Ok(value.String(d)) -> option.Some(d) 117 + _ -> option.None 118 + } 119 + 51 120 case list.key_find(fields, name) { 52 - Ok(val) -> Ok(val) 121 + Ok(val) -> { 122 + case type_, parent_did { 123 + // For blob fields, ensure did is injected 124 + "blob", option.Some(did) -> Ok(enrich_blob_with_did(val, did)) 125 + // For nested objects/arrays, propagate did 126 + _, option.Some(did) -> Ok(propagate_did(val, did)) 127 + // No did available, return as-is 128 + _, option.None -> Ok(val) 129 + } 130 + } 53 131 Error(_) -> Ok(value.Null) 54 132 } 55 133 } ··· 69 147 ) -> Dict(String, schema.Type) { 70 148 let object_refs = lexicon_registry.get_all_object_refs(registry) 71 149 72 - // Build all object types in a single pass 73 - // For simple cases (no circular refs), this works fine 74 - // TODO: Handle circular refs if needed 75 - list.fold(object_refs, dict.new(), fn(acc, ref) { 150 + // Sort refs so #fragment refs are built before main refs 151 + // This ensures union member types exist when main types reference them 152 + let sorted_refs = sort_refs_dependencies_first(object_refs) 153 + 154 + // Build all object types in dependency order 155 + list.fold(sorted_refs, dict.new(), fn(acc, ref) { 76 156 case lexicon_registry.get_object_def(registry, ref) { 77 157 option.Some(obj_def) -> { 78 158 // Generate a GraphQL type name from the ref 79 159 // e.g., "social.grain.defs#aspectRatio" -> "SocialGrainDefsAspectRatio" 80 160 let type_name = ref_to_type_name(ref) 161 + let lexicon_id = lexicon_registry.lexicon_id_from_ref(ref) 81 162 // Pass acc as the object_types_dict so we can resolve refs to previously built types 82 - let object_type = build_object_type(obj_def, type_name, acc) 163 + let object_type = build_object_type(obj_def, type_name, lexicon_id, acc) 83 164 dict.insert(acc, ref, object_type) 84 165 } 85 166 option.None -> acc ··· 94 175 let normalized = string.replace(ref, "#", ".") 95 176 nsid.to_type_name(normalized) 96 177 } 178 + 179 + /// Expand a shorthand ref to fully-qualified ref 180 + /// "#image" with lexicon_id "app.bsky.embed.images" -> "app.bsky.embed.images#image" 181 + fn expand_ref(ref: String, lexicon_id: String) -> String { 182 + case string.starts_with(ref, "#") { 183 + True -> lexicon_id <> ref 184 + False -> ref 185 + } 186 + } 187 + 188 + /// Expand shorthand refs in ArrayItems 189 + fn expand_array_items( 190 + items: types.ArrayItems, 191 + lexicon_id: String, 192 + ) -> types.ArrayItems { 193 + types.ArrayItems( 194 + type_: items.type_, 195 + ref: option.map(items.ref, fn(r) { expand_ref(r, lexicon_id) }), 196 + refs: option.map(items.refs, fn(rs) { 197 + list.map(rs, fn(r) { expand_ref(r, lexicon_id) }) 198 + }), 199 + ) 200 + } 201 + 202 + /// Enrich a blob value with did for URL generation 203 + /// Handles both raw blob format and AT Protocol $link format 204 + fn enrich_blob_with_did(val: value.Value, did: String) -> value.Value { 205 + case val { 206 + value.Object(fields) -> { 207 + // Extract ref - handle nested $link format from AT Protocol 208 + let ref = case list.key_find(fields, "ref") { 209 + Ok(value.Object(ref_obj)) -> { 210 + case list.key_find(ref_obj, "$link") { 211 + Ok(value.String(cid)) -> cid 212 + _ -> "" 213 + } 214 + } 215 + Ok(value.String(cid)) -> cid 216 + _ -> "" 217 + } 218 + 219 + let mime_type = case list.key_find(fields, "mimeType") { 220 + Ok(value.String(mt)) -> mt 221 + _ -> "image/jpeg" 222 + } 223 + 224 + let size = case list.key_find(fields, "size") { 225 + Ok(value.Int(s)) -> s 226 + _ -> 0 227 + } 228 + 229 + value.Object([ 230 + #("ref", value.String(ref)), 231 + #("mime_type", value.String(mime_type)), 232 + #("size", value.Int(size)), 233 + #("did", value.String(did)), 234 + ]) 235 + } 236 + _ -> val 237 + } 238 + } 239 + 240 + /// Propagate did through nested objects and arrays 241 + fn propagate_did(val: value.Value, did: String) -> value.Value { 242 + case val { 243 + value.Object(fields) -> { 244 + let has_did = list.any(fields, fn(f) { f.0 == "did" }) 245 + case has_did { 246 + True -> val 247 + False -> 248 + value.Object(list.append(fields, [#("did", value.String(did))])) 249 + } 250 + } 251 + value.List(items) -> { 252 + value.List(list.map(items, fn(item) { propagate_did(item, did) })) 253 + } 254 + _ -> val 255 + } 256 + }
+245 -23
lexicon_graphql/src/lexicon_graphql/internal/graphql/type_mapper.gleam
··· 12 12 import lexicon_graphql/scalar/blob as blob_type 13 13 import lexicon_graphql/types 14 14 import swell/schema 15 + import swell/value 15 16 16 17 /// Maps a lexicon type string to a GraphQL output Type. 17 18 /// ··· 116 117 pub fn map_array_type( 117 118 items: Option(types.ArrayItems), 118 119 object_types: Dict(String, schema.Type), 120 + parent_type_name: String, 121 + field_name: String, 119 122 ) -> schema.Type { 120 123 case items { 121 124 option.None -> ··· 151 154 "union" -> { 152 155 case item_refs { 153 156 option.Some(refs) -> { 154 - let union_type = build_array_union_type(refs, object_types) 157 + let union_type = 158 + build_array_union_type( 159 + refs, 160 + object_types, 161 + parent_type_name, 162 + field_name, 163 + ) 155 164 schema.list_type(schema.non_null(union_type)) 156 165 } 157 166 option.None -> ··· 174 183 } 175 184 176 185 /// Build a union type from a list of refs. 177 - /// Returns a GraphQL union with name like "FmTealAlphaFeedDefsArtistOrFmTealAlphaFeedDefsTrack" 186 + /// Returns a GraphQL union with name like "ParentTypeFieldUnion" 187 + /// Returns String type if no member types can be resolved (to avoid invalid unions) 178 188 fn build_array_union_type( 179 189 refs: List(String), 180 190 object_types: Dict(String, schema.Type), 191 + parent_type_name: String, 192 + field_name: String, 181 193 ) -> schema.Type { 182 - // Convert refs to type names 183 - let type_names = list.map(refs, ref_to_type_name) 184 - 185 - // Build union name: "TypeAOrTypeBOrTypeC" 186 - let union_name = string.join(type_names, "Or") 187 - 188 194 // Look up member types from object_types dict using raw refs (dict is keyed by raw refs) 189 195 let member_types = 190 196 list.filter_map(refs, fn(ref) { ··· 194 200 } 195 201 }) 196 202 197 - // Type resolver - inspect context to determine concrete type 198 - let type_resolver = fn(_ctx: schema.Context) -> Result(String, String) { 199 - // For now, return first type - proper implementation needs __typename or type field 200 - case type_names { 201 - [first, ..] -> Ok(first) 202 - [] -> Error("No types in union") 203 + // If no member types could be resolved, fall back to String type 204 + // This prevents invalid unions with 0 members 205 + case member_types { 206 + [] -> schema.string_type() 207 + _ -> { 208 + // Get only the refs that were successfully resolved 209 + let resolved_refs = 210 + list.filter(refs, fn(ref) { 211 + case dict.get(object_types, ref) { 212 + Ok(_) -> True 213 + Error(_) -> False 214 + } 215 + }) 216 + 217 + // Convert resolved refs to type names 218 + let type_names = list.map(resolved_refs, ref_to_type_name) 219 + 220 + // Build union name: ParentTypeNameFieldNameUnion 221 + let capitalized_field = capitalize_first(field_name) 222 + let union_name = parent_type_name <> capitalized_field <> "Union" 223 + 224 + // Build a mapping from $type values to GraphQL type names 225 + // AT Protocol $type values are like "app.bsky.richtext.facet#mention" 226 + // GraphQL type names are like "AppBskyRichtextFacetMention" 227 + let ref_to_name_map = 228 + list.zip(resolved_refs, type_names) 229 + |> dict.from_list() 230 + 231 + // Type resolver - inspect $type field in context to determine concrete type 232 + let type_resolver = fn(ctx: schema.Context) -> Result(String, String) { 233 + case ctx.data { 234 + option.Some(value.Object(fields)) -> { 235 + // Look for $type field in the data 236 + case list.key_find(fields, "$type") { 237 + Ok(value.String(type_value)) -> { 238 + // Map the $type value to a GraphQL type name 239 + case dict.get(ref_to_name_map, type_value) { 240 + Ok(type_name) -> Ok(type_name) 241 + Error(_) -> { 242 + // Fallback: try to convert the $type value directly 243 + Ok(ref_to_type_name(type_value)) 244 + } 245 + } 246 + } 247 + _ -> { 248 + // No $type field, fall back to first type 249 + case type_names { 250 + [first, ..] -> Ok(first) 251 + [] -> Error("No types in union") 252 + } 253 + } 254 + } 255 + } 256 + _ -> { 257 + // No object data, fall back to first type 258 + case type_names { 259 + [first, ..] -> Ok(first) 260 + [] -> Error("No types in union") 261 + } 262 + } 263 + } 264 + } 265 + 266 + schema.union_type( 267 + union_name, 268 + "Union of array item types", 269 + member_types, 270 + type_resolver, 271 + ) 203 272 } 204 273 } 205 - 206 - schema.union_type( 207 - union_name, 208 - "Union of array item types", 209 - member_types, 210 - type_resolver, 211 - ) 212 274 } 213 275 214 276 /// Maps a lexicon Property to a GraphQL type. 215 277 /// Handles arrays specially by looking at the items field. 278 + /// Note: This version doesn't expand local refs. Use map_property_type_with_context 279 + /// with lexicon_id for proper local ref resolution. 216 280 pub fn map_property_type( 217 281 property: types.Property, 218 282 object_types: Dict(String, schema.Type), 219 283 ) -> schema.Type { 284 + map_property_type_with_context(property, object_types, "", "", "") 285 + } 286 + 287 + /// Maps a lexicon Property to a GraphQL type, with parent context for union naming. 288 + /// Handles arrays, refs, and unions with proper type resolution. 289 + /// lexicon_id is used to expand local refs (e.g., "#replyRef" -> "app.bsky.feed.post#replyRef") 290 + pub fn map_property_type_with_context( 291 + property: types.Property, 292 + object_types: Dict(String, schema.Type), 293 + parent_type_name: String, 294 + field_name: String, 295 + lexicon_id: String, 296 + ) -> schema.Type { 220 297 case property.type_ { 221 - "array" -> map_array_type(property.items, object_types) 298 + "array" -> { 299 + // Expand local refs in array items before lookup 300 + let expanded_items = case property.items { 301 + option.Some(types.ArrayItems( 302 + type_: item_type, 303 + ref: item_ref, 304 + refs: item_refs, 305 + )) -> { 306 + option.Some(types.ArrayItems( 307 + type_: item_type, 308 + ref: option.map(item_ref, fn(r) { expand_ref(r, lexicon_id) }), 309 + refs: option.map(item_refs, fn(rs) { 310 + list.map(rs, fn(r) { expand_ref(r, lexicon_id) }) 311 + }), 312 + )) 313 + } 314 + option.None -> option.None 315 + } 316 + map_array_type(expanded_items, object_types, parent_type_name, field_name) 317 + } 222 318 "ref" -> { 223 319 case property.ref { 224 320 option.Some(ref_str) -> { 225 - case dict.get(object_types, ref_str) { 321 + // Expand local ref before lookup 322 + let full_ref = expand_ref(ref_str, lexicon_id) 323 + case dict.get(object_types, full_ref) { 226 324 Ok(obj_type) -> obj_type 227 325 Error(_) -> schema.string_type() 228 326 } ··· 230 328 option.None -> schema.string_type() 231 329 } 232 330 } 331 + "union" -> { 332 + case property.refs { 333 + option.Some(refs) -> { 334 + // Expand local refs in union before lookup 335 + let expanded_refs = 336 + list.map(refs, fn(r) { expand_ref(r, lexicon_id) }) 337 + build_property_union_type( 338 + expanded_refs, 339 + object_types, 340 + parent_type_name, 341 + field_name, 342 + ) 343 + } 344 + option.None -> schema.string_type() 345 + } 346 + } 233 347 _ -> map_type(property.type_) 234 348 } 235 349 } 350 + 351 + /// Build a union type for a property field. 352 + /// Names the union as ParentTypeNameFieldName (e.g., AppBskyFeedPostEmbed) 353 + /// Returns String type if no member types can be resolved (to avoid invalid unions) 354 + fn build_property_union_type( 355 + refs: List(String), 356 + object_types: Dict(String, schema.Type), 357 + parent_type_name: String, 358 + field_name: String, 359 + ) -> schema.Type { 360 + // Look up member types from object_types dict 361 + let member_types = 362 + list.filter_map(refs, fn(ref) { 363 + case dict.get(object_types, ref) { 364 + Ok(t) -> Ok(t) 365 + Error(_) -> Error(Nil) 366 + } 367 + }) 368 + 369 + // If no member types could be resolved, fall back to String type 370 + // This prevents invalid unions with 0 members 371 + case member_types { 372 + [] -> schema.string_type() 373 + _ -> { 374 + // Build union name: ParentTypeNameFieldName (capitalize field name) 375 + let capitalized_field = capitalize_first(field_name) 376 + let union_name = parent_type_name <> capitalized_field 377 + 378 + // Type resolver - inspect $type field to determine concrete type 379 + // Only include refs that were successfully resolved to types 380 + let resolved_refs = 381 + list.filter(refs, fn(ref) { 382 + case dict.get(object_types, ref) { 383 + Ok(_) -> True 384 + Error(_) -> False 385 + } 386 + }) 387 + 388 + // Convert resolved refs to type names 389 + let type_names = list.map(resolved_refs, ref_to_type_name) 390 + 391 + // Build a mapping from $type values to GraphQL type names 392 + let ref_to_name_map = 393 + list.zip(resolved_refs, type_names) 394 + |> dict.from_list() 395 + 396 + // Type resolver - inspect $type field in context to determine concrete type 397 + let type_resolver = fn(ctx: schema.Context) -> Result(String, String) { 398 + case ctx.data { 399 + option.Some(value.Object(fields)) -> { 400 + // Look for $type field in the data 401 + case list.key_find(fields, "$type") { 402 + Ok(value.String(type_value)) -> { 403 + // Map the $type value to a GraphQL type name 404 + case dict.get(ref_to_name_map, type_value) { 405 + Ok(type_name) -> Ok(type_name) 406 + Error(_) -> { 407 + // Fallback: try to convert the $type value directly 408 + Ok(ref_to_type_name(type_value)) 409 + } 410 + } 411 + } 412 + _ -> { 413 + // No $type field, fall back to first type 414 + case type_names { 415 + [first, ..] -> Ok(first) 416 + [] -> Error("No types in union") 417 + } 418 + } 419 + } 420 + } 421 + _ -> { 422 + // No object data, fall back to first type 423 + case type_names { 424 + [first, ..] -> Ok(first) 425 + [] -> Error("No types in union") 426 + } 427 + } 428 + } 429 + } 430 + 431 + schema.union_type( 432 + union_name, 433 + "Union type for " <> field_name, 434 + member_types, 435 + type_resolver, 436 + ) 437 + } 438 + } 439 + } 440 + 441 + /// Capitalize the first letter of a string 442 + fn capitalize_first(s: String) -> String { 443 + case string.pop_grapheme(s) { 444 + Ok(#(first, rest)) -> string.uppercase(first) <> rest 445 + Error(_) -> s 446 + } 447 + } 448 + 449 + /// Expand a local ref to a fully-qualified ref 450 + /// "#replyRef" with lexicon_id "app.bsky.feed.post" -> "app.bsky.feed.post#replyRef" 451 + /// External refs pass through unchanged 452 + pub fn expand_ref(ref: String, lexicon_id: String) -> String { 453 + case string.starts_with(ref, "#") { 454 + True -> lexicon_id <> ref 455 + False -> ref 456 + } 457 + }
+71 -11
lexicon_graphql/src/lexicon_graphql/internal/lexicon/parser.gleam
··· 28 28 use main <- decode.optional_field( 29 29 "main", 30 30 None, 31 - decode.optional(decode_record_def()), 31 + decode.optional(decode_main_def()), 32 32 ) 33 33 use defs_dict <- decode.then(decode.dict(decode.string, decode.dynamic)) 34 34 ··· 90 90 let #(name, prop_dyn) = entry 91 91 let is_required = list.contains(required_fields, name) 92 92 93 - let #(prop_type, prop_format, prop_ref, prop_items) = case 93 + let #(prop_type, prop_format, prop_ref, prop_refs, prop_items) = case 94 94 decode_property(prop_dyn) 95 95 { 96 - Ok(#(t, f, r, i)) -> #(t, f, r, i) 97 - Error(_) -> #("string", None, None, None) 96 + Ok(#(t, f, r, rs, i)) -> #(t, f, r, rs, i) 97 + Error(_) -> #("string", None, None, None, None) 98 98 } 99 99 100 100 #( ··· 104 104 is_required, 105 105 prop_format, 106 106 prop_ref, 107 + prop_refs, 107 108 prop_items, 108 109 ), 109 110 ) ··· 114 115 decode.run(dyn, decoder) 115 116 } 116 117 117 - /// Create a decoder for a record definition 118 + /// Decoder for main definition - tries record first, then object type 119 + fn decode_main_def() -> decode.Decoder(types.RecordDef) { 120 + // Try record type first (has "record" wrapper) 121 + // If that fails, try object type (flat structure with properties directly) 122 + decode.one_of(decode_record_def(), [decode_object_main_def()]) 123 + } 124 + 125 + /// Create a decoder for a record definition (type: "record" with "record" wrapper) 118 126 fn decode_record_def() -> decode.Decoder(types.RecordDef) { 119 127 use type_ <- decode.field("type", decode.string) 120 128 use key <- decode.optional_field("key", None, decode.optional(decode.string)) ··· 122 130 decode.success(types.RecordDef(type_:, key:, properties: record)) 123 131 } 124 132 133 + /// Create a decoder for object-type main definitions (type: "object" without wrapper) 134 + /// These lexicons have properties directly in main, not wrapped in a "record" field 135 + fn decode_object_main_def() -> decode.Decoder(types.RecordDef) { 136 + use type_ <- decode.field("type", decode.string) 137 + use required_fields <- decode.optional_field( 138 + "required", 139 + [], 140 + decode.list(decode.string), 141 + ) 142 + use properties_dict <- decode.field( 143 + "properties", 144 + decode.dict(decode.string, decode.dynamic), 145 + ) 146 + 147 + // Convert dict to list of properties 148 + let properties = 149 + properties_dict 150 + |> dict.to_list 151 + |> list.map(fn(entry) { 152 + let #(name, prop_dyn) = entry 153 + let is_required = list.contains(required_fields, name) 154 + 155 + let #(prop_type, prop_format, prop_ref, prop_refs, prop_items) = case 156 + decode_property(prop_dyn) 157 + { 158 + Ok(#(t, f, r, rs, i)) -> #(t, f, r, rs, i) 159 + Error(_) -> #("string", None, None, None, None) 160 + } 161 + 162 + #( 163 + name, 164 + types.Property( 165 + prop_type, 166 + is_required, 167 + prop_format, 168 + prop_ref, 169 + prop_refs, 170 + prop_items, 171 + ), 172 + ) 173 + }) 174 + 175 + decode.success(types.RecordDef(type_:, key: None, properties:)) 176 + } 177 + 125 178 /// Create a decoder for the record object which contains properties 126 179 fn decode_record_object() -> decode.Decoder(List(#(String, types.Property))) { 127 180 // This is more complex - we need to decode a dict of properties ··· 143 196 let #(name, prop_dyn) = entry 144 197 let is_required = list.contains(required_list, name) 145 198 146 - // Extract type, format, ref, and items from the property 147 - let #(prop_type, prop_format, prop_ref, prop_items) = case 199 + // Extract type, format, ref, refs, and items from the property 200 + let #(prop_type, prop_format, prop_ref, prop_refs, prop_items) = case 148 201 decode_property(prop_dyn) 149 202 { 150 - Ok(#(t, f, r, i)) -> #(t, f, r, i) 151 - Error(_) -> #("string", None, None, None) 203 + Ok(#(t, f, r, rs, i)) -> #(t, f, r, rs, i) 204 + Error(_) -> #("string", None, None, None, None) 152 205 // Default fallback 153 206 } 154 207 ··· 159 212 is_required, 160 213 prop_format, 161 214 prop_ref, 215 + prop_refs, 162 216 prop_items, 163 217 ), 164 218 ) ··· 167 221 decode.success(properties) 168 222 } 169 223 170 - /// Decode a property's type, format, ref, and items fields 224 + /// Decode a property's type, format, ref, refs, and items fields 171 225 fn decode_property( 172 226 dyn: decode.Dynamic, 173 227 ) -> Result( ··· 175 229 String, 176 230 option.Option(String), 177 231 option.Option(String), 232 + option.Option(List(String)), 178 233 option.Option(types.ArrayItems), 179 234 ), 180 235 List(decode.DecodeError), ··· 191 246 None, 192 247 decode.optional(decode.string), 193 248 ) 249 + use refs <- decode.optional_field( 250 + "refs", 251 + None, 252 + decode.optional(decode.list(decode.string)), 253 + ) 194 254 use items_dyn <- decode.optional_field( 195 255 "items", 196 256 None, ··· 207 267 None -> None 208 268 } 209 269 210 - decode.success(#(type_, format, ref, items)) 270 + decode.success(#(type_, format, ref, refs, items)) 211 271 } 212 272 decode.run(dyn, property_decoder) 213 273 }
+38 -1
lexicon_graphql/src/lexicon_graphql/internal/lexicon/registry.gleam
··· 28 28 |> dict.from_list 29 29 30 30 // Build object defs dict by extracting all object definitions from all lexicons 31 - let object_defs_dict = 31 + // This includes both: 32 + // 1. Object definitions in "others" (e.g., defs#aspectRatio) 33 + // 2. Main-level object types (e.g., app.bsky.embed.images where main.type == "object") 34 + let others_object_defs = 32 35 lexicons 33 36 |> list.flat_map(fn(lex) { 34 37 // Extract all object definitions from this lexicon's "others" dict ··· 46 49 } 47 50 }) 48 51 }) 52 + 53 + // Extract main-level object types (like app.bsky.embed.images) 54 + let main_object_defs = 55 + lexicons 56 + |> list.filter_map(fn(lex) { 57 + case lex.defs.main { 58 + option.Some(types.RecordDef(type_: "object", key: _, properties: props)) -> { 59 + // Convert RecordDef to ObjectDef for main-level object types 60 + let obj_def = 61 + types.ObjectDef( 62 + type_: "object", 63 + required_fields: [], 64 + properties: props, 65 + ) 66 + // Use lexicon id as key (no # fragment for main-level types) 67 + Ok(#(lex.id, obj_def)) 68 + } 69 + _ -> Error(Nil) 70 + } 71 + }) 72 + 73 + // Merge both sources of object definitions 74 + let object_defs_dict = 75 + list.append(others_object_defs, main_object_defs) 49 76 |> dict.from_list 50 77 51 78 Registry(lexicons: lexicons_dict, object_defs: object_defs_dict) ··· 72 99 case string.split(ref, "#") { 73 100 [lexicon_id, def_name] -> option.Some(#(lexicon_id, def_name)) 74 101 _ -> option.None 102 + } 103 + } 104 + 105 + /// Extract lexicon ID from a fully-qualified ref 106 + /// "app.bsky.embed.images#image" -> "app.bsky.embed.images" 107 + /// "app.bsky.embed.images" -> "app.bsky.embed.images" 108 + pub fn lexicon_id_from_ref(ref: String) -> String { 109 + case string.split(ref, "#") { 110 + [lexicon_id, _] -> lexicon_id 111 + _ -> ref 75 112 } 76 113 } 77 114
+1 -1
lexicon_graphql/src/lexicon_graphql/mutation/builder.gleam
··· 262 262 ) -> schema.Type { 263 263 let input_fields = 264 264 list.map(properties, fn(prop) { 265 - let #(name, types.Property(type_, required, _, _, _)) = prop 265 + let #(name, types.Property(type_, required, _, _, _, _)) = prop 266 266 // Use map_input_type to get input-compatible types (e.g., BlobInput instead of Blob) 267 267 let graphql_type = type_mapper.map_input_type(type_) 268 268
+86 -11
lexicon_graphql/src/lexicon_graphql/schema/builder.gleam
··· 46 46 // First extract ref object types from lexicon "others" (e.g., #artist, #aspectRatio) 47 47 let ref_object_types = extract_ref_object_types(lexicons) 48 48 49 - // Extract record types from lexicons, passing ref_object_types for field resolution 50 - let record_types = extract_record_types(lexicons, ref_object_types) 49 + // Extract object-type lexicons (like embed types) 50 + let object_type_lexicons = 51 + extract_object_type_lexicons(lexicons, ref_object_types) 51 52 52 - // Build object types dict for sharing between queries and mutations 53 - let object_types = build_object_types_dict(record_types) 53 + // Merge ref_object_types with object_type_lexicons 54 + let all_object_types = dict.merge(ref_object_types, object_type_lexicons) 54 55 55 - // Build the query type with fields for each record 56 + // Extract record types from lexicons, passing all object types for field resolution 57 + let record_types = extract_record_types(lexicons, all_object_types) 58 + 59 + // Build object types dict including record types 60 + let record_object_types = build_object_types_dict(record_types) 61 + let object_types = dict.merge(all_object_types, record_object_types) 62 + 63 + // Build the query type with fields for each record (not object types) 56 64 let query_type = build_query_type(record_types, object_types) 57 65 58 66 // Build the mutation type with stub resolvers, using shared object types ··· 93 101 ) -> { 94 102 let type_name = nsid.to_type_name(id) 95 103 let field_name = nsid.to_field_name(id) 96 - let fields = build_fields(properties, ref_object_types) 104 + let fields = 105 + build_fields_with_context(properties, ref_object_types, type_name, id) 97 106 98 107 Ok(RecordType( 99 108 nsid: id, ··· 106 115 } 107 116 } 108 117 109 - /// Build GraphQL fields from lexicon properties 110 - fn build_fields( 118 + /// Build GraphQL fields from lexicon properties with parent type context 119 + fn build_fields_with_context( 111 120 properties: List(#(String, Property)), 112 121 ref_object_types: dict.Dict(String, schema.Type), 122 + parent_type_name: String, 123 + lexicon_id: String, 113 124 ) -> List(schema.Field) { 114 125 // Add standard AT Proto fields 115 126 let standard_fields = [ ··· 130 141 ), 131 142 ] 132 143 133 - // Build fields from lexicon properties 144 + // Build fields from lexicon properties with context 134 145 let lexicon_fields = 135 146 list.map(properties, fn(prop) { 136 147 let #(name, property) = prop 137 148 let graphql_type = 138 - type_mapper.map_property_type(property, ref_object_types) 149 + type_mapper.map_property_type_with_context( 150 + property, 151 + ref_object_types, 152 + parent_type_name, 153 + name, 154 + lexicon_id, 155 + ) 139 156 140 157 schema.field(name, graphql_type, "Field from lexicon", fn(_ctx) { 141 158 Ok(value.Null) ··· 176 193 let type_name = nsid.to_type_name(id <> "." <> def_name) 177 194 178 195 // Build fields for the object type 179 - let fields = 196 + let lexicon_fields = 180 197 list.map(properties, fn(prop) { 181 198 let #(name, property) = prop 182 199 let graphql_type = type_mapper.map_type(property.type_) ··· 188 205 ) 189 206 }) 190 207 208 + // GraphQL requires at least one field - add placeholder for empty objects 209 + let fields = case lexicon_fields { 210 + [] -> [ 211 + schema.field( 212 + "_", 213 + schema.boolean_type(), 214 + "Placeholder field for empty object type", 215 + fn(_ctx) { Ok(value.Boolean(True)) }, 216 + ), 217 + ] 218 + _ -> lexicon_fields 219 + } 220 + 191 221 let object_type = 192 222 schema.object_type(type_name, "Object type: " <> full_ref, fields) 193 223 dict.insert(inner_acc, full_ref, object_type) ··· 225 255 226 256 schema.object_type("Query", "Root query type", query_fields) 227 257 } 258 + 259 + /// Extract object types from object-type lexicons (type: "object" at main level) 260 + /// These are NOT record types - they don't get query fields, just exist as types 261 + fn extract_object_type_lexicons( 262 + lexicons: List(Lexicon), 263 + ref_object_types: dict.Dict(String, schema.Type), 264 + ) -> dict.Dict(String, schema.Type) { 265 + list.fold(lexicons, dict.new(), fn(acc, lexicon) { 266 + case lexicon { 267 + types.Lexicon( 268 + id, 269 + types.Defs(option.Some(types.RecordDef("object", _, properties)), _), 270 + ) -> { 271 + let type_name = nsid.to_type_name(id) 272 + let fields = build_object_type_fields(properties, ref_object_types, id) 273 + let object_type = 274 + schema.object_type(type_name, "Object type: " <> id, fields) 275 + dict.insert(acc, id, object_type) 276 + } 277 + _ -> acc 278 + } 279 + }) 280 + } 281 + 282 + /// Build fields for object types (simplified - no standard AT Proto fields) 283 + fn build_object_type_fields( 284 + properties: List(#(String, Property)), 285 + ref_object_types: dict.Dict(String, schema.Type), 286 + lexicon_id: String, 287 + ) -> List(schema.Field) { 288 + list.map(properties, fn(prop) { 289 + let #(name, property) = prop 290 + let graphql_type = 291 + type_mapper.map_property_type_with_context( 292 + property, 293 + ref_object_types, 294 + "", 295 + name, 296 + lexicon_id, 297 + ) 298 + schema.field(name, graphql_type, "Field from lexicon", fn(_ctx) { 299 + Ok(value.Null) 300 + }) 301 + }) 302 + }
+47 -6
lexicon_graphql/src/lexicon_graphql/schema/database.gleam
··· 650 650 meta, 651 651 batch_fetcher, 652 652 ref_object_types, 653 + type_name, 654 + id, 653 655 ) 654 656 655 657 // Note: Reverse join fields are NOT built here - they will be added later ··· 679 681 _meta: collection_meta.CollectionMeta, 680 682 _batch_fetcher: option.Option(dataloader.BatchFetcher), 681 683 ref_object_types: Dict(String, schema.Type), 684 + parent_type_name: String, 685 + lexicon_id: String, 682 686 ) -> List(schema.Field) { 683 - let regular_fields = build_fields(properties, ref_object_types) 687 + let regular_fields = 688 + build_fields(properties, ref_object_types, parent_type_name, lexicon_id) 684 689 // Note: Forward join fields are NOT built here - they need object types first 685 690 686 691 regular_fields ··· 1189 1194 fn build_fields( 1190 1195 properties: List(#(String, types.Property)), 1191 1196 ref_object_types: Dict(String, schema.Type), 1197 + parent_type_name: String, 1198 + lexicon_id: String, 1192 1199 ) -> List(schema.Field) { 1193 1200 // Add standard AT Proto fields 1194 1201 let standard_fields = [ ··· 1246 1253 ), 1247 1254 ] 1248 1255 1249 - // Build fields from lexicon properties 1256 + // Build fields from lexicon properties with context for union naming 1250 1257 let lexicon_fields = 1251 1258 list.map(properties, fn(prop) { 1252 1259 let #(name, property) = prop 1253 - let types.Property(type_, _required, _format, _ref, _items) = property 1254 - // Use map_property_type to handle arrays, refs, and primitives 1260 + let types.Property(type_, _required, _format, _ref, _refs, _items) = 1261 + property 1262 + // Use map_property_type_with_context to handle arrays, refs, unions, and primitives 1255 1263 let graphql_type = 1256 - type_mapper.map_property_type(property, ref_object_types) 1264 + type_mapper.map_property_type_with_context( 1265 + property, 1266 + ref_object_types, 1267 + parent_type_name, 1268 + name, 1269 + lexicon_id, 1270 + ) 1257 1271 1258 1272 schema.field(name, graphql_type, "Field from lexicon", fn(ctx) { 1259 1273 // Special handling for blob fields ··· 1269 1283 // Try to extract field from the value object in context 1270 1284 // Use the type-safe version that preserves Int, Float, Boolean types 1271 1285 case get_nested_field_value_from_context(ctx, "value", name) { 1272 - Ok(val) -> Ok(val) 1286 + Ok(val) -> { 1287 + // Inject did into nested objects for blob URL resolution 1288 + let did = case get_field_from_context(ctx, "did") { 1289 + Ok(d) -> d 1290 + Error(_) -> "" 1291 + } 1292 + Ok(inject_did_into_value(val, did)) 1293 + } 1273 1294 Error(_) -> Ok(value.Null) 1274 1295 } 1275 1296 } ··· 2080 2101 } 2081 2102 } 2082 2103 _ -> Error(Nil) 2104 + } 2105 + } 2106 + 2107 + /// Inject DID into a value for nested blob resolution 2108 + /// Recursively adds "did" field to objects that might contain blobs 2109 + fn inject_did_into_value(val: value.Value, did: String) -> value.Value { 2110 + case val { 2111 + value.Object(fields) -> { 2112 + // Add did if not already present 2113 + let has_did = list.any(fields, fn(f) { f.0 == "did" }) 2114 + case has_did { 2115 + True -> val 2116 + False -> 2117 + value.Object(list.append(fields, [#("did", value.String(did))])) 2118 + } 2119 + } 2120 + value.List(items) -> { 2121 + value.List(list.map(items, fn(item) { inject_did_into_value(item, did) })) 2122 + } 2123 + _ -> val 2083 2124 } 2084 2125 } 2085 2126
+1
lexicon_graphql/src/lexicon_graphql/types.gleam
··· 48 48 required: Bool, 49 49 format: Option(String), 50 50 ref: Option(String), 51 + refs: Option(List(String)), 51 52 items: Option(ArrayItems), 52 53 ) 53 54 }
+155
lexicon_graphql/test/array_ref_expansion_test.gleam
··· 1 + /// Tests for array ref expansion in object types 2 + /// 3 + /// Tests that array fields with refs properly resolve to object types. 4 + import gleam/dict 5 + import gleam/option 6 + import gleam/string 7 + import gleeunit/should 8 + import lexicon_graphql/schema/builder 9 + import lexicon_graphql/types 10 + import swell/introspection 11 + import swell/sdl 12 + 13 + /// Test that array fields in records with refs to object defs resolve correctly. 14 + /// This mirrors the pattern in array_type_test but verifies the full ref path. 15 + pub fn array_in_record_with_ref_to_object_test() { 16 + // Similar to the existing array_of_refs_generates_object_list_type_test 17 + // but uses a different lexicon to verify our changes work 18 + let lexicon = 19 + types.Lexicon( 20 + id: "app.bsky.embed.images", 21 + defs: types.Defs( 22 + main: option.Some( 23 + types.RecordDef(type_: "record", key: option.None, properties: [ 24 + #( 25 + "images", 26 + types.Property( 27 + type_: "array", 28 + required: True, 29 + format: option.None, 30 + ref: option.None, 31 + refs: option.None, 32 + items: option.Some(types.ArrayItems( 33 + type_: "ref", 34 + ref: option.Some("app.bsky.embed.images#image"), 35 + refs: option.None, 36 + )), 37 + ), 38 + ), 39 + ]), 40 + ), 41 + others: dict.from_list([ 42 + #( 43 + "image", 44 + types.Object( 45 + types.ObjectDef(type_: "object", required_fields: [], properties: [ 46 + #( 47 + "alt", 48 + types.Property( 49 + type_: "string", 50 + required: False, 51 + format: option.None, 52 + ref: option.None, 53 + refs: option.None, 54 + items: option.None, 55 + ), 56 + ), 57 + ]), 58 + ), 59 + ), 60 + ]), 61 + ), 62 + ) 63 + 64 + let result = builder.build_schema([lexicon]) 65 + should.be_ok(result) 66 + 67 + case result { 68 + Ok(schema_val) -> { 69 + let all_types = introspection.get_all_schema_types(schema_val) 70 + let serialized = sdl.print_types(all_types) 71 + 72 + // The image type should exist 73 + string.contains(serialized, "type AppBskyEmbedImagesImage") 74 + |> should.be_true 75 + 76 + // The image type should have the alt field 77 + string.contains(serialized, "alt: String") 78 + |> should.be_true 79 + } 80 + Error(_) -> should.fail() 81 + } 82 + } 83 + 84 + /// Test that object types in "others" get properly built with their fields. 85 + /// This verifies the object_builder creates types from ObjectDef definitions. 86 + pub fn object_def_in_others_builds_correctly_test() { 87 + // Create a record that references an object def in others 88 + let lexicon = 89 + types.Lexicon( 90 + id: "social.grain.example", 91 + defs: types.Defs( 92 + main: option.Some( 93 + types.RecordDef(type_: "record", key: option.None, properties: [ 94 + #( 95 + "ratio", 96 + types.Property( 97 + type_: "ref", 98 + required: False, 99 + format: option.None, 100 + ref: option.Some("social.grain.example#aspectRatio"), 101 + refs: option.None, 102 + items: option.None, 103 + ), 104 + ), 105 + ]), 106 + ), 107 + others: dict.from_list([ 108 + #( 109 + "aspectRatio", 110 + types.Object( 111 + types.ObjectDef(type_: "object", required_fields: [], properties: [ 112 + #( 113 + "width", 114 + types.Property( 115 + type_: "integer", 116 + required: True, 117 + format: option.None, 118 + ref: option.None, 119 + refs: option.None, 120 + items: option.None, 121 + ), 122 + ), 123 + #( 124 + "height", 125 + types.Property( 126 + type_: "integer", 127 + required: True, 128 + format: option.None, 129 + ref: option.None, 130 + refs: option.None, 131 + items: option.None, 132 + ), 133 + ), 134 + ]), 135 + ), 136 + ), 137 + ]), 138 + ), 139 + ) 140 + 141 + let result = builder.build_schema([lexicon]) 142 + should.be_ok(result) 143 + 144 + case result { 145 + Ok(schema_val) -> { 146 + let all_types = introspection.get_all_schema_types(schema_val) 147 + let serialized = sdl.print_types(all_types) 148 + 149 + // The aspectRatio type should exist 150 + string.contains(serialized, "SocialGrainExampleAspectRatio") 151 + |> should.be_true 152 + } 153 + Error(_) -> should.fail() 154 + } 155 + }
+3
lexicon_graphql/test/array_type_test.gleam
··· 22 22 required: False, 23 23 format: option.None, 24 24 ref: option.None, 25 + refs: option.None, 25 26 items: option.Some(types.ArrayItems( 26 27 type_: "string", 27 28 ref: option.None, ··· 64 65 required: False, 65 66 format: option.None, 66 67 ref: option.None, 68 + refs: option.None, 67 69 items: option.Some(types.ArrayItems( 68 70 type_: "ref", 69 71 ref: option.Some("fm.teal.alpha.feed.track#artist"), ··· 88 90 required: True, 89 91 format: option.None, 90 92 ref: option.None, 93 + refs: option.None, 91 94 items: option.None, 92 95 ), 93 96 ),
+12
lexicon_graphql/test/collection_meta_test.gleam
··· 22 22 required: True, 23 23 format: None, 24 24 ref: None, 25 + refs: None, 25 26 items: None, 26 27 ), 27 28 ), ··· 32 33 required: False, 33 34 format: None, 34 35 ref: Some("com.atproto.repo.strongRef"), 36 + refs: None, 35 37 items: None, 36 38 ), 37 39 ), ··· 42 44 required: False, 43 45 format: None, 44 46 ref: Some("com.atproto.repo.strongRef"), 47 + refs: None, 45 48 items: None, 46 49 ), 47 50 ), ··· 91 94 required: True, 92 95 format: Some("at-uri"), 93 96 ref: None, 97 + refs: None, 94 98 items: None, 95 99 ), 96 100 ), ··· 101 105 required: True, 102 106 format: Some("datetime"), 103 107 ref: None, 108 + refs: None, 104 109 items: None, 105 110 ), 106 111 ), ··· 150 155 required: True, 151 156 format: None, 152 157 ref: None, 158 + refs: None, 153 159 items: None, 154 160 ), 155 161 ), ··· 160 166 required: False, 161 167 format: None, 162 168 ref: Some("com.atproto.repo.strongRef"), 169 + refs: None, 163 170 items: None, 164 171 ), 165 172 ), ··· 170 177 required: False, 171 178 format: Some("at-uri"), 172 179 ref: None, 180 + refs: None, 173 181 items: None, 174 182 ), 175 183 ), ··· 215 223 required: True, 216 224 format: None, 217 225 ref: None, 226 + refs: None, 218 227 items: None, 219 228 ), 220 229 ), ··· 225 234 required: True, 226 235 format: Some("datetime"), 227 236 ref: None, 237 + refs: None, 228 238 items: None, 229 239 ), 230 240 ), ··· 259 269 required: True, 260 270 format: None, 261 271 ref: None, 272 + refs: None, 262 273 items: None, 263 274 ), 264 275 ), ··· 289 300 required: True, 290 301 format: None, 291 302 ref: None, 303 + refs: None, 292 304 items: None, 293 305 ), 294 306 ),
+2
lexicon_graphql/test/dataloader_test.gleam
··· 185 185 required: True, 186 186 format: Some("at-uri"), 187 187 ref: None, 188 + refs: None, 188 189 items: None, 189 190 ), 190 191 ), ··· 238 239 required: False, 239 240 format: None, 240 241 ref: Some("com.atproto.repo.strongRef"), 242 + refs: None, 241 243 items: None, 242 244 ), 243 245 ),
+10
lexicon_graphql/test/did_join_test.gleam
··· 56 56 required: True, 57 57 format: option.None, 58 58 ref: option.None, 59 + refs: option.None, 59 60 items: option.None, 60 61 ), 61 62 ), ··· 81 82 required: False, 82 83 format: option.None, 83 84 ref: option.None, 85 + refs: option.None, 84 86 items: option.None, 85 87 ), 86 88 ), ··· 122 124 required: True, 123 125 format: option.None, 124 126 ref: option.None, 127 + refs: option.None, 125 128 items: option.None, 126 129 ), 127 130 ), ··· 147 150 required: False, 148 151 format: option.None, 149 152 ref: option.None, 153 + refs: option.None, 150 154 items: option.None, 151 155 ), 152 156 ), ··· 185 189 required: True, 186 190 format: option.None, 187 191 ref: option.None, 192 + refs: option.None, 188 193 items: option.None, 189 194 ), 190 195 ), ··· 207 212 required: True, 208 213 format: option.None, 209 214 ref: option.None, 215 + refs: option.None, 210 216 items: option.None, 211 217 ), 212 218 ), ··· 243 249 required: True, 244 250 format: option.None, 245 251 ref: option.None, 252 + refs: option.None, 246 253 items: option.None, 247 254 ), 248 255 ), ··· 265 272 required: True, 266 273 format: option.None, 267 274 ref: option.None, 275 + refs: option.None, 268 276 items: option.None, 269 277 ), 270 278 ), ··· 287 295 required: True, 288 296 format: option.Some("at-uri"), 289 297 ref: option.None, 298 + refs: option.None, 290 299 items: option.None, 291 300 ), 292 301 ), ··· 335 344 required: True, 336 345 format: option.None, 337 346 ref: option.None, 347 + refs: option.None, 338 348 items: option.None, 339 349 ), 340 350 ),
+12
lexicon_graphql/test/field_type_validation_test.gleam
··· 19 19 required: True, 20 20 format: option.Some("datetime"), 21 21 ref: option.None, 22 + refs: option.None, 22 23 items: option.None, 23 24 ) 24 25 ··· 33 34 required: True, 34 35 format: option.None, 35 36 ref: option.None, 37 + refs: option.None, 36 38 items: option.None, 37 39 ) 38 40 ··· 47 49 required: True, 48 50 format: option.None, 49 51 ref: option.None, 52 + refs: option.None, 50 53 items: option.None, 51 54 ) 52 55 ··· 61 64 required: True, 62 65 format: option.None, 63 66 ref: option.None, 67 + refs: option.None, 64 68 items: option.None, 65 69 ) 66 70 ··· 75 79 required: True, 76 80 format: option.None, 77 81 ref: option.None, 82 + refs: option.None, 78 83 items: option.None, 79 84 ) 80 85 ··· 89 94 required: True, 90 95 format: option.None, 91 96 ref: option.None, 97 + refs: option.None, 92 98 items: option.None, 93 99 ) 94 100 ··· 103 109 required: True, 104 110 format: option.None, 105 111 ref: option.None, 112 + refs: option.None, 106 113 items: option.None, 107 114 ) 108 115 ··· 117 124 required: True, 118 125 format: option.None, 119 126 ref: option.Some("app.bsky.actor.defs#profileView"), 127 + refs: option.None, 120 128 items: option.None, 121 129 ) 122 130 ··· 135 143 required: True, 136 144 format: option.Some("datetime"), 137 145 ref: option.None, 146 + refs: option.None, 138 147 items: option.None, 139 148 ), 140 149 ), ··· 145 154 required: True, 146 155 format: option.None, 147 156 ref: option.None, 157 + refs: option.None, 148 158 items: option.None, 149 159 ), 150 160 ), ··· 155 165 required: False, 156 166 format: option.None, 157 167 ref: option.None, 168 + refs: option.None, 158 169 items: option.None, 159 170 ), 160 171 ), ··· 165 176 required: True, 166 177 format: option.None, 167 178 ref: option.None, 179 + refs: option.None, 168 180 items: option.None, 169 181 ), 170 182 ),
+13
lexicon_graphql/test/forward_join_test.gleam
··· 52 52 required: True, 53 53 format: None, 54 54 ref: None, 55 + refs: None, 55 56 items: None, 56 57 ), 57 58 ), ··· 62 63 required: False, 63 64 format: None, 64 65 ref: Some("com.atproto.repo.strongRef"), 66 + refs: None, 65 67 items: None, 66 68 ), 67 69 ), ··· 97 99 required: True, 98 100 format: Some("at-uri"), 99 101 ref: None, 102 + refs: None, 100 103 items: None, 101 104 ), 102 105 ), ··· 107 110 required: True, 108 111 format: Some("datetime"), 109 112 ref: None, 113 + refs: None, 110 114 items: None, 111 115 ), 112 116 ), ··· 141 145 required: True, 142 146 format: None, 143 147 ref: None, 148 + refs: None, 144 149 items: None, 145 150 ), 146 151 ), ··· 151 156 required: False, 152 157 format: None, 153 158 ref: Some("com.atproto.repo.strongRef"), 159 + refs: None, 154 160 items: None, 155 161 ), 156 162 ), ··· 161 167 required: False, 162 168 format: Some("at-uri"), 163 169 ref: None, 170 + refs: None, 164 171 items: None, 165 172 ), 166 173 ), ··· 198 205 required: True, 199 206 format: None, 200 207 ref: None, 208 + refs: None, 201 209 items: None, 202 210 ), 203 211 ), ··· 208 216 required: True, 209 217 format: Some("datetime"), 210 218 ref: None, 219 + refs: None, 211 220 items: None, 212 221 ), 213 222 ), ··· 248 257 required: True, 249 258 format: None, 250 259 ref: None, 260 + refs: None, 251 261 items: None, 252 262 ), 253 263 ), ··· 258 268 required: False, 259 269 format: None, 260 270 ref: Some("com.atproto.repo.strongRef"), 271 + refs: None, 261 272 items: None, 262 273 ), 263 274 ), ··· 292 303 required: True, 293 304 format: None, 294 305 ref: None, 306 + refs: None, 295 307 items: None, 296 308 ), 297 309 ), ··· 302 314 required: False, 303 315 format: None, 304 316 ref: Some("com.atproto.repo.strongRef"), 317 + refs: None, 305 318 items: None, 306 319 ), 307 320 ),
+182
lexicon_graphql/test/lexicon_parser_test.gleam
··· 1 1 /// Tests for Lexicon JSON Parser 2 2 /// 3 3 /// Parses AT Protocol lexicon JSON into structured Lexicon types 4 + import gleam/dict 4 5 import gleam/list 5 6 import gleam/option 6 7 import gleeunit/should ··· 223 224 } 224 225 } 225 226 227 + // Test parsing lexicon with union property (not array) 228 + pub fn parse_property_union_test() { 229 + let json = 230 + "{ 231 + \"lexicon\": 1, 232 + \"id\": \"app.bsky.feed.post\", 233 + \"defs\": { 234 + \"main\": { 235 + \"type\": \"record\", 236 + \"record\": { 237 + \"type\": \"object\", 238 + \"properties\": { 239 + \"embed\": { 240 + \"type\": \"union\", 241 + \"refs\": [ 242 + \"app.bsky.embed.images\", 243 + \"app.bsky.embed.video\" 244 + ] 245 + } 246 + } 247 + } 248 + } 249 + } 250 + }" 251 + 252 + let result = lexicon_parser.parse_lexicon(json) 253 + should.be_ok(result) 254 + 255 + case result { 256 + Ok(lexicon) -> { 257 + case lexicon.defs.main { 258 + option.Some(types.RecordDef(type_: _, key: _, properties: props)) -> { 259 + case list.find(props, fn(p) { p.0 == "embed" }) { 260 + Ok(#(_, prop)) -> { 261 + should.equal(prop.type_, "union") 262 + should.equal( 263 + prop.refs, 264 + option.Some([ 265 + "app.bsky.embed.images", 266 + "app.bsky.embed.video", 267 + ]), 268 + ) 269 + } 270 + Error(_) -> should.fail() 271 + } 272 + } 273 + option.None -> should.fail() 274 + } 275 + } 276 + Error(_) -> should.fail() 277 + } 278 + } 279 + 280 + // Test parsing lexicon WITHOUT main definition (like com.atproto.label.defs) 281 + // These lexicons only have defs.others with object types 282 + pub fn parse_lexicon_without_main_test() { 283 + let json = 284 + "{ 285 + \"lexicon\": 1, 286 + \"id\": \"com.atproto.label.defs\", 287 + \"defs\": { 288 + \"selfLabels\": { 289 + \"type\": \"object\", 290 + \"required\": [\"values\"], 291 + \"properties\": { 292 + \"values\": { 293 + \"type\": \"array\", 294 + \"items\": {\"ref\": \"#selfLabel\", \"type\": \"ref\"}, 295 + \"maxLength\": 10 296 + } 297 + } 298 + }, 299 + \"selfLabel\": { 300 + \"type\": \"object\", 301 + \"required\": [\"val\"], 302 + \"properties\": { 303 + \"val\": {\"type\": \"string\", \"maxLength\": 128} 304 + } 305 + } 306 + } 307 + }" 308 + 309 + let result = lexicon_parser.parse_lexicon(json) 310 + should.be_ok(result) 311 + 312 + case result { 313 + Ok(lexicon) -> { 314 + should.equal(lexicon.id, "com.atproto.label.defs") 315 + // Main should be None since there's no main definition 316 + should.be_none(lexicon.defs.main) 317 + // Others should have both selfLabels and selfLabel 318 + let others_count = dict.size(lexicon.defs.others) 319 + should.equal(others_count, 2) 320 + } 321 + Error(_) -> should.fail() 322 + } 323 + } 324 + 226 325 // Test parsing lexicon with array property containing string items 227 326 pub fn parse_array_with_string_items_test() { 228 327 let json = ··· 276 375 Error(_) -> should.fail() 277 376 } 278 377 } 378 + 379 + // Test parsing lexicon with main object type AND others defs (like app.bsky.richtext.facet) 380 + pub fn parse_object_main_with_others_test() { 381 + let json = 382 + "{ 383 + \"lexicon\": 1, 384 + \"id\": \"app.bsky.richtext.facet\", 385 + \"defs\": { 386 + \"main\": { 387 + \"type\": \"object\", 388 + \"required\": [\"index\", \"features\"], 389 + \"properties\": { 390 + \"index\": {\"ref\": \"#byteSlice\", \"type\": \"ref\"}, 391 + \"features\": { 392 + \"type\": \"array\", 393 + \"items\": { 394 + \"refs\": [\"#mention\", \"#link\", \"#tag\"], 395 + \"type\": \"union\" 396 + } 397 + } 398 + } 399 + }, 400 + \"mention\": { 401 + \"type\": \"object\", 402 + \"required\": [\"did\"], 403 + \"properties\": { 404 + \"did\": {\"type\": \"string\", \"format\": \"did\"} 405 + } 406 + }, 407 + \"link\": { 408 + \"type\": \"object\", 409 + \"required\": [\"uri\"], 410 + \"properties\": { 411 + \"uri\": {\"type\": \"string\", \"format\": \"uri\"} 412 + } 413 + }, 414 + \"tag\": { 415 + \"type\": \"object\", 416 + \"required\": [\"tag\"], 417 + \"properties\": { 418 + \"tag\": {\"type\": \"string\"} 419 + } 420 + }, 421 + \"byteSlice\": { 422 + \"type\": \"object\", 423 + \"required\": [\"byteStart\", \"byteEnd\"], 424 + \"properties\": { 425 + \"byteStart\": {\"type\": \"integer\"}, 426 + \"byteEnd\": {\"type\": \"integer\"} 427 + } 428 + } 429 + } 430 + }" 431 + 432 + let result = lexicon_parser.parse_lexicon(json) 433 + should.be_ok(result) 434 + 435 + case result { 436 + Ok(lexicon) -> { 437 + should.equal(lexicon.id, "app.bsky.richtext.facet") 438 + 439 + // Main should exist and be object type 440 + case lexicon.defs.main { 441 + option.Some(types.RecordDef(type_: "object", key: _, properties: _)) -> { 442 + // Good - main is object type 443 + Nil 444 + } 445 + _ -> should.fail() 446 + } 447 + 448 + // Others should have mention, link, tag, byteSlice 449 + let others_count = dict.size(lexicon.defs.others) 450 + should.equal(others_count, 4) 451 + 452 + // Verify each one exists 453 + should.be_true(dict.has_key(lexicon.defs.others, "mention")) 454 + should.be_true(dict.has_key(lexicon.defs.others, "link")) 455 + should.be_true(dict.has_key(lexicon.defs.others, "tag")) 456 + should.be_true(dict.has_key(lexicon.defs.others, "byteSlice")) 457 + } 458 + Error(_) -> should.fail() 459 + } 460 + }
+190
lexicon_graphql/test/local_ref_test.gleam
··· 1 + /// Tests for local ref resolution in schema builder 2 + /// 3 + /// Verifies that refs like "#replyRef" resolve to their object types 4 + import gleam/dict 5 + import gleam/option.{None, Some} 6 + import gleam/string 7 + import gleeunit/should 8 + import lexicon_graphql/schema/builder 9 + import lexicon_graphql/types 10 + import swell/introspection 11 + import swell/sdl 12 + 13 + /// Test that a local ref (starting with #) resolves to its object type 14 + pub fn local_ref_resolves_to_object_type_test() { 15 + // Create a lexicon with a record that has a local ref field 16 + let lexicon = 17 + types.Lexicon( 18 + id: "app.bsky.feed.post", 19 + defs: types.Defs( 20 + main: Some( 21 + types.RecordDef(type_: "record", key: None, properties: [ 22 + #( 23 + "text", 24 + types.Property( 25 + type_: "string", 26 + required: True, 27 + format: None, 28 + ref: None, 29 + refs: None, 30 + items: None, 31 + ), 32 + ), 33 + #( 34 + "reply", 35 + types.Property( 36 + type_: "ref", 37 + required: False, 38 + format: None, 39 + ref: Some("#replyRef"), 40 + // Local ref! 41 + refs: None, 42 + items: None, 43 + ), 44 + ), 45 + ]), 46 + ), 47 + others: dict.from_list([ 48 + #( 49 + "replyRef", 50 + types.Object( 51 + types.ObjectDef(type_: "object", required_fields: [], properties: [ 52 + #( 53 + "root", 54 + types.Property( 55 + type_: "string", 56 + required: True, 57 + format: None, 58 + ref: None, 59 + refs: None, 60 + items: None, 61 + ), 62 + ), 63 + #( 64 + "parent", 65 + types.Property( 66 + type_: "string", 67 + required: True, 68 + format: None, 69 + ref: None, 70 + refs: None, 71 + items: None, 72 + ), 73 + ), 74 + ]), 75 + ), 76 + ), 77 + ]), 78 + ), 79 + ) 80 + 81 + let result = builder.build_schema([lexicon]) 82 + should.be_ok(result) 83 + 84 + case result { 85 + Ok(schema_val) -> { 86 + let all_types = introspection.get_all_schema_types(schema_val) 87 + let serialized = sdl.print_types(all_types) 88 + 89 + // The replyRef object type should exist 90 + string.contains(serialized, "type AppBskyFeedPostReplyRef") 91 + |> should.be_true 92 + 93 + // The reply field should be AppBskyFeedPostReplyRef, NOT String 94 + string.contains(serialized, "reply: AppBskyFeedPostReplyRef") 95 + |> should.be_true 96 + } 97 + Error(_) -> should.fail() 98 + } 99 + } 100 + 101 + /// Test that local refs in arrays also resolve correctly 102 + pub fn local_ref_in_array_resolves_to_object_type_test() { 103 + let lexicon = 104 + types.Lexicon( 105 + id: "app.bsky.feed.post", 106 + defs: types.Defs( 107 + main: Some( 108 + types.RecordDef(type_: "record", key: None, properties: [ 109 + #( 110 + "text", 111 + types.Property( 112 + type_: "string", 113 + required: True, 114 + format: None, 115 + ref: None, 116 + refs: None, 117 + items: None, 118 + ), 119 + ), 120 + #( 121 + "entities", 122 + types.Property( 123 + type_: "array", 124 + required: False, 125 + format: None, 126 + ref: None, 127 + refs: None, 128 + items: Some(types.ArrayItems( 129 + type_: "ref", 130 + ref: Some("#entity"), 131 + // Local ref in array! 132 + refs: None, 133 + )), 134 + ), 135 + ), 136 + ]), 137 + ), 138 + others: dict.from_list([ 139 + #( 140 + "entity", 141 + types.Object( 142 + types.ObjectDef(type_: "object", required_fields: [], properties: [ 143 + #( 144 + "type", 145 + types.Property( 146 + type_: "string", 147 + required: True, 148 + format: None, 149 + ref: None, 150 + refs: None, 151 + items: None, 152 + ), 153 + ), 154 + #( 155 + "value", 156 + types.Property( 157 + type_: "string", 158 + required: True, 159 + format: None, 160 + ref: None, 161 + refs: None, 162 + items: None, 163 + ), 164 + ), 165 + ]), 166 + ), 167 + ), 168 + ]), 169 + ), 170 + ) 171 + 172 + let result = builder.build_schema([lexicon]) 173 + should.be_ok(result) 174 + 175 + case result { 176 + Ok(schema_val) -> { 177 + let all_types = introspection.get_all_schema_types(schema_val) 178 + let serialized = sdl.print_types(all_types) 179 + 180 + // The entity object type should exist 181 + string.contains(serialized, "type AppBskyFeedPostEntity") 182 + |> should.be_true 183 + 184 + // The entities field should be [AppBskyFeedPostEntity!], NOT [String!] 185 + string.contains(serialized, "entities: [AppBskyFeedPostEntity!]") 186 + |> should.be_true 187 + } 188 + Error(_) -> should.fail() 189 + } 190 + }
+155
lexicon_graphql/test/object_build_order_test.gleam
··· 1 + /// Tests for object type build order 2 + /// 3 + /// Verifies that #fragment refs are built before main object types 4 + /// so that unions in main types can resolve their member types 5 + import gleam/dict 6 + import gleam/list 7 + import gleam/option.{None, Some} 8 + import gleeunit/should 9 + import lexicon_graphql/internal/graphql/object_builder 10 + import lexicon_graphql/internal/lexicon/registry 11 + import lexicon_graphql/types 12 + import swell/schema 13 + 14 + /// Test that main object types with union array fields resolve correctly 15 + /// when the union members are #fragment refs in the same lexicon 16 + pub fn union_array_refs_resolve_to_object_types_test() { 17 + // Create a lexicon like app.bsky.richtext.facet with: 18 + // - main object type with features: array of union [#mention, #link] 19 + // - others: mention, link object definitions 20 + let lexicon = 21 + types.Lexicon( 22 + id: "app.bsky.richtext.facet", 23 + defs: types.Defs( 24 + main: Some( 25 + types.RecordDef(type_: "object", key: None, properties: [ 26 + #( 27 + "features", 28 + types.Property( 29 + type_: "array", 30 + required: True, 31 + format: None, 32 + ref: None, 33 + refs: None, 34 + items: Some(types.ArrayItems( 35 + type_: "union", 36 + ref: None, 37 + refs: Some(["#mention", "#link"]), 38 + )), 39 + ), 40 + ), 41 + ]), 42 + ), 43 + others: dict.from_list([ 44 + #( 45 + "mention", 46 + types.Object( 47 + types.ObjectDef( 48 + type_: "object", 49 + required_fields: ["did"], 50 + properties: [ 51 + #( 52 + "did", 53 + types.Property( 54 + type_: "string", 55 + required: True, 56 + format: Some("did"), 57 + ref: None, 58 + refs: None, 59 + items: None, 60 + ), 61 + ), 62 + ], 63 + ), 64 + ), 65 + ), 66 + #( 67 + "link", 68 + types.Object( 69 + types.ObjectDef( 70 + type_: "object", 71 + required_fields: ["uri"], 72 + properties: [ 73 + #( 74 + "uri", 75 + types.Property( 76 + type_: "string", 77 + required: True, 78 + format: Some("uri"), 79 + ref: None, 80 + refs: None, 81 + items: None, 82 + ), 83 + ), 84 + ], 85 + ), 86 + ), 87 + ), 88 + ]), 89 + ), 90 + ) 91 + 92 + // Build registry and object types 93 + let reg = registry.from_lexicons([lexicon]) 94 + let object_types = object_builder.build_all_object_types(reg) 95 + 96 + // The main type should exist 97 + let main_type_result = dict.get(object_types, "app.bsky.richtext.facet") 98 + should.be_ok(main_type_result) 99 + 100 + // The #fragment types should exist 101 + let mention_type_result = 102 + dict.get(object_types, "app.bsky.richtext.facet#mention") 103 + should.be_ok(mention_type_result) 104 + 105 + let link_type_result = dict.get(object_types, "app.bsky.richtext.facet#link") 106 + should.be_ok(link_type_result) 107 + 108 + // Check the main type's features field is a union, not String 109 + case main_type_result { 110 + Ok(main_type) -> { 111 + let type_name = schema.type_name(main_type) 112 + should.equal(type_name, "AppBskyRichtextFacet") 113 + 114 + // Get the fields and find "features" 115 + let fields = schema.get_fields(main_type) 116 + let features_field = 117 + list.find(fields, fn(f) { schema.field_name(f) == "features" }) 118 + 119 + case features_field { 120 + Ok(field) -> { 121 + // The field type should be a list containing a union, not [String!] 122 + let field_type = schema.field_type(field) 123 + let inner_type_name = get_list_inner_type_name(field_type) 124 + 125 + // Should NOT be "String" - should be a union type name 126 + should.be_false(inner_type_name == "String") 127 + 128 + // With new naming convention: {ParentType}{CapitalizedField}Union 129 + // Should be "AppBskyRichtextFacetFeaturesUnion" 130 + should.equal(inner_type_name, "AppBskyRichtextFacetFeaturesUnion") 131 + } 132 + Error(_) -> should.fail() 133 + } 134 + } 135 + Error(_) -> should.fail() 136 + } 137 + } 138 + 139 + /// Helper to get the inner type name from a list type 140 + /// NonNull[List[NonNull[Union]]] -> "UnionName" 141 + fn get_list_inner_type_name(t: schema.Type) -> String { 142 + // Unwrap NonNull -> List -> NonNull -> actual type 143 + case schema.inner_type(t) { 144 + Some(list_type) -> 145 + case schema.inner_type(list_type) { 146 + Some(inner_nonnull) -> 147 + case schema.inner_type(inner_nonnull) { 148 + Some(union_type) -> schema.type_name(union_type) 149 + None -> schema.type_name(inner_nonnull) 150 + } 151 + None -> schema.type_name(list_type) 152 + } 153 + None -> schema.type_name(t) 154 + } 155 + }
+94
lexicon_graphql/test/object_type_lexicon_test.gleam
··· 1 + /// Tests for object-type lexicon GraphQL generation 2 + import gleam/dict 3 + import gleam/option 4 + import gleam/string 5 + import gleeunit/should 6 + import lexicon_graphql/schema/builder 7 + import lexicon_graphql/types 8 + import swell/introspection 9 + import swell/sdl 10 + 11 + pub fn object_type_lexicon_referenced_from_record_test() { 12 + // Create an object-type lexicon (like app.bsky.embed.images) 13 + let embed_images_lexicon = 14 + types.Lexicon( 15 + id: "app.bsky.embed.images", 16 + defs: types.Defs( 17 + main: option.Some( 18 + types.RecordDef(type_: "object", key: option.None, properties: [ 19 + #( 20 + "images", 21 + types.Property( 22 + type_: "array", 23 + required: True, 24 + format: option.None, 25 + ref: option.None, 26 + refs: option.None, 27 + items: option.Some(types.ArrayItems( 28 + type_: "string", 29 + ref: option.None, 30 + refs: option.None, 31 + )), 32 + ), 33 + ), 34 + ]), 35 + ), 36 + others: dict.new(), 37 + ), 38 + ) 39 + 40 + // Create a record that references the object type 41 + let post_lexicon = 42 + types.Lexicon( 43 + id: "app.bsky.feed.post", 44 + defs: types.Defs( 45 + main: option.Some( 46 + types.RecordDef(type_: "record", key: option.Some("tid"), properties: [ 47 + #( 48 + "text", 49 + types.Property( 50 + type_: "string", 51 + required: True, 52 + format: option.None, 53 + ref: option.None, 54 + refs: option.None, 55 + items: option.None, 56 + ), 57 + ), 58 + #( 59 + "embed", 60 + types.Property( 61 + type_: "ref", 62 + required: False, 63 + format: option.None, 64 + ref: option.Some("app.bsky.embed.images"), 65 + refs: option.None, 66 + items: option.None, 67 + ), 68 + ), 69 + ]), 70 + ), 71 + others: dict.new(), 72 + ), 73 + ) 74 + 75 + let result = builder.build_schema([embed_images_lexicon, post_lexicon]) 76 + should.be_ok(result) 77 + 78 + case result { 79 + Ok(schema_val) -> { 80 + // The type should exist in the schema 81 + let all_types = introspection.get_all_schema_types(schema_val) 82 + let serialized = sdl.print_types(all_types) 83 + 84 + // AppBskyEmbedImages should be in the types (referenced from Post.embed) 85 + string.contains(serialized, "AppBskyEmbedImages") 86 + |> should.be_true 87 + 88 + // Post type should have embed field 89 + string.contains(serialized, "embed: AppBskyEmbedImages") 90 + |> should.be_true 91 + } 92 + Error(_) -> should.fail() 93 + } 94 + }
+6
lexicon_graphql/test/ref_resolver_test.gleam
··· 23 23 required: True, 24 24 format: None, 25 25 ref: None, 26 + refs: None, 26 27 items: None, 27 28 ), 28 29 ), ··· 33 34 required: False, 34 35 format: None, 35 36 ref: None, 37 + refs: None, 36 38 items: None, 37 39 ), 38 40 ), ··· 63 65 required: True, 64 66 format: None, 65 67 ref: None, 68 + refs: None, 66 69 items: None, 67 70 ), 68 71 ), ··· 73 76 required: False, 74 77 format: None, 75 78 ref: None, 79 + refs: None, 76 80 items: None, 77 81 ), 78 82 ), ··· 95 99 required: True, 96 100 format: None, 97 101 ref: None, 102 + refs: None, 98 103 items: None, 99 104 ), 100 105 ), ··· 129 134 required: True, 130 135 format: None, 131 136 ref: None, 137 + refs: None, 132 138 items: None, 133 139 ), 134 140 ),
+34
lexicon_graphql/test/registry_test.gleam
··· 1 + /// Tests for Lexicon Registry utilities 2 + import gleeunit/should 3 + import lexicon_graphql/internal/lexicon/registry 4 + 5 + pub fn lexicon_id_from_ref_with_fragment_test() { 6 + // Ref with fragment should return the lexicon ID part 7 + registry.lexicon_id_from_ref("app.bsky.embed.images#image") 8 + |> should.equal("app.bsky.embed.images") 9 + } 10 + 11 + pub fn lexicon_id_from_ref_without_fragment_test() { 12 + // Ref without fragment should return as-is 13 + registry.lexicon_id_from_ref("app.bsky.embed.images") 14 + |> should.equal("app.bsky.embed.images") 15 + } 16 + 17 + pub fn lexicon_id_from_ref_with_defs_fragment_test() { 18 + // Standard defs ref pattern 19 + registry.lexicon_id_from_ref("social.grain.defs#aspectRatio") 20 + |> should.equal("social.grain.defs") 21 + } 22 + 23 + pub fn parse_ref_with_fragment_test() { 24 + // Parse ref should split into lexicon ID and def name 25 + registry.parse_ref("app.bsky.embed.images#image") 26 + |> should.be_some 27 + |> should.equal(#("app.bsky.embed.images", "image")) 28 + } 29 + 30 + pub fn parse_ref_without_fragment_test() { 31 + // Ref without fragment should return None 32 + registry.parse_ref("app.bsky.embed.images") 33 + |> should.be_none 34 + }
+9
lexicon_graphql/test/reverse_join_test.gleam
··· 53 53 required: True, 54 54 format: None, 55 55 ref: None, 56 + refs: None, 56 57 items: None, 57 58 ), 58 59 ), ··· 76 77 required: True, 77 78 format: Some("at-uri"), 78 79 ref: None, 80 + refs: None, 79 81 items: None, 80 82 ), 81 83 ), ··· 114 116 required: True, 115 117 format: None, 116 118 ref: None, 119 + refs: None, 117 120 items: None, 118 121 ), 119 122 ), ··· 137 140 required: False, 138 141 format: None, 139 142 ref: Some("com.atproto.repo.strongRef"), 143 + refs: None, 140 144 items: None, 141 145 ), 142 146 ), ··· 174 178 required: True, 175 179 format: None, 176 180 ref: None, 181 + refs: None, 177 182 items: None, 178 183 ), 179 184 ), ··· 197 202 required: True, 198 203 format: Some("at-uri"), 199 204 ref: None, 205 + refs: None, 200 206 items: None, 201 207 ), 202 208 ), ··· 220 226 required: True, 221 227 format: Some("at-uri"), 222 228 ref: None, 229 + refs: None, 223 230 items: None, 224 231 ), 225 232 ), ··· 263 270 required: True, 264 271 format: None, 265 272 ref: None, 273 + refs: None, 266 274 items: None, 267 275 ), 268 276 ), ··· 286 294 required: True, 287 295 format: None, 288 296 ref: None, 297 + refs: None, 289 298 items: None, 290 299 ), 291 300 ),
+92
lexicon_graphql/test/schema_builder_test.gleam
··· 5 5 import birdie 6 6 import gleam/dict 7 7 import gleam/option.{None, Some} 8 + import gleam/string as gleam_string 8 9 import gleeunit/should 9 10 import lexicon_graphql/schema/builder as schema_builder 10 11 import lexicon_graphql/types ··· 28 29 required: False, 29 30 format: None, 30 31 ref: None, 32 + refs: None, 31 33 items: None, 32 34 ), 33 35 ), ··· 38 40 required: True, 39 41 format: None, 40 42 ref: None, 43 + refs: None, 41 44 items: None, 42 45 ), 43 46 ), ··· 72 75 required: False, 73 76 format: None, 74 77 ref: None, 78 + refs: None, 75 79 items: None, 76 80 ), 77 81 ), ··· 94 98 required: False, 95 99 format: None, 96 100 ref: None, 101 + refs: None, 97 102 items: None, 98 103 ), 99 104 ), ··· 131 136 required: True, 132 137 format: None, 133 138 ref: None, 139 + refs: None, 134 140 items: None, 135 141 ), 136 142 ), ··· 141 147 required: False, 142 148 format: None, 143 149 ref: None, 150 + refs: None, 144 151 items: None, 145 152 ), 146 153 ), ··· 171 178 should.be_error(result) 172 179 } 173 180 181 + // Test that union properties generate proper union types 182 + pub fn union_property_generates_union_type_test() { 183 + // Create embed object type lexicon 184 + let embed_images_lexicon = 185 + types.Lexicon( 186 + id: "app.bsky.embed.images", 187 + defs: types.Defs( 188 + main: Some( 189 + types.RecordDef(type_: "object", key: None, properties: [ 190 + #( 191 + "images", 192 + types.Property( 193 + type_: "array", 194 + required: True, 195 + format: None, 196 + ref: None, 197 + refs: None, 198 + items: Some(types.ArrayItems( 199 + type_: "string", 200 + ref: None, 201 + refs: None, 202 + )), 203 + ), 204 + ), 205 + ]), 206 + ), 207 + others: dict.new(), 208 + ), 209 + ) 210 + 211 + // Create record with union property 212 + let post_lexicon = 213 + types.Lexicon( 214 + id: "app.bsky.feed.post", 215 + defs: types.Defs( 216 + main: Some( 217 + types.RecordDef(type_: "record", key: Some("tid"), properties: [ 218 + #( 219 + "text", 220 + types.Property( 221 + type_: "string", 222 + required: True, 223 + format: None, 224 + ref: None, 225 + refs: None, 226 + items: None, 227 + ), 228 + ), 229 + #( 230 + "embed", 231 + types.Property( 232 + type_: "union", 233 + required: False, 234 + format: None, 235 + ref: None, 236 + refs: Some(["app.bsky.embed.images"]), 237 + items: None, 238 + ), 239 + ), 240 + ]), 241 + ), 242 + others: dict.new(), 243 + ), 244 + ) 245 + 246 + let result = schema_builder.build_schema([embed_images_lexicon, post_lexicon]) 247 + should.be_ok(result) 248 + 249 + case result { 250 + Ok(schema_val) -> { 251 + // The union type should exist 252 + let all_types = introspection.get_all_schema_types(schema_val) 253 + let serialized = sdl.print_types(all_types) 254 + 255 + // Union type should be in the schema 256 + serialized 257 + |> gleam_string.contains("union AppBskyFeedPostEmbed") 258 + |> should.be_true 259 + } 260 + Error(_) -> should.fail() 261 + } 262 + } 263 + 174 264 // Comprehensive test showing ALL generated types 175 265 pub fn simple_schema_all_types_snapshot_test() { 176 266 let lexicon = ··· 186 276 required: False, 187 277 format: None, 188 278 ref: None, 279 + refs: None, 189 280 items: None, 190 281 ), 191 282 ), ··· 196 287 required: True, 197 288 format: None, 198 289 ref: None, 290 + refs: None, 199 291 items: None, 200 292 ), 201 293 ),
+25
lexicon_graphql/test/sorting_test.gleam
··· 70 70 required: False, 71 71 format: None, 72 72 ref: None, 73 + refs: None, 73 74 items: None, 74 75 ), 75 76 ), ··· 80 81 required: False, 81 82 format: None, 82 83 ref: None, 84 + refs: None, 83 85 items: None, 84 86 ), 85 87 ), ··· 115 117 required: False, 116 118 format: None, 117 119 ref: None, 120 + refs: None, 118 121 items: None, 119 122 ), 120 123 ), ··· 125 128 required: False, 126 129 format: None, 127 130 ref: None, 131 + refs: None, 128 132 items: None, 129 133 ), 130 134 ), ··· 147 151 required: False, 148 152 format: None, 149 153 ref: None, 154 + refs: None, 150 155 items: None, 151 156 ), 152 157 ), ··· 157 162 required: False, 158 163 format: None, 159 164 ref: None, 165 + refs: None, 160 166 items: None, 161 167 ), 162 168 ), ··· 192 198 required: False, 193 199 format: None, 194 200 ref: None, 201 + refs: None, 195 202 items: None, 196 203 ), 197 204 ), ··· 237 244 required: False, 238 245 format: None, 239 246 ref: None, 247 + refs: None, 240 248 items: None, 241 249 ), 242 250 ), ··· 280 288 required: False, 281 289 format: None, 282 290 ref: None, 291 + refs: None, 283 292 items: None, 284 293 ), 285 294 ), ··· 290 299 required: False, 291 300 format: None, 292 301 ref: None, 302 + refs: None, 293 303 items: None, 294 304 ), 295 305 ), ··· 326 336 required: False, 327 337 format: None, 328 338 ref: None, 339 + refs: None, 329 340 items: None, 330 341 ), 331 342 ), ··· 336 347 required: False, 337 348 format: None, 338 349 ref: None, 350 + refs: None, 339 351 items: None, 340 352 ), 341 353 ), ··· 346 358 required: False, 347 359 format: None, 348 360 ref: None, 361 + refs: None, 349 362 items: None, 350 363 ), 351 364 ), ··· 356 369 required: False, 357 370 format: None, 358 371 ref: None, 372 + refs: None, 359 373 items: None, 360 374 ), 361 375 ), ··· 366 380 required: False, 367 381 format: Some("at-uri"), 368 382 ref: None, 383 + refs: None, 369 384 items: None, 370 385 ), 371 386 ), ··· 377 392 required: False, 378 393 format: None, 379 394 ref: None, 395 + refs: None, 380 396 items: None, 381 397 ), 382 398 ), ··· 387 403 required: False, 388 404 format: None, 389 405 ref: Some("app.bsky.test.object"), 406 + refs: None, 390 407 items: None, 391 408 ), 392 409 ), ··· 451 468 required: False, 452 469 format: None, 453 470 ref: None, 471 + refs: None, 454 472 items: None, 455 473 ), 456 474 ), ··· 461 479 required: False, 462 480 format: None, 463 481 ref: None, 482 + refs: None, 464 483 items: None, 465 484 ), 466 485 ), ··· 471 490 required: False, 472 491 format: None, 473 492 ref: None, 493 + refs: None, 474 494 items: None, 475 495 ), 476 496 ), ··· 481 501 required: False, 482 502 format: None, 483 503 ref: None, 504 + refs: None, 484 505 items: None, 485 506 ), 486 507 ), ··· 491 512 required: False, 492 513 format: Some("datetime"), 493 514 ref: None, 515 + refs: None, 494 516 items: None, 495 517 ), 496 518 ), ··· 501 523 required: False, 502 524 format: Some("at-uri"), 503 525 ref: None, 526 + refs: None, 504 527 items: None, 505 528 ), 506 529 ), ··· 512 535 required: False, 513 536 format: None, 514 537 ref: None, 538 + refs: None, 515 539 items: None, 516 540 ), 517 541 ), ··· 522 546 required: False, 523 547 format: None, 524 548 ref: Some("com.atproto.repo.strongRef"), 549 + refs: None, 525 550 items: None, 526 551 ), 527 552 ),
+6 -2
lexicon_graphql/test/subscription_schema_test.gleam
··· 35 35 required: True, 36 36 format: None, 37 37 ref: None, 38 + refs: None, 38 39 items: None, 39 40 ), 40 41 ), ··· 88 89 required: True, 89 90 format: None, 90 91 ref: None, 92 + refs: None, 91 93 items: None, 92 94 ), 93 95 ), ··· 162 164 required: True, 163 165 format: None, 164 166 ref: None, 167 + refs: None, 165 168 items: None, 166 169 ), 167 170 ), ··· 229 232 required: True, 230 233 format: None, 231 234 ref: None, 235 + refs: None, 232 236 items: None, 233 237 ), 234 238 ), ··· 306 310 defs: types.Defs( 307 311 main: Some( 308 312 types.RecordDef(type_: "record", key: None, properties: [ 309 - #("text", types.Property("string", True, None, None, None)), 313 + #("text", types.Property("string", True, None, None, None, None)), 310 314 ]), 311 315 ), 312 316 others: dict.new(), ··· 319 323 defs: types.Defs( 320 324 main: Some( 321 325 types.RecordDef(type_: "record", key: None, properties: [ 322 - #("subject", types.Property("string", True, None, None, None)), 326 + #("subject", types.Property("string", True, None, None, None, None)), 323 327 ]), 324 328 ), 325 329 others: dict.new(),
+55 -3
lexicon_graphql/test/type_mapper_test.gleam
··· 78 78 pub fn map_array_of_strings_test() { 79 79 let items = 80 80 types.ArrayItems(type_: "string", ref: option.None, refs: option.None) 81 - let result = type_mapper.map_array_type(option.Some(items), dict.new()) 81 + let result = 82 + type_mapper.map_array_type( 83 + option.Some(items), 84 + dict.new(), 85 + "TestType", 86 + "testField", 87 + ) 82 88 83 89 // Should be [String!] - list of non-null strings 84 90 schema.type_name(result) ··· 88 94 pub fn map_array_of_integers_test() { 89 95 let items = 90 96 types.ArrayItems(type_: "integer", ref: option.None, refs: option.None) 91 - let result = type_mapper.map_array_type(option.Some(items), dict.new()) 97 + let result = 98 + type_mapper.map_array_type( 99 + option.Some(items), 100 + dict.new(), 101 + "TestType", 102 + "testField", 103 + ) 92 104 93 105 schema.type_name(result) 94 106 |> should.equal("[Int!]") 95 107 } 96 108 97 109 pub fn map_array_without_items_test() { 98 - let result = type_mapper.map_array_type(option.None, dict.new()) 110 + let result = 111 + type_mapper.map_array_type(option.None, dict.new(), "TestType", "testField") 99 112 100 113 // Should fallback to [String!] 101 114 schema.type_name(result) ··· 123 136 required: True, 124 137 format: option.None, 125 138 ref: option.None, 139 + refs: option.None, 126 140 items: option.Some(items), 127 141 ) 128 142 ··· 139 153 required: True, 140 154 format: option.None, 141 155 ref: option.None, 156 + refs: option.None, 142 157 items: option.None, 143 158 ) 144 159 ··· 147 162 result 148 163 |> should.equal(schema.string_type()) 149 164 } 165 + 166 + pub fn map_property_union_type_test() { 167 + // Create object types that the union will reference 168 + let images_type = schema.object_type("AppBskyEmbedImages", "Images embed", []) 169 + let video_type = schema.object_type("AppBskyEmbedVideo", "Video embed", []) 170 + 171 + let object_types = 172 + dict.new() 173 + |> dict.insert("app.bsky.embed.images", images_type) 174 + |> dict.insert("app.bsky.embed.video", video_type) 175 + 176 + let property = 177 + types.Property( 178 + type_: "union", 179 + required: False, 180 + format: option.None, 181 + ref: option.None, 182 + refs: option.Some([ 183 + "app.bsky.embed.images", 184 + "app.bsky.embed.video", 185 + ]), 186 + items: option.None, 187 + ) 188 + 189 + let result = 190 + type_mapper.map_property_type_with_context( 191 + property, 192 + object_types, 193 + "AppBskyFeedPost", 194 + "embed", 195 + "app.bsky.feed.post", 196 + ) 197 + 198 + // Should be a union type, not String 199 + schema.type_name(result) 200 + |> should.equal("AppBskyFeedPostEmbed") 201 + }
+213
lexicon_graphql/test/union_integration_test.gleam
··· 1 + /// Integration tests for union type support with real Bluesky lexicons 2 + import gleam/dict 3 + import gleam/option 4 + import gleam/string 5 + import gleeunit/should 6 + import lexicon_graphql/internal/lexicon/parser as lexicon_parser 7 + import lexicon_graphql/schema/builder 8 + import lexicon_graphql/types 9 + import swell/introspection 10 + import swell/sdl 11 + 12 + /// Test parsing real Bluesky post lexicon with embed union field 13 + pub fn bluesky_post_embed_union_test() { 14 + // This is a simplified version of the real app.bsky.feed.post lexicon 15 + let post_json = 16 + "{ 17 + \"lexicon\": 1, 18 + \"id\": \"app.bsky.feed.post\", 19 + \"defs\": { 20 + \"main\": { 21 + \"type\": \"record\", 22 + \"record\": { 23 + \"type\": \"object\", 24 + \"required\": [\"text\", \"createdAt\"], 25 + \"properties\": { 26 + \"text\": {\"type\": \"string\"}, 27 + \"createdAt\": {\"type\": \"string\", \"format\": \"datetime\"}, 28 + \"embed\": { 29 + \"type\": \"union\", 30 + \"refs\": [ 31 + \"app.bsky.embed.images\", 32 + \"app.bsky.embed.video\", 33 + \"app.bsky.embed.external\" 34 + ] 35 + } 36 + } 37 + } 38 + } 39 + } 40 + }" 41 + 42 + let result = lexicon_parser.parse_lexicon(post_json) 43 + should.be_ok(result) 44 + 45 + case result { 46 + Ok(lexicon) -> { 47 + // Verify embed field has refs parsed 48 + case lexicon.defs.main { 49 + option.Some(record_def) -> { 50 + case 51 + record_def.properties 52 + |> find_property("embed") 53 + { 54 + option.Some(embed_prop) -> { 55 + should.equal(embed_prop.type_, "union") 56 + should.equal( 57 + embed_prop.refs, 58 + option.Some([ 59 + "app.bsky.embed.images", 60 + "app.bsky.embed.video", 61 + "app.bsky.embed.external", 62 + ]), 63 + ) 64 + } 65 + option.None -> should.fail() 66 + } 67 + } 68 + option.None -> should.fail() 69 + } 70 + } 71 + Error(_) -> should.fail() 72 + } 73 + } 74 + 75 + /// Test full pipeline: build schema with multiple embed types and union field 76 + /// This tests the complete flow from lexicon types to GraphQL schema 77 + pub fn full_pipeline_union_type_test() { 78 + // Create embed type lexicons as object types 79 + let embed_images_lexicon = 80 + types.Lexicon( 81 + id: "app.bsky.embed.images", 82 + defs: types.Defs( 83 + main: option.Some( 84 + types.RecordDef(type_: "object", key: option.None, properties: [ 85 + #( 86 + "images", 87 + types.Property( 88 + type_: "array", 89 + required: True, 90 + format: option.None, 91 + ref: option.None, 92 + refs: option.None, 93 + items: option.Some(types.ArrayItems( 94 + type_: "string", 95 + ref: option.None, 96 + refs: option.None, 97 + )), 98 + ), 99 + ), 100 + ]), 101 + ), 102 + others: dict.new(), 103 + ), 104 + ) 105 + 106 + let embed_video_lexicon = 107 + types.Lexicon( 108 + id: "app.bsky.embed.video", 109 + defs: types.Defs( 110 + main: option.Some( 111 + types.RecordDef(type_: "object", key: option.None, properties: [ 112 + #( 113 + "video", 114 + types.Property( 115 + type_: "blob", 116 + required: True, 117 + format: option.None, 118 + ref: option.None, 119 + refs: option.None, 120 + items: option.None, 121 + ), 122 + ), 123 + ]), 124 + ), 125 + others: dict.new(), 126 + ), 127 + ) 128 + 129 + // Create post lexicon with union property referencing both embed types 130 + let post_lexicon = 131 + types.Lexicon( 132 + id: "app.bsky.feed.post", 133 + defs: types.Defs( 134 + main: option.Some( 135 + types.RecordDef(type_: "record", key: option.Some("tid"), properties: [ 136 + #( 137 + "text", 138 + types.Property( 139 + type_: "string", 140 + required: True, 141 + format: option.None, 142 + ref: option.None, 143 + refs: option.None, 144 + items: option.None, 145 + ), 146 + ), 147 + #( 148 + "embed", 149 + types.Property( 150 + type_: "union", 151 + required: False, 152 + format: option.None, 153 + ref: option.None, 154 + refs: option.Some([ 155 + "app.bsky.embed.images", 156 + "app.bsky.embed.video", 157 + ]), 158 + items: option.None, 159 + ), 160 + ), 161 + ]), 162 + ), 163 + others: dict.new(), 164 + ), 165 + ) 166 + 167 + // Build schema with all lexicons 168 + let result = 169 + builder.build_schema([ 170 + embed_images_lexicon, 171 + embed_video_lexicon, 172 + post_lexicon, 173 + ]) 174 + should.be_ok(result) 175 + 176 + case result { 177 + Ok(schema_val) -> { 178 + let all_types = introspection.get_all_schema_types(schema_val) 179 + let serialized = sdl.print_types(all_types) 180 + 181 + // Verify union type was generated with correct name 182 + string.contains(serialized, "union AppBskyFeedPostEmbed") 183 + |> should.be_true 184 + 185 + // Verify both embed object types exist 186 + string.contains(serialized, "AppBskyEmbedImages") 187 + |> should.be_true 188 + 189 + string.contains(serialized, "AppBskyEmbedVideo") 190 + |> should.be_true 191 + 192 + // Verify post type exists and has embed field 193 + string.contains(serialized, "type AppBskyFeedPost") 194 + |> should.be_true 195 + } 196 + Error(_) -> should.fail() 197 + } 198 + } 199 + 200 + /// Helper to find a property by name 201 + fn find_property( 202 + properties: List(#(String, a)), 203 + name: String, 204 + ) -> option.Option(a) { 205 + case properties { 206 + [] -> option.None 207 + [#(prop_name, prop), ..rest] -> 208 + case prop_name == name { 209 + True -> option.Some(prop) 210 + False -> find_property(rest, name) 211 + } 212 + } 213 + }
+122
lexicon_graphql/test/union_resolver_test.gleam
··· 1 + import gleam/dict 2 + import gleam/list 3 + import gleam/option.{None, Some} 4 + import gleeunit/should 5 + import lexicon_graphql/internal/graphql/object_builder 6 + import lexicon_graphql/internal/lexicon/registry 7 + import lexicon_graphql/types 8 + import swell/schema 9 + import swell/value 10 + 11 + /// Test the union type resolver with actual $type data 12 + pub fn union_type_resolver_works_test() { 13 + // Create a lexicon like app.bsky.richtext.facet 14 + let lexicon = 15 + types.Lexicon( 16 + id: "app.bsky.richtext.facet", 17 + defs: types.Defs( 18 + main: Some( 19 + types.RecordDef(type_: "object", key: None, properties: [ 20 + #( 21 + "features", 22 + types.Property( 23 + type_: "array", 24 + required: True, 25 + format: None, 26 + ref: None, 27 + refs: None, 28 + items: Some(types.ArrayItems( 29 + type_: "union", 30 + ref: None, 31 + refs: Some(["#mention", "#link"]), 32 + )), 33 + ), 34 + ), 35 + ]), 36 + ), 37 + others: dict.from_list([ 38 + #( 39 + "mention", 40 + types.Object( 41 + types.ObjectDef( 42 + type_: "object", 43 + required_fields: ["did"], 44 + properties: [ 45 + #( 46 + "did", 47 + types.Property( 48 + type_: "string", 49 + required: True, 50 + format: Some("did"), 51 + ref: None, 52 + refs: None, 53 + items: None, 54 + ), 55 + ), 56 + ], 57 + ), 58 + ), 59 + ), 60 + #( 61 + "link", 62 + types.Object( 63 + types.ObjectDef( 64 + type_: "object", 65 + required_fields: ["uri"], 66 + properties: [ 67 + #( 68 + "uri", 69 + types.Property( 70 + type_: "string", 71 + required: True, 72 + format: Some("uri"), 73 + ref: None, 74 + refs: None, 75 + items: None, 76 + ), 77 + ), 78 + ], 79 + ), 80 + ), 81 + ), 82 + ]), 83 + ), 84 + ) 85 + 86 + // Build registry and object types 87 + let reg = registry.from_lexicons([lexicon]) 88 + let object_types = object_builder.build_all_object_types(reg) 89 + 90 + // Get the main facet type 91 + let assert Ok(main_type) = dict.get(object_types, "app.bsky.richtext.facet") 92 + 93 + // Get the features field 94 + let fields = schema.get_fields(main_type) 95 + let assert Ok(features_field) = 96 + list.find(fields, fn(f) { schema.field_name(f) == "features" }) 97 + 98 + // Get the field type and unwrap to the union 99 + // Field type is NonNull[List[NonNull[Union]]] 100 + let field_type = schema.field_type(features_field) 101 + 102 + // Unwrap NonNull -> List -> NonNull -> Union 103 + let assert Some(list_type) = schema.inner_type(field_type) 104 + let assert Some(inner_nonnull) = schema.inner_type(list_type) 105 + let assert Some(union_type) = schema.inner_type(inner_nonnull) 106 + 107 + // Verify it's a union 108 + should.be_true(schema.is_union(union_type)) 109 + 110 + // Now test the type resolver with mock data that has $type 111 + let mention_data = 112 + value.Object([ 113 + #("$type", value.String("app.bsky.richtext.facet#mention")), 114 + #("did", value.String("did:plc:abc123")), 115 + ]) 116 + 117 + let ctx = schema.context(Some(mention_data)) 118 + 119 + // Try to resolve the union type 120 + let assert Ok(resolved_type) = schema.resolve_union_type(union_type, ctx) 121 + should.equal(schema.type_name(resolved_type), "AppBskyRichtextFacetMention") 122 + }
+15
lexicon_graphql/test/where_schema_test.gleam
··· 225 225 required: False, 226 226 format: None, 227 227 ref: None, 228 + refs: None, 228 229 items: None, 229 230 ), 230 231 ), ··· 235 236 required: False, 236 237 format: None, 237 238 ref: None, 239 + refs: None, 238 240 items: None, 239 241 ), 240 242 ), ··· 245 247 required: False, 246 248 format: None, 247 249 ref: None, 250 + refs: None, 248 251 items: None, 249 252 ), 250 253 ), ··· 255 258 required: False, 256 259 format: None, 257 260 ref: None, 261 + refs: None, 258 262 items: None, 259 263 ), 260 264 ), ··· 265 269 required: False, 266 270 format: Some("at-uri"), 267 271 ref: None, 272 + refs: None, 268 273 items: None, 269 274 ), 270 275 ), ··· 276 281 required: False, 277 282 format: None, 278 283 ref: None, 284 + refs: None, 279 285 items: None, 280 286 ), 281 287 ), ··· 286 292 required: False, 287 293 format: None, 288 294 ref: Some("app.bsky.test.object"), 295 + refs: None, 289 296 items: None, 290 297 ), 291 298 ), ··· 352 359 required: False, 353 360 format: None, 354 361 ref: None, 362 + refs: None, 355 363 items: None, 356 364 ), 357 365 ), ··· 362 370 required: False, 363 371 format: None, 364 372 ref: None, 373 + refs: None, 365 374 items: None, 366 375 ), 367 376 ), ··· 372 381 required: False, 373 382 format: None, 374 383 ref: None, 384 + refs: None, 375 385 items: None, 376 386 ), 377 387 ), ··· 382 392 required: False, 383 393 format: None, 384 394 ref: None, 395 + refs: None, 385 396 items: None, 386 397 ), 387 398 ), ··· 392 403 required: False, 393 404 format: Some("datetime"), 394 405 ref: None, 406 + refs: None, 395 407 items: None, 396 408 ), 397 409 ), ··· 402 414 required: False, 403 415 format: Some("at-uri"), 404 416 ref: None, 417 + refs: None, 405 418 items: None, 406 419 ), 407 420 ), ··· 413 426 required: False, 414 427 format: None, 415 428 ref: None, 429 + refs: None, 416 430 items: None, 417 431 ), 418 432 ), ··· 423 437 required: False, 424 438 format: None, 425 439 ref: Some("com.atproto.repo.strongRef"), 440 + refs: None, 426 441 items: None, 427 442 ), 428 443 ),
+2 -2
server/manifest.toml
··· 21 21 { name = "gleam_json", version = "3.1.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_json", source = "hex", outer_checksum = "44FDAA8847BE8FC48CA7A1C089706BD54BADCC4C45B237A992EDDF9F2CDB2836" }, 22 22 { name = "gleam_otp", version = "1.2.0", build_tools = ["gleam"], requirements = ["gleam_erlang", "gleam_stdlib"], otp_app = "gleam_otp", source = "hex", outer_checksum = "BA6A294E295E428EC1562DC1C11EA7530DCB981E8359134BEABC8493B7B2258E" }, 23 23 { name = "gleam_regexp", version = "1.1.1", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_regexp", source = "hex", outer_checksum = "9C215C6CA84A5B35BB934A9B61A9A306EC743153BE2B0425A0D032E477B062A9" }, 24 - { name = "gleam_stdlib", version = "0.67.0", build_tools = ["gleam"], requirements = [], otp_app = "gleam_stdlib", source = "hex", outer_checksum = "6368313DB35963DC02F677A513BB0D95D58A34ED0A9436C8116820BF94BE3511" }, 24 + { name = "gleam_stdlib", version = "0.67.1", build_tools = ["gleam"], requirements = [], otp_app = "gleam_stdlib", source = "hex", outer_checksum = "6CE3E4189A8B8EC2F73AB61A2FBDE49F159D6C9C61C49E3B3082E439F260D3D0" }, 25 25 { name = "gleam_time", version = "1.6.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_time", source = "hex", outer_checksum = "0DF3834D20193F0A38D0EB21F0A78D48F2EC276C285969131B86DF8D4EF9E762" }, 26 26 { name = "gleam_yielder", version = "1.1.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_yielder", source = "hex", outer_checksum = "8E4E4ECFA7982859F430C57F549200C7749823C106759F4A19A78AEA6687717A" }, 27 27 { name = "gleeunit", version = "1.9.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleeunit", source = "hex", outer_checksum = "DA9553CE58B67924B3C631F96FE3370C49EB6D6DC6B384EC4862CC4AAA718F3C" }, ··· 46 46 { name = "simplifile", version = "2.3.1", build_tools = ["gleam"], requirements = ["filepath", "gleam_stdlib"], otp_app = "simplifile", source = "hex", outer_checksum = "957E0E5B75927659F1D2A1B7B75D7B9BA96FAA8D0C53EA71C4AD9CD0C6B848F6" }, 47 47 { name = "sqlight", version = "1.0.3", build_tools = ["gleam"], requirements = ["esqlite", "gleam_stdlib"], otp_app = "sqlight", source = "hex", outer_checksum = "CADD79663C9B61D4BAC960A47CC2D42CA8F48EAF5804DBEB79977287750F4B16" }, 48 48 { name = "ssl_verify_fun", version = "1.1.7", build_tools = ["mix", "rebar3", "make"], requirements = [], otp_app = "ssl_verify_fun", source = "hex", outer_checksum = "FE4C190E8F37401D30167C8C405EDA19469F34577987C76DDE613E838BBC67F8" }, 49 - { name = "swell", version = "2.1.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "swell", source = "hex", outer_checksum = "0C58555BC0A77BEBB47945B0AAB808A4888EAC1E6BCE9E1AC01C6AC73FFAE50F" }, 49 + { name = "swell", version = "2.1.1", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "swell", source = "hex", outer_checksum = "64CFC91A6851487D07E85B02D8405F5EA06EAA74C6742915F5A78531D6237F16" }, 50 50 { name = "telemetry", version = "1.3.0", build_tools = ["rebar3"], requirements = [], otp_app = "telemetry", source = "hex", outer_checksum = "7015FC8919DBE63764F4B4B87A95B7C0996BD539E0D499BE6EC9D7F3875B79E6" }, 51 51 { name = "thoas", version = "1.2.1", build_tools = ["rebar3"], requirements = [], otp_app = "thoas", source = "hex", outer_checksum = "E38697EDFFD6E91BD12CEA41B155115282630075C2A727E7A6B2947F5408B86A" }, 52 52 { name = "unicode_util_compat", version = "0.7.1", build_tools = ["rebar3"], requirements = [], otp_app = "unicode_util_compat", source = "hex", outer_checksum = "B3A917854CE3AE233619744AD1E0102E05673136776FB2FA76234F3E03B23642" },