Auto-indexing service and GraphQL API for AT Protocol Records quickslice.slices.network/
atproto gleam graphql

feat: add viewer state fields for authenticated user relationships

Viewer state fields show the authenticated user's relationship to records:
- `viewer{Collection}Via{Field}` for AT-URI refs (favorites, likes)
- `viewer{Collection}Via{Field}` for DID refs (follows)

Server extracts viewer DID from auth token automatically. Clients no
longer need to pass viewer_did as a variable.

Includes documentation, tests, and WebSocket subscription support.

+3000 -26
+8
CHANGELOG.md
··· 5 5 The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), 6 6 and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). 7 7 8 + ## v0.19.0 9 + 10 + ### Added 11 + - Add viewer state fields that show the authenticated user's relationship to records 12 + - `viewer{Collection}Via{Field}` fields for AT-URI references (favorites, likes) 13 + - `viewer{Collection}Via{Field}` fields for DID references (follows) 14 + - Server extracts viewer DID from auth token automatically 15 + 8 16 ## v0.18.1 9 17 10 18 ### Fixed
+316
dev-docs/plans/2025-12-27-viewer-did-from-auth.md
··· 1 + # Viewer DID from Auth Token Implementation Plan 2 + 3 + > **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. 4 + 5 + **Goal:** Extract the viewer's DID from the authenticated user's token instead of requiring clients to pass it as a GraphQL variable. 6 + 7 + **Architecture:** Currently, viewer state fields require clients to pass `viewer_did` as a GraphQL variable, which is redundant (the DID is already in the auth token) and insecure (clients could spoof it). We'll modify the query execution to verify the auth token, extract the DID, and inject it into context.data where resolvers can access it. 8 + 9 + **Tech Stack:** Gleam, swell GraphQL library, atproto_auth module 10 + 11 + --- 12 + 13 + ### Task 1: Update Schema to Extract Viewer DID from Auth Token 14 + 15 + **Files:** 16 + - Modify: `server/src/graphql/lexicon/schema.gleam:1-30` (imports) 17 + - Modify: `server/src/graphql/lexicon/schema.gleam:176-188` (execute_query_with_db context creation) 18 + 19 + **Step 1: Add atproto_auth import** 20 + 21 + In `server/src/graphql/lexicon/schema.gleam`, add the import after line 4: 22 + 23 + ```gleam 24 + import atproto_auth 25 + ``` 26 + 27 + **Step 2: Modify context creation to extract viewer DID** 28 + 29 + Replace the context creation block (lines 176-188) with: 30 + 31 + ```gleam 32 + // Create context with viewer DID if authenticated 33 + let ctx_data = case auth_token { 34 + Ok(token) -> { 35 + // Verify token and extract user DID 36 + case atproto_auth.verify_token(db, token) { 37 + Ok(user_info) -> { 38 + // Add both auth_token (for mutations) and viewer_did (for viewer fields) 39 + option.Some(value.Object([ 40 + #("auth_token", value.String(token)), 41 + #("viewer_did", value.String(user_info.did)), 42 + ])) 43 + } 44 + Error(_) -> { 45 + // Token invalid/expired - still allow query but without viewer context 46 + option.None 47 + } 48 + } 49 + } 50 + Error(_) -> option.None 51 + } 52 + 53 + // Convert json variables to Dict(String, value.Value) 54 + let variables_dict = json_string_to_variables_dict(variables_json_str) 55 + 56 + let ctx = schema.context_with_variables(ctx_data, variables_dict) 57 + ``` 58 + 59 + **Step 3: Build and verify no compile errors** 60 + 61 + Run: `cd /Users/chadmiller/code/quickslice/server && gleam build` 62 + Expected: Compiles successfully 63 + 64 + **Step 4: Commit** 65 + 66 + ```bash 67 + git add server/src/graphql/lexicon/schema.gleam 68 + git commit -m "feat(schema): extract viewer_did from auth token into context" 69 + ``` 70 + 71 + --- 72 + 73 + ### Task 2: Update Viewer State Resolver to Read from Context Data 74 + 75 + **Files:** 76 + - Modify: `lexicon_graphql/src/lexicon_graphql/schema/database.gleam:1112-1119` (get_viewer_did_from_context function) 77 + 78 + **Step 1: Update get_viewer_did_from_context to read from ctx.data** 79 + 80 + Replace the `get_viewer_did_from_context` function (around lines 1112-1119) with: 81 + 82 + ```gleam 83 + /// Extract viewer DID from context data 84 + /// The viewer DID is set by the server after verifying the auth token 85 + fn get_viewer_did_from_context(ctx: schema.Context) -> Result(String, Nil) { 86 + case ctx.data { 87 + option.Some(value.Object(fields)) -> { 88 + case list.key_find(fields, "viewer_did") { 89 + Ok(value.String(did)) -> Ok(did) 90 + _ -> Error(Nil) 91 + } 92 + } 93 + _ -> Error(Nil) 94 + } 95 + } 96 + ``` 97 + 98 + **Step 2: Build and verify no compile errors** 99 + 100 + Run: `cd /Users/chadmiller/code/quickslice/lexicon_graphql && gleam build` 101 + Expected: Compiles successfully 102 + 103 + **Step 3: Build server to verify integration** 104 + 105 + Run: `cd /Users/chadmiller/code/quickslice/server && gleam build` 106 + Expected: Compiles successfully 107 + 108 + **Step 4: Commit** 109 + 110 + ```bash 111 + git add lexicon_graphql/src/lexicon_graphql/schema/database.gleam 112 + git commit -m "feat(viewer-state): read viewer_did from context data instead of variables" 113 + ``` 114 + 115 + --- 116 + 117 + ### Task 3: Add Test Helper for Mock Auth Tokens 118 + 119 + **Files:** 120 + - Modify: `server/test/test_helpers.gleam` 121 + 122 + Insert a test token into `oauth_access_tokens` table that maps to the test viewer DID. 123 + 124 + **Step 1: Check oauth_access_tokens table structure** 125 + 126 + Review `server/src/database/repositories/oauth_access_tokens.gleam` to understand the required fields. 127 + 128 + **Step 2: Add helper to create oauth_access_tokens table and insert test token** 129 + 130 + In `server/test/test_helpers.gleam`, add: 131 + 132 + ```gleam 133 + /// Create oauth_access_tokens table for testing 134 + pub fn create_oauth_access_tokens_table(exec: Executor) -> Result(Nil, sqlight.Error) { 135 + let sql = " 136 + CREATE TABLE IF NOT EXISTS oauth_access_tokens ( 137 + token TEXT PRIMARY KEY, 138 + user_id TEXT NOT NULL, 139 + client_id TEXT NOT NULL, 140 + scope TEXT NOT NULL, 141 + expires_at INTEGER NOT NULL, 142 + created_at INTEGER NOT NULL, 143 + revoked INTEGER NOT NULL DEFAULT 0 144 + ) 145 + " 146 + case sqlight.query(sql, exec, [], decode.dynamic) { 147 + Ok(_) -> Ok(Nil) 148 + Error(e) -> Error(e) 149 + } 150 + } 151 + 152 + /// Insert a test token that maps to a DID 153 + pub fn insert_test_token( 154 + exec: Executor, 155 + token: String, 156 + did: String, 157 + ) -> Result(Nil, sqlight.Error) { 158 + let far_future = 9999999999 // Won't expire 159 + let sql = "INSERT INTO oauth_access_tokens (token, user_id, client_id, scope, expires_at, created_at, revoked) VALUES (?, ?, 'test-client', 'atproto', ?, ?, 0)" 160 + 161 + case sqlight.query(sql, exec, [sqlight.text(token), sqlight.text(did), sqlight.int(far_future), sqlight.int(0)], decode.dynamic) { 162 + Ok(_) -> Ok(Nil) 163 + Error(e) -> Error(e) 164 + } 165 + } 166 + ``` 167 + 168 + **Step 3: Build and verify** 169 + 170 + Run: `cd /Users/chadmiller/code/quickslice/server && gleam build` 171 + Expected: Compiles successfully 172 + 173 + **Step 4: Commit** 174 + 175 + ```bash 176 + git add server/test/test_helpers.gleam 177 + git commit -m "test: add helper to create mock auth tokens for testing" 178 + ``` 179 + 180 + --- 181 + 182 + ### Task 4: Update Integration Tests to Use Mock Auth Token 183 + 184 + **Files:** 185 + - Modify: `server/test/viewer_state_integration_test.gleam` 186 + 187 + **Step 1: Update test setup to create token table and insert test token** 188 + 189 + Add to each test's setup: 190 + 191 + ```gleam 192 + // Setup auth token for viewer 193 + let assert Ok(_) = test_helpers.create_oauth_access_tokens_table(exec) 194 + let assert Ok(_) = test_helpers.insert_test_token(exec, "test-viewer-token", "did:plc:viewer") 195 + ``` 196 + 197 + **Step 2: Update requests to use Authorization header instead of variables** 198 + 199 + Change from: 200 + ```gleam 201 + let query = 202 + json.object([ 203 + #("query", json.string("{ ... }")), 204 + #("variables", json.object([#("viewer_did", json.string("did:plc:viewer"))])), 205 + ]) 206 + |> json.to_string 207 + 208 + let request = 209 + simulate.request(http.Post, "/graphql") 210 + |> simulate.string_body(query) 211 + |> simulate.header("content-type", "application/json") 212 + ``` 213 + 214 + To: 215 + ```gleam 216 + let query = 217 + json.object([ 218 + #("query", json.string("{ ... }")), 219 + ]) 220 + |> json.to_string 221 + 222 + let request = 223 + simulate.request(http.Post, "/graphql") 224 + |> simulate.string_body(query) 225 + |> simulate.header("content-type", "application/json") 226 + |> simulate.header("authorization", "Bearer test-viewer-token") 227 + ``` 228 + 229 + **Step 3: Run viewer state tests** 230 + 231 + Run: `cd /Users/chadmiller/code/quickslice/server && gleam test -- --filter viewer_state` 232 + Expected: All viewer_state tests pass 233 + 234 + **Step 4: Run all tests** 235 + 236 + Run: `cd /Users/chadmiller/code/quickslice/server && gleam test` 237 + Expected: All tests pass 238 + 239 + **Step 5: Commit** 240 + 241 + ```bash 242 + git add server/test/viewer_state_integration_test.gleam 243 + git commit -m "test(viewer-state): use mock auth tokens instead of variables" 244 + ``` 245 + 246 + --- 247 + 248 + ### Task 5: Update WebSocket Handler for Subscriptions 249 + 250 + **Files:** 251 + - Check: `server/src/handlers/graphql_ws.gleam` - verify it passes auth context correctly 252 + 253 + **Step 1: Review WebSocket handler** 254 + 255 + Read `server/src/handlers/graphql_ws.gleam` to verify: 256 + 1. Auth token is extracted from WebSocket connection 257 + 2. Context is created similarly to HTTP handler 258 + 259 + **Step 2: Update if needed** 260 + 261 + If the WebSocket handler creates context differently, update it to also verify token and inject viewer_did. 262 + 263 + **Step 3: Commit if changes made** 264 + 265 + ```bash 266 + git add server/src/handlers/graphql_ws.gleam 267 + git commit -m "feat(ws): extract viewer_did from auth for subscription context" 268 + ``` 269 + 270 + --- 271 + 272 + ### Task 5: Final Verification 273 + 274 + **Step 1: Run all server tests** 275 + 276 + Run: `cd /Users/chadmiller/code/quickslice/server && gleam test` 277 + Expected: All tests pass 278 + 279 + **Step 2: Run lexicon_graphql tests** 280 + 281 + Run: `cd /Users/chadmiller/code/quickslice/lexicon_graphql && gleam test` 282 + Expected: All tests pass 283 + 284 + **Step 3: Manual verification (optional)** 285 + 286 + Start the server and test with a real authenticated request: 287 + - Query with auth token should auto-populate viewer fields 288 + - Query without auth token should return null for viewer fields 289 + 290 + --- 291 + 292 + ## Summary of Changes 293 + 294 + | File | Change | 295 + |------|--------| 296 + | `server/src/graphql/lexicon/schema.gleam` | Import atproto_auth, verify token, inject viewer_did into context.data | 297 + | `lexicon_graphql/src/lexicon_graphql/schema/database.gleam` | Read viewer_did from context.data with variable fallback | 298 + | `server/src/handlers/graphql_ws.gleam` | (If needed) Same auth extraction for WebSocket | 299 + 300 + ## API Behavior After Change 301 + 302 + **Before:** Clients must pass `viewer_did` variable: 303 + ```json 304 + { 305 + "query": "{ gallery { viewerFavorite { uri } } }", 306 + "variables": { "viewer_did": "did:plc:abc123" } 307 + } 308 + ``` 309 + 310 + **After:** Viewer DID extracted automatically from auth token: 311 + ```json 312 + { 313 + "query": "{ gallery { viewerFavorite { uri } } }" 314 + } 315 + ``` 316 + (With `Authorization: Bearer <token>` header)
+1084
dev-docs/plans/2025-12-27-viewer-state-fields.md
··· 1 + # Viewer State Fields Implementation Plan 2 + 3 + > **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. 4 + 5 + **Goal:** Auto-generate viewer fields that show a logged-in user's relationship to content (e.g., "did I like this post?", "do I follow this author?"). 6 + 7 + **Architecture:** For each reverse join field, generate a corresponding viewer field that returns the viewer's single record (if any) instead of a connection. Two patterns: AT-URI subjects (existing reverse joins) and DID subjects (follows). 8 + 9 + **Tech Stack:** Gleam, lexicon_graphql schema builder 10 + 11 + --- 12 + 13 + ## Task 1: Extend CollectionMeta to Track DID-Typed Subject Fields 14 + 15 + **Files:** 16 + - Modify: `lexicon_graphql/src/lexicon_graphql/internal/lexicon/collection_meta.gleam` 17 + - Test: `lexicon_graphql/test/collection_meta_test.gleam` 18 + 19 + **Step 1: Write the failing test** 20 + 21 + Create test file `lexicon_graphql/test/collection_meta_test.gleam`: 22 + 23 + ```gleam 24 + import gleeunit/should 25 + import lexicon_graphql/internal/lexicon/collection_meta 26 + import lexicon_graphql/types 27 + import gleam/option.{None, Some} 28 + 29 + pub fn detects_did_subject_field_test() { 30 + // Follow lexicon has subject with format: "did" 31 + let lexicon = 32 + types.Lexicon( 33 + lexicon: 1, 34 + id: "social.grain.graph.follow", 35 + revision: None, 36 + description: None, 37 + defs: types.Defs( 38 + main: Some(types.MainDef( 39 + type_: "record", 40 + description: None, 41 + key: Some("tid"), 42 + properties: [ 43 + #( 44 + "subject", 45 + types.Property( 46 + type_: "string", 47 + required: True, 48 + format: Some("did"), 49 + ref: None, 50 + refs: None, 51 + items: None, 52 + ), 53 + ), 54 + ], 55 + required: None, 56 + ref: None, 57 + refs: None, 58 + )), 59 + other: [], 60 + ), 61 + ) 62 + 63 + let meta = collection_meta.extract_metadata(lexicon) 64 + 65 + // Should detect subject as a DID-typed field 66 + meta.did_subject_fields 67 + |> should.equal(["subject"]) 68 + } 69 + 70 + pub fn detects_at_uri_subject_field_test() { 71 + // Favorite lexicon has subject with format: "at-uri" 72 + let lexicon = 73 + types.Lexicon( 74 + lexicon: 1, 75 + id: "social.grain.favorite", 76 + revision: None, 77 + description: None, 78 + defs: types.Defs( 79 + main: Some(types.MainDef( 80 + type_: "record", 81 + description: None, 82 + key: Some("tid"), 83 + properties: [ 84 + #( 85 + "subject", 86 + types.Property( 87 + type_: "string", 88 + required: True, 89 + format: Some("at-uri"), 90 + ref: None, 91 + refs: None, 92 + items: None, 93 + ), 94 + ), 95 + ], 96 + required: None, 97 + ref: None, 98 + refs: None, 99 + )), 100 + other: [], 101 + ), 102 + ) 103 + 104 + let meta = collection_meta.extract_metadata(lexicon) 105 + 106 + // Should detect subject as at-uri (existing behavior) 107 + meta.reverse_join_fields 108 + |> should.equal(["subject"]) 109 + 110 + // Should NOT detect as DID field 111 + meta.did_subject_fields 112 + |> should.equal([]) 113 + } 114 + ``` 115 + 116 + **Step 2: Run test to verify it fails** 117 + 118 + Run: `cd lexicon_graphql && gleam test -- --filter=detects_did_subject_field` 119 + Expected: FAIL - `did_subject_fields` field doesn't exist on CollectionMeta 120 + 121 + **Step 3: Add did_subject_fields to CollectionMeta type** 122 + 123 + In `collection_meta.gleam`, update the type definition: 124 + 125 + ```gleam 126 + pub type CollectionMeta { 127 + CollectionMeta( 128 + nsid: String, 129 + type_name: String, 130 + key_type: String, 131 + has_unique_did: Bool, 132 + forward_join_fields: List(ForwardJoinField), 133 + reverse_join_fields: List(String), 134 + /// Fields with format: "did" that can be used for DID-based viewer joins 135 + did_subject_fields: List(String), 136 + ) 137 + } 138 + ``` 139 + 140 + **Step 4: Update scan_properties to detect DID fields** 141 + 142 + ```gleam 143 + fn scan_properties( 144 + properties: List(#(String, types.Property)), 145 + ) -> #(List(ForwardJoinField), List(String), List(String)) { 146 + list.fold(properties, #([], [], []), fn(acc, prop) { 147 + let #(forward_fields, reverse_fields, did_fields) = acc 148 + let #(name, property) = prop 149 + 150 + // Check if this is a forward join field 151 + let new_forward = case is_forward_join_field(property) { 152 + Some(field_type) -> [field_type(name), ..forward_fields] 153 + None -> forward_fields 154 + } 155 + 156 + // Check if this is a reverse join field (at-uri format) 157 + let new_reverse = case is_reverse_join_field(property) { 158 + True -> [name, ..reverse_fields] 159 + False -> reverse_fields 160 + } 161 + 162 + // Check if this is a DID-typed field 163 + let new_did = case is_did_field(property) { 164 + True -> [name, ..did_fields] 165 + False -> did_fields 166 + } 167 + 168 + #(new_forward, new_reverse, new_did) 169 + }) 170 + } 171 + 172 + /// Check if a property has DID format 173 + fn is_did_field(property: types.Property) -> Bool { 174 + case property.format { 175 + Some(fmt) if fmt == "did" -> True 176 + _ -> False 177 + } 178 + } 179 + ``` 180 + 181 + **Step 5: Update extract_metadata to use new scan_properties** 182 + 183 + ```gleam 184 + pub fn extract_metadata(lexicon: types.Lexicon) -> CollectionMeta { 185 + let type_name = nsid.to_type_name(lexicon.id) 186 + 187 + case lexicon.defs.main { 188 + Some(main_def) -> { 189 + let key_type = case main_def.key { 190 + Some(k) -> k 191 + None -> "tid" 192 + } 193 + let has_unique_did = key_type == "literal:self" 194 + 195 + let #(forward_fields, reverse_fields, did_fields) = 196 + scan_properties(main_def.properties) 197 + 198 + CollectionMeta( 199 + nsid: lexicon.id, 200 + type_name: type_name, 201 + key_type: key_type, 202 + has_unique_did: has_unique_did, 203 + forward_join_fields: forward_fields, 204 + reverse_join_fields: reverse_fields, 205 + did_subject_fields: did_fields, 206 + ) 207 + } 208 + None -> { 209 + CollectionMeta( 210 + nsid: lexicon.id, 211 + type_name: type_name, 212 + key_type: "tid", 213 + has_unique_did: False, 214 + forward_join_fields: [], 215 + reverse_join_fields: [], 216 + did_subject_fields: [], 217 + ) 218 + } 219 + } 220 + } 221 + ``` 222 + 223 + **Step 6: Run test to verify it passes** 224 + 225 + Run: `cd lexicon_graphql && gleam test -- --filter=detects_` 226 + Expected: PASS 227 + 228 + **Step 7: Fix any compilation errors in other files** 229 + 230 + Update any files that construct `CollectionMeta` to include the new field. 231 + 232 + **Step 8: Commit** 233 + 234 + ```bash 235 + git add lexicon_graphql/src/lexicon_graphql/internal/lexicon/collection_meta.gleam lexicon_graphql/test/collection_meta_test.gleam 236 + git commit -m "$(cat <<'EOF' 237 + feat(collection_meta): track DID-typed subject fields 238 + 239 + Add did_subject_fields to CollectionMeta to enable viewer field generation 240 + for collections where the subject field contains a DID (e.g., follows). 241 + EOF 242 + )" 243 + ``` 244 + 245 + --- 246 + 247 + ## Task 2: Add Viewer State Fetcher Type 248 + 249 + **Files:** 250 + - Modify: `lexicon_graphql/src/lexicon_graphql/query/dataloader.gleam` 251 + - Test: `lexicon_graphql/test/dataloader_test.gleam` 252 + 253 + **Step 1: Write the failing test** 254 + 255 + Add to `dataloader_test.gleam`: 256 + 257 + ```gleam 258 + pub fn batch_fetch_viewer_state_test() { 259 + // Mock fetcher that returns records for specific viewer+subject combinations 260 + let fetcher = fn( 261 + viewer_did: String, 262 + collection: String, 263 + reference_field: String, 264 + parent_keys: List(String), 265 + ) -> Result(Dict(String, value.Value), String) { 266 + // Simulate: viewer "did:plc:viewer" liked gallery1 and gallery3 267 + case viewer_did, collection { 268 + "did:plc:viewer", "social.grain.favorite" -> { 269 + let results = 270 + dict.new() 271 + |> dict.insert( 272 + "at://did:plc:author/social.grain.gallery/gallery1", 273 + value.Object([ 274 + #("uri", value.String("at://did:plc:viewer/social.grain.favorite/abc")), 275 + #("subject", value.String("at://did:plc:author/social.grain.gallery/gallery1")), 276 + ]), 277 + ) 278 + |> dict.insert( 279 + "at://did:plc:author/social.grain.gallery/gallery3", 280 + value.Object([ 281 + #("uri", value.String("at://did:plc:viewer/social.grain.favorite/def")), 282 + #("subject", value.String("at://did:plc:author/social.grain.gallery/gallery3")), 283 + ]), 284 + ) 285 + Ok(results) 286 + } 287 + _, _ -> Ok(dict.new()) 288 + } 289 + } 290 + 291 + let parent_uris = [ 292 + "at://did:plc:author/social.grain.gallery/gallery1", 293 + "at://did:plc:author/social.grain.gallery/gallery2", 294 + "at://did:plc:author/social.grain.gallery/gallery3", 295 + ] 296 + 297 + let result = 298 + dataloader.batch_fetch_viewer_state( 299 + "did:plc:viewer", 300 + "social.grain.favorite", 301 + "subject", 302 + parent_uris, 303 + fetcher, 304 + ) 305 + 306 + result |> should.be_ok 307 + 308 + let records = result |> result.unwrap(dict.new()) 309 + 310 + // gallery1 should have a record 311 + dict.get(records, "at://did:plc:author/social.grain.gallery/gallery1") 312 + |> should.be_ok 313 + 314 + // gallery2 should NOT have a record (viewer didn't like it) 315 + dict.get(records, "at://did:plc:author/social.grain.gallery/gallery2") 316 + |> should.be_error 317 + 318 + // gallery3 should have a record 319 + dict.get(records, "at://did:plc:author/social.grain.gallery/gallery3") 320 + |> should.be_ok 321 + } 322 + ``` 323 + 324 + **Step 2: Run test to verify it fails** 325 + 326 + Run: `cd lexicon_graphql && gleam test -- --filter=batch_fetch_viewer_state` 327 + Expected: FAIL - function doesn't exist 328 + 329 + **Step 3: Add ViewerStateFetcher type and batch function** 330 + 331 + In `dataloader.gleam`: 332 + 333 + ```gleam 334 + /// Fetcher for viewer state queries 335 + /// Takes viewer DID, collection, reference field, and list of parent keys 336 + /// Returns a dict mapping parent keys to the viewer's record (if any) 337 + pub type ViewerStateFetcher = 338 + fn(String, String, String, List(String)) -> 339 + Result(Dict(String, value.Value), String) 340 + 341 + /// Batch fetch viewer state for multiple parent records 342 + /// 343 + /// Given a viewer DID and list of parent URIs/DIDs, finds the viewer's 344 + /// record for each parent (if any). 345 + /// Returns a Dict mapping parent keys to the viewer's record. 346 + pub fn batch_fetch_viewer_state( 347 + viewer_did: String, 348 + collection: String, 349 + reference_field: String, 350 + parent_keys: List(String), 351 + fetcher: ViewerStateFetcher, 352 + ) -> Result(Dict(String, value.Value), String) { 353 + fetcher(viewer_did, collection, reference_field, parent_keys) 354 + } 355 + ``` 356 + 357 + **Step 4: Run test to verify it passes** 358 + 359 + Run: `cd lexicon_graphql && gleam test -- --filter=batch_fetch_viewer_state` 360 + Expected: PASS 361 + 362 + **Step 5: Commit** 363 + 364 + ```bash 365 + git add lexicon_graphql/src/lexicon_graphql/query/dataloader.gleam lexicon_graphql/test/dataloader_test.gleam 366 + git commit -m "$(cat <<'EOF' 367 + feat(dataloader): add viewer state batch fetcher 368 + 369 + Add ViewerStateFetcher type and batch_fetch_viewer_state function 370 + for efficiently fetching viewer relationships across multiple records. 371 + EOF 372 + )" 373 + ``` 374 + 375 + --- 376 + 377 + ## Task 3: Generate AT-URI Viewer Fields 378 + 379 + **Files:** 380 + - Modify: `lexicon_graphql/src/lexicon_graphql/schema/database.gleam` 381 + - Test: `lexicon_graphql/test/viewer_state_test.gleam` 382 + 383 + **Step 1: Write the failing test** 384 + 385 + Create `lexicon_graphql/test/viewer_state_test.gleam`: 386 + 387 + ```gleam 388 + import gleeunit/should 389 + import gleam/dict 390 + import gleam/list 391 + import gleam/option.{None, Some} 392 + import gleam/string 393 + import lexicon_graphql/schema/database as db_schema_builder 394 + import lexicon_graphql/types 395 + import swell/schema 396 + 397 + // Test lexicons 398 + fn gallery_lexicon() -> types.Lexicon { 399 + types.Lexicon( 400 + lexicon: 1, 401 + id: "social.grain.gallery", 402 + revision: None, 403 + description: None, 404 + defs: types.Defs( 405 + main: Some(types.MainDef( 406 + type_: "record", 407 + description: None, 408 + key: Some("tid"), 409 + properties: [ 410 + #("title", types.Property( 411 + type_: "string", 412 + required: True, 413 + format: None, 414 + ref: None, 415 + refs: None, 416 + items: None, 417 + )), 418 + ], 419 + required: None, 420 + ref: None, 421 + refs: None, 422 + )), 423 + other: [], 424 + ), 425 + ) 426 + } 427 + 428 + fn favorite_lexicon() -> types.Lexicon { 429 + types.Lexicon( 430 + lexicon: 1, 431 + id: "social.grain.favorite", 432 + revision: None, 433 + description: None, 434 + defs: types.Defs( 435 + main: Some(types.MainDef( 436 + type_: "record", 437 + description: None, 438 + key: Some("tid"), 439 + properties: [ 440 + #("subject", types.Property( 441 + type_: "string", 442 + required: True, 443 + format: Some("at-uri"), 444 + ref: None, 445 + refs: None, 446 + items: None, 447 + )), 448 + ], 449 + required: None, 450 + ref: None, 451 + refs: None, 452 + )), 453 + other: [], 454 + ), 455 + ) 456 + } 457 + 458 + pub fn generates_viewer_field_for_reverse_join_test() { 459 + let lexicons = [gallery_lexicon(), favorite_lexicon()] 460 + 461 + let schema_result = db_schema_builder.build_schema_with_fetchers( 462 + lexicons, 463 + stub_fetcher(), 464 + None, 465 + None, 466 + None, 467 + None, 468 + None, 469 + None, 470 + None, 471 + ) 472 + 473 + schema_result |> should.be_ok 474 + let schema = schema_result |> result.unwrap(panic) 475 + 476 + // Get the SocialGrainGallery type 477 + let gallery_type = schema.types 478 + |> dict.get("SocialGrainGallery") 479 + 480 + gallery_type |> should.be_ok 481 + let gallery = gallery_type |> result.unwrap(panic) 482 + 483 + // Should have the viewer field 484 + let has_viewer_favorite = case gallery { 485 + schema.ObjectType(_, fields, _) -> 486 + list.any(fields, fn(f) { 487 + f.name == "viewerSocialGrainFavoriteViaSubject" 488 + }) 489 + _ -> False 490 + } 491 + 492 + has_viewer_favorite |> should.be_true 493 + } 494 + 495 + fn stub_fetcher() -> db_schema_builder.RecordFetcher { 496 + fn(_collection, _params) { 497 + Ok(#([], None, False, False, Some(0))) 498 + } 499 + } 500 + ``` 501 + 502 + **Step 2: Run test to verify it fails** 503 + 504 + Run: `cd lexicon_graphql && gleam test -- --filter=generates_viewer_field` 505 + Expected: FAIL - no viewerSocialGrainFavoriteViaSubject field 506 + 507 + **Step 3: Add viewer field generation to database.gleam** 508 + 509 + After `build_reverse_join_fields_with_types`, add a new function: 510 + 511 + ```gleam 512 + /// Build viewer state fields for reverse joins 513 + /// These return a single nullable record (the viewer's record, if any) 514 + fn build_viewer_fields_for_reverse_joins( 515 + reverse_joins: List(ReverseJoinRelationship), 516 + viewer_state_fetcher: option.Option(dataloader.ViewerStateFetcher), 517 + object_types: dict.Dict(String, schema.Type), 518 + ) -> List(schema.Field) { 519 + list.map(reverse_joins, fn(relationship) { 520 + // Generate field name: viewer<SourceTypeName>Via<FieldName> 521 + let field_name = 522 + "viewer" 523 + <> relationship.source_type_name 524 + <> "Via" 525 + <> capitalize_first(relationship.source_field) 526 + 527 + // Get the source object type 528 + let source_object_type = case 529 + dict.get(object_types, relationship.source_collection) 530 + { 531 + Ok(obj_type) -> obj_type 532 + Error(_) -> schema.string_type() 533 + } 534 + 535 + // Return type is nullable single object (not a connection) 536 + let return_type = schema.nullable(source_object_type) 537 + 538 + schema.field( 539 + field_name, 540 + return_type, 541 + "Viewer's " 542 + <> relationship.source_collection 543 + <> " record referencing this via " 544 + <> relationship.source_field 545 + <> " (null if not authenticated or no record)", 546 + fn(ctx) { 547 + // Get viewer DID from context 548 + case get_viewer_did_from_context(ctx) { 549 + Ok(viewer_did) -> { 550 + // Get parent URI 551 + case get_field_from_context(ctx, "uri") { 552 + Ok(parent_uri) -> { 553 + case viewer_state_fetcher { 554 + option.Some(fetcher) -> { 555 + case 556 + dataloader.batch_fetch_viewer_state( 557 + viewer_did, 558 + relationship.source_collection, 559 + relationship.source_field, 560 + [parent_uri], 561 + fetcher, 562 + ) 563 + { 564 + Ok(results) -> { 565 + case dict.get(results, parent_uri) { 566 + Ok(record) -> Ok(record) 567 + Error(_) -> Ok(value.Null) 568 + } 569 + } 570 + Error(_) -> Ok(value.Null) 571 + } 572 + } 573 + option.None -> Ok(value.Null) 574 + } 575 + } 576 + Error(_) -> Ok(value.Null) 577 + } 578 + } 579 + Error(_) -> Ok(value.Null) 580 + } 581 + }, 582 + ) 583 + }) 584 + } 585 + 586 + /// Extract viewer DID from context 587 + fn get_viewer_did_from_context( 588 + ctx: schema.Context, 589 + ) -> Result(String, Nil) { 590 + // The viewer DID should be in the top-level context 591 + case dict.get(ctx.context, "viewer_did") { 592 + Ok(dynamic_val) -> { 593 + case dynamic.string(dynamic_val) { 594 + Ok(did) -> Ok(did) 595 + Error(_) -> Error(Nil) 596 + } 597 + } 598 + Error(_) -> Error(Nil) 599 + } 600 + } 601 + ``` 602 + 603 + **Step 4: Wire viewer fields into type building** 604 + 605 + In the type building section (around line 515), add viewer fields: 606 + 607 + ```gleam 608 + // Combine all fields (base + forward + reverse + DID joins + viewer fields) 609 + let viewer_fields = 610 + build_viewer_fields_for_reverse_joins( 611 + record_type.reverse_joins, 612 + viewer_state_fetcher, 613 + pass3_object_types, 614 + ) 615 + 616 + let all_fields = 617 + list.flatten([ 618 + base_fields, 619 + forward_join_fields, 620 + reverse_join_fields, 621 + did_join_fields, 622 + viewer_fields, 623 + ]) 624 + ``` 625 + 626 + **Step 5: Add viewer_state_fetcher parameter to build_schema_with_fetchers** 627 + 628 + Update the function signature and pass the fetcher through. 629 + 630 + **Step 6: Run test to verify it passes** 631 + 632 + Run: `cd lexicon_graphql && gleam test -- --filter=generates_viewer_field` 633 + Expected: PASS 634 + 635 + **Step 7: Commit** 636 + 637 + ```bash 638 + git add lexicon_graphql/src/lexicon_graphql/schema/database.gleam lexicon_graphql/test/viewer_state_test.gleam 639 + git commit -m "$(cat <<'EOF' 640 + feat(schema): generate viewer fields for AT-URI reverse joins 641 + 642 + For each reverse join (e.g., socialGrainFavoriteViaSubject), generate 643 + a corresponding viewer field (viewerSocialGrainFavoriteViaSubject) that 644 + returns the viewer's single record or null. 645 + EOF 646 + )" 647 + ``` 648 + 649 + --- 650 + 651 + ## Task 4: Generate DID-Based Viewer Fields 652 + 653 + **Files:** 654 + - Modify: `lexicon_graphql/src/lexicon_graphql/schema/database.gleam` 655 + - Test: `lexicon_graphql/test/viewer_state_test.gleam` 656 + 657 + **Step 1: Write the failing test** 658 + 659 + Add to `viewer_state_test.gleam`: 660 + 661 + ```gleam 662 + fn follow_lexicon() -> types.Lexicon { 663 + types.Lexicon( 664 + lexicon: 1, 665 + id: "social.grain.graph.follow", 666 + revision: None, 667 + description: None, 668 + defs: types.Defs( 669 + main: Some(types.MainDef( 670 + type_: "record", 671 + description: None, 672 + key: Some("tid"), 673 + properties: [ 674 + #("subject", types.Property( 675 + type_: "string", 676 + required: True, 677 + format: Some("did"), 678 + ref: None, 679 + refs: None, 680 + items: None, 681 + )), 682 + ], 683 + required: None, 684 + ref: None, 685 + refs: None, 686 + )), 687 + other: [], 688 + ), 689 + ) 690 + } 691 + 692 + pub fn generates_viewer_field_for_did_subject_test() { 693 + let lexicons = [gallery_lexicon(), follow_lexicon()] 694 + 695 + let schema_result = db_schema_builder.build_schema_with_fetchers( 696 + lexicons, 697 + stub_fetcher(), 698 + None, 699 + None, 700 + None, 701 + None, 702 + None, 703 + None, 704 + None, 705 + ) 706 + 707 + schema_result |> should.be_ok 708 + let schema = schema_result |> result.unwrap(panic) 709 + 710 + // Get the SocialGrainGallery type (has did field) 711 + let gallery_type = schema.types 712 + |> dict.get("SocialGrainGallery") 713 + 714 + gallery_type |> should.be_ok 715 + let gallery = gallery_type |> result.unwrap(panic) 716 + 717 + // Should have the viewer follow field 718 + let has_viewer_follow = case gallery { 719 + schema.ObjectType(_, fields, _) -> 720 + list.any(fields, fn(f) { 721 + f.name == "viewerSocialGrainGraphFollowViaDid" 722 + }) 723 + _ -> False 724 + } 725 + 726 + has_viewer_follow |> should.be_true 727 + } 728 + ``` 729 + 730 + **Step 2: Run test to verify it fails** 731 + 732 + Run: `cd lexicon_graphql && gleam test -- --filter=generates_viewer_field_for_did` 733 + Expected: FAIL - no viewerSocialGrainGraphFollowViaDid field 734 + 735 + **Step 3: Build DID-based viewer field map** 736 + 737 + Add function to discover collections with DID-typed subject fields: 738 + 739 + ```gleam 740 + /// Build a map of collections with DID-typed subject fields 741 + /// Returns: Dict(source_nsid, List(field_name)) 742 + fn build_did_subject_collection_map( 743 + metas: List(collection_meta.CollectionMeta), 744 + ) -> dict.Dict(String, List(String)) { 745 + list.fold(metas, dict.new(), fn(acc, meta) { 746 + case meta.did_subject_fields { 747 + [] -> acc 748 + fields -> dict.insert(acc, meta.nsid, fields) 749 + } 750 + }) 751 + } 752 + ``` 753 + 754 + **Step 4: Add DID-based viewer field generation** 755 + 756 + ```gleam 757 + /// Build viewer state fields for DID-based relationships 758 + /// These are added to any type with a did field 759 + fn build_viewer_fields_for_did_subjects( 760 + did_subject_collections: dict.Dict(String, List(String)), 761 + viewer_state_fetcher: option.Option(dataloader.ViewerStateFetcher), 762 + object_types: dict.Dict(String, schema.Type), 763 + record_types: List(RecordType), 764 + ) -> List(schema.Field) { 765 + // For each collection with DID-typed subject fields 766 + dict.to_list(did_subject_collections) 767 + |> list.flat_map(fn(entry) { 768 + let #(source_nsid, did_fields) = entry 769 + 770 + // Find the source type name 771 + let source_type_name = case 772 + list.find(record_types, fn(rt) { rt.nsid == source_nsid }) 773 + { 774 + Ok(rt) -> rt.type_name 775 + Error(_) -> nsid.to_type_name(source_nsid) 776 + } 777 + 778 + // Get the source object type 779 + let source_object_type = case dict.get(object_types, source_nsid) { 780 + Ok(obj_type) -> obj_type 781 + Error(_) -> schema.string_type() 782 + } 783 + 784 + // Generate a viewer field for each DID field 785 + list.map(did_fields, fn(field_name) { 786 + let viewer_field_name = 787 + "viewer" 788 + <> source_type_name 789 + <> "Via" 790 + <> capitalize_first(field_name) 791 + 792 + let return_type = schema.nullable(source_object_type) 793 + 794 + schema.field( 795 + viewer_field_name, 796 + return_type, 797 + "Viewer's " 798 + <> source_nsid 799 + <> " record where " 800 + <> field_name 801 + <> " matches this record's DID (null if not authenticated or no record)", 802 + fn(ctx) { 803 + // Get viewer DID from context 804 + case get_viewer_did_from_context(ctx) { 805 + Ok(viewer_did) -> { 806 + // Get parent DID (extract from URI) 807 + case get_field_from_context(ctx, "uri") { 808 + Ok(parent_uri) -> { 809 + case extract_did_from_uri(parent_uri) { 810 + option.Some(parent_did) -> { 811 + case viewer_state_fetcher { 812 + option.Some(fetcher) -> { 813 + case 814 + dataloader.batch_fetch_viewer_state( 815 + viewer_did, 816 + source_nsid, 817 + field_name, 818 + [parent_did], 819 + fetcher, 820 + ) 821 + { 822 + Ok(results) -> { 823 + case dict.get(results, parent_did) { 824 + Ok(record) -> Ok(record) 825 + Error(_) -> Ok(value.Null) 826 + } 827 + } 828 + Error(_) -> Ok(value.Null) 829 + } 830 + } 831 + option.None -> Ok(value.Null) 832 + } 833 + } 834 + option.None -> Ok(value.Null) 835 + } 836 + } 837 + Error(_) -> Ok(value.Null) 838 + } 839 + } 840 + Error(_) -> Ok(value.Null) 841 + } 842 + }, 843 + ) 844 + }) 845 + }) 846 + } 847 + ``` 848 + 849 + **Step 5: Wire DID viewer fields into type building** 850 + 851 + Add DID-based viewer fields to types that have a `did` field: 852 + 853 + ```gleam 854 + // Only add DID viewer fields if this type has a did field 855 + let did_viewer_fields = case has_did_field(record_type) { 856 + True -> 857 + build_viewer_fields_for_did_subjects( 858 + did_subject_collections, 859 + viewer_state_fetcher, 860 + pass3_object_types, 861 + record_types, 862 + ) 863 + False -> [] 864 + } 865 + 866 + let all_fields = 867 + list.flatten([ 868 + base_fields, 869 + forward_join_fields, 870 + reverse_join_fields, 871 + did_join_fields, 872 + viewer_fields, 873 + did_viewer_fields, 874 + ]) 875 + ``` 876 + 877 + **Step 6: Run test to verify it passes** 878 + 879 + Run: `cd lexicon_graphql && gleam test -- --filter=generates_viewer_field_for_did` 880 + Expected: PASS 881 + 882 + **Step 7: Commit** 883 + 884 + ```bash 885 + git add lexicon_graphql/src/lexicon_graphql/schema/database.gleam lexicon_graphql/test/viewer_state_test.gleam 886 + git commit -m "$(cat <<'EOF' 887 + feat(schema): generate viewer fields for DID-based subjects 888 + 889 + For collections where subject is a DID (e.g., follows), generate viewer 890 + fields on any type with a did field. For example, SocialGrainGallery 891 + gets viewerSocialGrainGraphFollowViaDid. 892 + EOF 893 + )" 894 + ``` 895 + 896 + --- 897 + 898 + ## Task 5: Update Server to Pass Viewer State Fetcher 899 + 900 + **Files:** 901 + - Modify: `server/src/server/graphql/schema.gleam` 902 + - Modify: `server/src/server/database/viewer_state.gleam` (create) 903 + 904 + **Step 1: Create viewer state database module** 905 + 906 + Create `server/src/server/database/viewer_state.gleam`: 907 + 908 + ```gleam 909 + import gleam/dict.{type Dict} 910 + import gleam/list 911 + import gleam/pgo 912 + import gleam/option.{type Option, None, Some} 913 + import gleam/dynamic 914 + import swell/value 915 + 916 + /// Fetch viewer state for multiple parent records 917 + /// Returns a dict mapping parent keys to the viewer's record (if any) 918 + pub fn fetch_viewer_state( 919 + db: pgo.Connection, 920 + viewer_did: String, 921 + collection: String, 922 + reference_field: String, 923 + parent_keys: List(String), 924 + ) -> Result(Dict(String, value.Value), String) { 925 + // Build query to find viewer's records 926 + // SELECT * FROM {collection} WHERE did = $1 AND {reference_field} IN ($2, $3, ...) 927 + let table_name = collection_to_table_name(collection) 928 + 929 + let placeholders = 930 + list.index_map(parent_keys, fn(_, i) { "$" <> int.to_string(i + 2) }) 931 + |> string.join(", ") 932 + 933 + let query = 934 + "SELECT uri, data FROM " 935 + <> table_name 936 + <> " WHERE did = $1 AND data->>'" 937 + <> reference_field 938 + <> "' IN (" 939 + <> placeholders 940 + <> ")" 941 + 942 + // Execute query and build result dict 943 + // ... (implementation details) 944 + 945 + Ok(dict.new()) 946 + } 947 + 948 + fn collection_to_table_name(collection: String) -> String { 949 + collection 950 + |> string.replace(".", "_") 951 + } 952 + ``` 953 + 954 + **Step 2: Wire fetcher into schema building** 955 + 956 + Update `server/src/server/graphql/schema.gleam` to pass the viewer state fetcher. 957 + 958 + **Step 3: Commit** 959 + 960 + ```bash 961 + git add server/src/server/database/viewer_state.gleam server/src/server/graphql/schema.gleam 962 + git commit -m "$(cat <<'EOF' 963 + feat(server): implement viewer state database fetcher 964 + 965 + Add database query to fetch viewer's records for multiple parent keys, 966 + used by viewer fields to show relationship state. 967 + EOF 968 + )" 969 + ``` 970 + 971 + --- 972 + 973 + ## Task 6: Integration Test 974 + 975 + **Files:** 976 + - Test: `server/test/viewer_state_integration_test.gleam` 977 + 978 + **Step 1: Write integration test** 979 + 980 + ```gleam 981 + import gleeunit/should 982 + import server/test_helpers 983 + import gleam/json 984 + 985 + pub fn viewer_favorite_shows_liked_state_test() { 986 + use ctx <- test_helpers.with_authenticated_context("did:plc:viewer") 987 + 988 + // Create a gallery 989 + let gallery_uri = test_helpers.create_gallery(ctx, "Test Gallery") 990 + 991 + // Query without liking - should be null 992 + let query = " 993 + query { 994 + socialGrainGallery(where: { uri: { eq: \"" <> gallery_uri <> "\" } }) { 995 + edges { 996 + node { 997 + uri 998 + viewerSocialGrainFavoriteViaSubject { uri } 999 + } 1000 + } 1001 + } 1002 + } 1003 + " 1004 + 1005 + let result = test_helpers.execute_query(ctx, query) 1006 + result.data 1007 + |> json.get(["socialGrainGallery", "edges", 0, "node", "viewerSocialGrainFavoriteViaSubject"]) 1008 + |> should.equal(json.Null) 1009 + 1010 + // Like the gallery 1011 + let like_uri = test_helpers.create_favorite(ctx, gallery_uri) 1012 + 1013 + // Query again - should show the like 1014 + let result2 = test_helpers.execute_query(ctx, query) 1015 + result2.data 1016 + |> json.get(["socialGrainGallery", "edges", 0, "node", "viewerSocialGrainFavoriteViaSubject", "uri"]) 1017 + |> should.equal(json.String(like_uri)) 1018 + } 1019 + 1020 + pub fn viewer_follow_shows_followed_state_test() { 1021 + use ctx <- test_helpers.with_authenticated_context("did:plc:viewer") 1022 + 1023 + // Create a gallery by another user 1024 + let gallery_uri = test_helpers.create_gallery_by(ctx, "did:plc:author", "Author's Gallery") 1025 + 1026 + // Query without following - should be null 1027 + let query = " 1028 + query { 1029 + socialGrainGallery(where: { uri: { eq: \"" <> gallery_uri <> "\" } }) { 1030 + edges { 1031 + node { 1032 + uri 1033 + did 1034 + viewerSocialGrainGraphFollowViaDid { uri } 1035 + } 1036 + } 1037 + } 1038 + } 1039 + " 1040 + 1041 + let result = test_helpers.execute_query(ctx, query) 1042 + result.data 1043 + |> json.get(["socialGrainGallery", "edges", 0, "node", "viewerSocialGrainGraphFollowViaDid"]) 1044 + |> should.equal(json.Null) 1045 + 1046 + // Follow the author 1047 + let follow_uri = test_helpers.create_follow(ctx, "did:plc:author") 1048 + 1049 + // Query again - should show the follow 1050 + let result2 = test_helpers.execute_query(ctx, query) 1051 + result2.data 1052 + |> json.get(["socialGrainGallery", "edges", 0, "node", "viewerSocialGrainGraphFollowViaDid", "uri"]) 1053 + |> should.equal(json.String(follow_uri)) 1054 + } 1055 + ``` 1056 + 1057 + **Step 2: Run integration tests** 1058 + 1059 + Run: `cd server && gleam test -- --filter=viewer_` 1060 + Expected: PASS 1061 + 1062 + **Step 3: Commit** 1063 + 1064 + ```bash 1065 + git add server/test/viewer_state_integration_test.gleam 1066 + git commit -m "$(cat <<'EOF' 1067 + test: add viewer state integration tests 1068 + 1069 + Verify that viewer fields correctly show liked/followed state 1070 + when authenticated. 1071 + EOF 1072 + )" 1073 + ``` 1074 + 1075 + --- 1076 + 1077 + ## Summary 1078 + 1079 + | Pattern | Field Name | Subject Type | Join On | 1080 + |---------|------------|--------------|---------| 1081 + | AT-URI | `viewer{TypeName}Via{FieldName}` | `format: "at-uri"` | `subject = parent.uri` | 1082 + | DID | `viewer{TypeName}Via{FieldName}` | `format: "did"` | `subject = parent.did` | 1083 + 1084 + Both return single nullable object and require viewer authentication.
+215
docs/guides/viewer-state.md
··· 1 + # Viewer State 2 + 3 + Viewer state fields show the authenticated user's relationship to records. Has the current user favorited this photo? Do they follow this author? Viewer state answers these questions in a single query. 4 + 5 + ## How It Works 6 + 7 + Viewer state fields find your records that reference the current record's URI or its author's DID. When you query a gallery, `viewerSocialGrainFavoriteViaSubject` returns your favorite for that gallery, if one exists. 8 + 9 + The server identifies you from your access token. Authentication is required. 10 + 11 + ## Field Naming 12 + 13 + Quickslice generates viewer state fields with the pattern: 14 + 15 + ``` 16 + viewer{CollectionName}Via{FieldName} 17 + ``` 18 + 19 + The field name comes from your lexicon. For example: 20 + - A `subject` field generates `viewerSocialGrainFavoriteViaSubject` 21 + - A `target` field generates `viewerSocialGrainFavoriteViaTarget` 22 + 23 + ## AT-URI Based (Records) 24 + 25 + For lexicons with `format: "at-uri"` fields, viewer state checks if you have a record pointing to the current record's URI. 26 + 27 + ### Example: Check if User Favorited a Gallery 28 + 29 + ```graphql 30 + query { 31 + socialGrainGallery(first: 10) { 32 + edges { 33 + node { 34 + uri 35 + title 36 + viewerSocialGrainFavoriteViaSubject { 37 + uri 38 + } 39 + } 40 + } 41 + } 42 + } 43 + ``` 44 + 45 + Response when you've favorited the first gallery: 46 + 47 + ```json 48 + { 49 + "data": { 50 + "socialGrainGallery": { 51 + "edges": [ 52 + { 53 + "node": { 54 + "uri": "at://did:plc:author/social.grain.gallery/abc123", 55 + "title": "My Gallery", 56 + "viewerSocialGrainFavoriteViaSubject": { 57 + "uri": "at://did:plc:you/social.grain.favorite/fav456" 58 + } 59 + } 60 + }, 61 + { 62 + "node": { 63 + "uri": "at://did:plc:author/social.grain.gallery/xyz789", 64 + "title": "Another Gallery", 65 + "viewerSocialGrainFavoriteViaSubject": null 66 + } 67 + } 68 + ] 69 + } 70 + } 71 + } 72 + ``` 73 + 74 + The first gallery returns your favorite record. The second returns `null` because you have not favorited it. 75 + 76 + ## DID Based (Users) 77 + 78 + For lexicons with `format: "did"` fields, viewer state checks if you have a record pointing to the current record's author DID. 79 + 80 + ### Example: Check if User Follows an Author 81 + 82 + ```graphql 83 + query { 84 + socialGrainActorProfile(first: 10) { 85 + edges { 86 + node { 87 + uri 88 + did 89 + displayName 90 + viewerSocialGrainGraphFollowViaSubject { 91 + uri 92 + } 93 + } 94 + } 95 + } 96 + } 97 + ``` 98 + 99 + Response when you follow the first user: 100 + 101 + ```json 102 + { 103 + "data": { 104 + "socialGrainActorProfile": { 105 + "edges": [ 106 + { 107 + "node": { 108 + "uri": "at://did:plc:alice/social.grain.actor.profile/self", 109 + "did": "did:plc:alice", 110 + "displayName": "Alice", 111 + "viewerSocialGrainGraphFollowViaSubject": { 112 + "uri": "at://did:plc:you/social.grain.graph.follow/follow123" 113 + } 114 + } 115 + }, 116 + { 117 + "node": { 118 + "uri": "at://did:plc:bob/social.grain.actor.profile/self", 119 + "did": "did:plc:bob", 120 + "displayName": "Bob", 121 + "viewerSocialGrainGraphFollowViaSubject": null 122 + } 123 + } 124 + ] 125 + } 126 + } 127 + } 128 + ``` 129 + 130 + ## Combining with Other Joins 131 + 132 + Viewer state works alongside other join types. Get engagement counts and your personal state together: 133 + 134 + ```graphql 135 + query { 136 + socialGrainGallery(first: 5) { 137 + edges { 138 + node { 139 + uri 140 + title 141 + 142 + # Author profile 143 + socialGrainActorProfileByDid { 144 + displayName 145 + } 146 + 147 + # Total favorites count 148 + socialGrainFavoriteViaSubject { 149 + totalCount 150 + } 151 + 152 + # Did YOU favorite it? 153 + viewerSocialGrainFavoriteViaSubject { 154 + uri 155 + } 156 + } 157 + } 158 + } 159 + } 160 + ``` 161 + 162 + ## Authentication Required 163 + 164 + Viewer state fields require authentication. Without a valid auth token: 165 + - Fields return `null` 166 + - No error is thrown 167 + 168 + Use the [Quickslice client SDK](./authentication.md#using-the-client-sdk) to handle authentication automatically. 169 + 170 + ## Lexicon Requirements 171 + 172 + Quickslice generates viewer state fields for any field with `format: "at-uri"` or `format: "did"`: 173 + 174 + ### AT-URI Format (for record references) 175 + 176 + ```json 177 + { 178 + "properties": { 179 + "subject": { 180 + "type": "string", 181 + "format": "at-uri" 182 + } 183 + } 184 + } 185 + ``` 186 + 187 + Generates: `viewer{Collection}ViaSubject` 188 + 189 + ### DID Format (for user references) 190 + 191 + ```json 192 + { 193 + "properties": { 194 + "subject": { 195 + "type": "string", 196 + "format": "did" 197 + } 198 + } 199 + } 200 + ``` 201 + 202 + Generates: `viewer{Collection}ViaSubject` 203 + 204 + The field can have any name: `subject`, `target`, `ref`, or something else. The name becomes part of the generated field name. 205 + 206 + ## How Batching Works 207 + 208 + Viewer state queries are batched like other joins. When fetching 100 galleries with viewer favorites: 209 + 210 + 1. Fetches 100 galleries 211 + 2. Collects all gallery URIs 212 + 3. Queries favorites where `did = viewer_did AND subject IN (uris)` 213 + 4. Maps results back to galleries 214 + 215 + This avoids N+1 queries regardless of result size.
+1 -1
lexicon_graphql/gleam.toml
··· 15 15 [dependencies] 16 16 gleam_stdlib = ">= 0.44.0 and < 2.0.0" 17 17 gleam_json = ">= 3.0.0 and < 4.0.0" 18 - swell = ">= 2.1.3 and < 3.0.0" 18 + swell = ">= 2.1.4 and < 3.0.0" 19 19 20 20 [dev-dependencies] 21 21 gleeunit = ">= 1.0.0 and < 2.0.0"
+4 -4
lexicon_graphql/manifest.toml
··· 3 3 4 4 packages = [ 5 5 { name = "argv", version = "1.0.2", build_tools = ["gleam"], requirements = [], otp_app = "argv", source = "hex", outer_checksum = "BA1FF0929525DEBA1CE67256E5ADF77A7CDDFE729E3E3F57A5BDCAA031DED09D" }, 6 - { name = "birdie", version = "1.5.2", build_tools = ["gleam"], requirements = ["argv", "edit_distance", "envoy", "filepath", "glance", "gleam_community_ansi", "gleam_stdlib", "global_value", "justin", "rank", "simplifile", "term_size", "tom", "trie_again"], otp_app = "birdie", source = "hex", outer_checksum = "088CCB697A16E0EAC4A0B4F3F037D47E02C460594001EED45AA0F9F0559D90C5" }, 6 + { name = "birdie", version = "1.5.3", build_tools = ["gleam"], requirements = ["argv", "edit_distance", "envoy", "filepath", "glance", "gleam_community_ansi", "gleam_stdlib", "global_value", "justin", "rank", "simplifile", "term_size", "tom", "trie_again"], otp_app = "birdie", source = "hex", outer_checksum = "64E7D9CC9E84272DA07061628E1B8F31F34FCD2008BCED47AB8FD58457CA63E2" }, 7 7 { name = "edit_distance", version = "3.0.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "edit_distance", source = "hex", outer_checksum = "7DC465C34695F9E57D79FC65670C53C992CE342BF29E0AA41FF44F61AF62FC56" }, 8 8 { name = "envoy", version = "1.1.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "envoy", source = "hex", outer_checksum = "850DA9D29D2E5987735872A2B5C81035146D7FE19EFC486129E44440D03FD832" }, 9 9 { name = "filepath", version = "1.1.2", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "filepath", source = "hex", outer_checksum = "B06A9AF0BF10E51401D64B98E4B627F1D2E48C154967DA7AF4D0914780A6D40A" }, ··· 19 19 { name = "global_value", version = "1.0.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "global_value", source = "hex", outer_checksum = "23F74C91A7B819C43ABCCBF49DAD5BB8799D81F2A3736BA9A534BD47F309FF4F" }, 20 20 { name = "justin", version = "1.0.1", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "justin", source = "hex", outer_checksum = "7FA0C6DB78640C6DC5FBFD59BF3456009F3F8B485BF6825E97E1EB44E9A1E2CD" }, 21 21 { name = "rank", version = "1.0.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "rank", source = "hex", outer_checksum = "5660E361F0E49CBB714CC57CC4C89C63415D8986F05B2DA0C719D5642FAD91C9" }, 22 - { name = "simplifile", version = "2.3.1", build_tools = ["gleam"], requirements = ["filepath", "gleam_stdlib"], otp_app = "simplifile", source = "hex", outer_checksum = "957E0E5B75927659F1D2A1B7B75D7B9BA96FAA8D0C53EA71C4AD9CD0C6B848F6" }, 22 + { name = "simplifile", version = "2.3.2", build_tools = ["gleam"], requirements = ["filepath", "gleam_stdlib"], otp_app = "simplifile", source = "hex", outer_checksum = "E049B4DACD4D206D87843BCF4C775A50AE0F50A52031A2FFB40C9ED07D6EC70A" }, 23 23 { name = "splitter", version = "1.2.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "splitter", source = "hex", outer_checksum = "3DFD6B6C49E61EDAF6F7B27A42054A17CFF6CA2135FF553D0CB61C234D281DD0" }, 24 - { name = "swell", version = "2.1.3", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "swell", source = "hex", outer_checksum = "018FF9B3F775F101E72208B4E0EA15A670A51E59C83247DD0302C3AD8C2FE9FF" }, 24 + { name = "swell", version = "2.1.4", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "swell", source = "hex", outer_checksum = "E12BCF0F90886C3B0C641AA2BB69CE9EDEEF1293279256B75DEB14A03F2F8221" }, 25 25 { name = "term_size", version = "1.0.1", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "term_size", source = "hex", outer_checksum = "D00BD2BC8FB3EBB7E6AE076F3F1FF2AC9D5ED1805F004D0896C784D06C6645F1" }, 26 26 { name = "tom", version = "2.0.0", build_tools = ["gleam"], requirements = ["gleam_stdlib", "gleam_time"], otp_app = "tom", source = "hex", outer_checksum = "74D0C5A3761F7A7D06994755D4D5AD854122EF8E9F9F76A3E7547606D8C77091" }, 27 27 { name = "trie_again", version = "1.1.4", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "trie_again", source = "hex", outer_checksum = "E3BD66B4E126EF567EA8C4944EAB216413392ADF6C16C36047AF79EE5EF13466" }, ··· 32 32 gleam_json = { version = ">= 3.0.0 and < 4.0.0" } 33 33 gleam_stdlib = { version = ">= 0.44.0 and < 2.0.0" } 34 34 gleeunit = { version = ">= 1.0.0 and < 2.0.0" } 35 - swell = { version = ">= 2.1.3 and < 3.0.0" } 35 + swell = { version = ">= 2.1.4 and < 3.0.0" }
+2
lexicon_graphql/src/lexicon_graphql.gleam
··· 62 62 aggregate_fetcher: Option(db_schema_builder.AggregateFetcher), 63 63 viewer_fetcher: Option(ViewerFetcher), 64 64 notification_fetcher: Option(NotificationFetcher), 65 + viewer_state_fetcher: Option(dataloader.ViewerStateFetcher), 65 66 ) { 66 67 db_schema_builder.build_schema_with_subscriptions( 67 68 lexicons, ··· 75 76 aggregate_fetcher, 76 77 viewer_fetcher, 77 78 notification_fetcher, 79 + viewer_state_fetcher, 78 80 ) 79 81 } 80 82
+24 -6
lexicon_graphql/src/lexicon_graphql/internal/lexicon/collection_meta.gleam
··· 22 22 forward_join_fields: List(ForwardJoinField), 23 23 /// Fields that can be used for reverse joins (fields with at-uri format) 24 24 reverse_join_fields: List(String), 25 + /// Fields with format: "did" that can be used for DID-based viewer joins 26 + did_subject_fields: List(String), 25 27 ) 26 28 } 27 29 ··· 49 51 // Collections with key="literal:self" have a unique record per DID (e.g., profiles) 50 52 let has_unique_did = key_type == "literal:self" 51 53 52 - let #(forward_fields, reverse_fields) = 54 + let #(forward_fields, reverse_fields, did_fields) = 53 55 scan_properties(main_def.properties) 54 56 55 57 CollectionMeta( ··· 59 61 has_unique_did: has_unique_did, 60 62 forward_join_fields: forward_fields, 61 63 reverse_join_fields: reverse_fields, 64 + did_subject_fields: did_fields, 62 65 ) 63 66 } 64 67 None -> { ··· 70 73 has_unique_did: False, 71 74 forward_join_fields: [], 72 75 reverse_join_fields: [], 76 + did_subject_fields: [], 73 77 ) 74 78 } 75 79 } 76 80 } 77 81 78 - /// Scan properties to identify forward and reverse join fields 82 + /// Scan properties to identify forward, reverse, and DID join fields 79 83 fn scan_properties( 80 84 properties: List(#(String, types.Property)), 81 - ) -> #(List(ForwardJoinField), List(String)) { 82 - list.fold(properties, #([], []), fn(acc, prop) { 83 - let #(forward_fields, reverse_fields) = acc 85 + ) -> #(List(ForwardJoinField), List(String), List(String)) { 86 + list.fold(properties, #([], [], []), fn(acc, prop) { 87 + let #(forward_fields, reverse_fields, did_fields) = acc 84 88 let #(name, property) = prop 85 89 86 90 // Check if this is a forward join field ··· 95 99 False -> reverse_fields 96 100 } 97 101 98 - #(new_forward, new_reverse) 102 + // Check if this is a DID-typed field 103 + let new_did = case is_did_field(property) { 104 + True -> [name, ..did_fields] 105 + False -> did_fields 106 + } 107 + 108 + #(new_forward, new_reverse, new_did) 99 109 }) 110 + } 111 + 112 + /// Check if a property has DID format 113 + fn is_did_field(property: types.Property) -> Bool { 114 + case property.format { 115 + Some(fmt) if fmt == "did" -> True 116 + _ -> False 117 + } 100 118 } 101 119 102 120 /// Check if a property is a forward join field
+22
lexicon_graphql/src/lexicon_graphql/query/dataloader.gleam
··· 77 77 ) 78 78 } 79 79 80 + /// Fetcher for viewer state queries 81 + /// Takes viewer DID, collection, reference field, and list of parent keys 82 + /// Returns a dict mapping parent keys to the viewer's record (if any) 83 + pub type ViewerStateFetcher = 84 + fn(String, String, String, List(String)) -> 85 + Result(Dict(String, value.Value), String) 86 + 80 87 /// Extract the collection name from an AT URI 81 88 /// Format: at://did:plc:abc123/app.bsky.feed.post/rkey 82 89 /// Returns: app.bsky.feed.post ··· 178 185 ) -> Result(PaginatedBatchResult, String) { 179 186 // Fetch paginated records by DID 180 187 fetcher(did, target_collection, None, pagination) 188 + } 189 + 190 + /// Batch fetch viewer state for multiple parent records 191 + /// 192 + /// Given a viewer DID and list of parent URIs/DIDs, finds the viewer's 193 + /// record for each parent (if any). 194 + /// Returns a Dict mapping parent keys to the viewer's record. 195 + pub fn batch_fetch_viewer_state( 196 + viewer_did: String, 197 + collection: String, 198 + reference_field: String, 199 + parent_keys: List(String), 200 + fetcher: ViewerStateFetcher, 201 + ) -> Result(Dict(String, value.Value), String) { 202 + fetcher(viewer_did, collection, reference_field, parent_keys) 181 203 } 182 204 183 205 /// Group URIs by their collection for batching
+247 -1
lexicon_graphql/src/lexicon_graphql/schema/database.gleam
··· 165 165 lexicons, 166 166 batch_fetcher, 167 167 paginated_batch_fetcher, 168 + option.None, 169 + // No viewer state fetcher for basic schema 168 170 ) 169 171 170 172 // Build the query type with fields for each record using shared object types ··· 217 219 aggregate_fetcher: option.Option(AggregateFetcher), 218 220 viewer_fetcher: option.Option(ViewerFetcher), 219 221 notification_fetcher: option.Option(NotificationFetcher), 222 + viewer_state_fetcher: option.Option(dataloader.ViewerStateFetcher), 220 223 ) -> Result(schema.Schema, String) { 221 224 case lexicons { 222 225 [] -> Error("Cannot build schema from empty lexicon list") ··· 227 230 lexicons, 228 231 batch_fetcher, 229 232 paginated_batch_fetcher, 233 + viewer_state_fetcher, 230 234 ) 231 235 232 236 // Build notification types if fetcher provided ··· 298 302 lexicons: List(types.Lexicon), 299 303 batch_fetcher: option.Option(dataloader.BatchFetcher), 300 304 paginated_batch_fetcher: option.Option(dataloader.PaginatedBatchFetcher), 305 + viewer_state_fetcher: option.Option(dataloader.ViewerStateFetcher), 301 306 ) -> #( 302 307 List(RecordType), 303 308 dict.Dict(String, schema.Type), ··· 344 349 // Build DID join map: source_nsid -> List(#(target_nsid, target_meta)) 345 350 let did_join_map = build_did_join_map(metadata_list) 346 351 352 + // Build list of collections with DID-typed subject fields 353 + // Used for generating viewerXxxViaSubject fields on all record types 354 + let did_subject_collections = build_did_subject_collections(metadata_list) 355 + 347 356 // Parse lexicons to create TEMPORARY RecordTypes (just to build Record union) 348 357 let temp_record_types = 349 358 lexicons ··· 512 521 sort_field_enums, 513 522 ) 514 523 515 - // Combine all fields (base + forward + reverse + DID joins) 524 + // Build viewer fields for AT-URI reverse joins 525 + let viewer_fields = 526 + build_viewer_fields_for_reverse_joins( 527 + reverse_joins, 528 + viewer_state_fetcher, 529 + basic_object_types, 530 + ) 531 + 532 + // Build viewer fields for DID-based subjects (e.g., follows) 533 + // These are added to all record types since all have a did field 534 + let did_viewer_fields = 535 + build_viewer_fields_for_did_subjects( 536 + did_subject_collections, 537 + viewer_state_fetcher, 538 + basic_object_types, 539 + ) 540 + 541 + // Combine all fields (base + forward + reverse + DID joins + viewer + DID viewer) 516 542 let all_fields = 517 543 list.flatten([ 518 544 record_type.fields, 519 545 forward_join_fields, 520 546 reverse_join_fields, 521 547 did_join_fields, 548 + viewer_fields, 549 + did_viewer_fields, 522 550 ]) 523 551 524 552 RecordType( ··· 588 616 sort_field_enums, 589 617 ) 590 618 619 + // Build viewer fields for AT-URI reverse joins 620 + let viewer_fields = 621 + build_viewer_fields_for_reverse_joins( 622 + reverse_joins, 623 + viewer_state_fetcher, 624 + complete_object_types, 625 + ) 626 + 627 + // Build viewer fields for DID-based subjects (e.g., follows) 628 + let did_viewer_fields = 629 + build_viewer_fields_for_did_subjects( 630 + did_subject_collections, 631 + viewer_state_fetcher, 632 + complete_object_types, 633 + ) 634 + 591 635 // Combine all fields 592 636 let all_fields = 593 637 list.flatten([ ··· 595 639 forward_join_fields, 596 640 reverse_join_fields, 597 641 did_join_fields, 642 + viewer_fields, 643 + did_viewer_fields, 598 644 ]) 599 645 600 646 RecordType( ··· 982 1028 } 983 1029 }, 984 1030 ) 1031 + }) 1032 + } 1033 + 1034 + /// Build viewer state fields for reverse joins 1035 + /// These return a single nullable record (the viewer's record, if any) 1036 + /// For example, if a gallery has a socialGrainFavoriteViaSubject connection, 1037 + /// it will also get a viewerSocialGrainFavoriteViaSubject field that returns 1038 + /// the viewer's like record (or null if not liked/not authenticated) 1039 + fn build_viewer_fields_for_reverse_joins( 1040 + reverse_joins: List(ReverseJoinRelationship), 1041 + viewer_state_fetcher: option.Option(dataloader.ViewerStateFetcher), 1042 + object_types: dict.Dict(String, schema.Type), 1043 + ) -> List(schema.Field) { 1044 + list.map(reverse_joins, fn(relationship) { 1045 + // Generate field name: viewer<SourceTypeName>Via<FieldName> 1046 + // Example: viewerSocialGrainFavoriteViaSubject 1047 + let field_name = 1048 + "viewer" 1049 + <> relationship.source_type_name 1050 + <> "Via" 1051 + <> capitalize_first(relationship.source_field) 1052 + 1053 + // Get the source object type 1054 + let source_object_type = case 1055 + dict.get(object_types, relationship.source_collection) 1056 + { 1057 + Ok(obj_type) -> obj_type 1058 + Error(_) -> schema.string_type() 1059 + } 1060 + 1061 + // Return type is the source object type (nullable by default in GraphQL) 1062 + let return_type = source_object_type 1063 + 1064 + schema.field( 1065 + field_name, 1066 + return_type, 1067 + "Viewer's " 1068 + <> relationship.source_collection 1069 + <> " record referencing this via " 1070 + <> relationship.source_field 1071 + <> " (null if not authenticated or no record)", 1072 + fn(ctx) { 1073 + // Get viewer DID from context 1074 + case get_viewer_did_from_context(ctx) { 1075 + Ok(viewer_did) -> { 1076 + // Get parent URI 1077 + case get_field_from_context(ctx, "uri") { 1078 + Ok(parent_uri) -> { 1079 + case viewer_state_fetcher { 1080 + option.Some(fetcher) -> { 1081 + case 1082 + dataloader.batch_fetch_viewer_state( 1083 + viewer_did, 1084 + relationship.source_collection, 1085 + relationship.source_field, 1086 + [parent_uri], 1087 + fetcher, 1088 + ) 1089 + { 1090 + Ok(results) -> { 1091 + case dict.get(results, parent_uri) { 1092 + Ok(record) -> Ok(record) 1093 + Error(_) -> Ok(value.Null) 1094 + } 1095 + } 1096 + Error(_) -> Ok(value.Null) 1097 + } 1098 + } 1099 + option.None -> Ok(value.Null) 1100 + } 1101 + } 1102 + Error(_) -> Ok(value.Null) 1103 + } 1104 + } 1105 + Error(_) -> Ok(value.Null) 1106 + } 1107 + }, 1108 + ) 1109 + }) 1110 + } 1111 + 1112 + /// Extract viewer DID from context variables 1113 + /// The viewer DID is set by the server after verifying the auth token 1114 + /// It's stored in variables (not ctx.data) because ctx.data gets overwritten 1115 + /// with parent values during field resolution 1116 + fn get_viewer_did_from_context(ctx: schema.Context) -> Result(String, Nil) { 1117 + case schema.get_variable(ctx, "viewer_did") { 1118 + option.Some(value.String(did)) -> Ok(did) 1119 + _ -> Error(Nil) 1120 + } 1121 + } 1122 + 1123 + /// Structure representing a collection with DID-typed subject fields 1124 + type DidSubjectCollection { 1125 + DidSubjectCollection( 1126 + /// Collection NSID (e.g., "social.grain.graph.follow") 1127 + nsid: String, 1128 + /// Type name (e.g., "SocialGrainGraphFollow") 1129 + type_name: String, 1130 + /// DID-typed field names (e.g., ["subject"]) 1131 + did_fields: List(String), 1132 + ) 1133 + } 1134 + 1135 + /// Build a list of collections with DID-typed subject fields 1136 + fn build_did_subject_collections( 1137 + metadata_list: List(#(String, collection_meta.CollectionMeta)), 1138 + ) -> List(DidSubjectCollection) { 1139 + list.filter_map(metadata_list, fn(meta_pair) { 1140 + let #(nsid, meta) = meta_pair 1141 + case meta.did_subject_fields { 1142 + [] -> Error(Nil) 1143 + fields -> 1144 + Ok(DidSubjectCollection( 1145 + nsid: nsid, 1146 + type_name: meta.type_name, 1147 + did_fields: fields, 1148 + )) 1149 + } 1150 + }) 1151 + } 1152 + 1153 + /// Build viewer state fields for DID-based relationships 1154 + /// These are added to all record types (since all records have a did field) 1155 + /// For example, if social.grain.graph.follow has subject: format "did", 1156 + /// then all types get viewerSocialGrainGraphFollowViaSubject 1157 + fn build_viewer_fields_for_did_subjects( 1158 + did_subject_collections: List(DidSubjectCollection), 1159 + viewer_state_fetcher: option.Option(dataloader.ViewerStateFetcher), 1160 + object_types: dict.Dict(String, schema.Type), 1161 + ) -> List(schema.Field) { 1162 + // For each collection with DID-typed subject fields 1163 + list.flat_map(did_subject_collections, fn(collection) { 1164 + // Get the source object type 1165 + let source_object_type = case dict.get(object_types, collection.nsid) { 1166 + Ok(obj_type) -> obj_type 1167 + Error(_) -> schema.string_type() 1168 + } 1169 + 1170 + // Generate a viewer field for each DID field 1171 + list.map(collection.did_fields, fn(field_name) { 1172 + let viewer_field_name = 1173 + "viewer" 1174 + <> collection.type_name 1175 + <> "Via" 1176 + <> capitalize_first(field_name) 1177 + 1178 + let return_type = source_object_type 1179 + 1180 + schema.field( 1181 + viewer_field_name, 1182 + return_type, 1183 + "Viewer's " 1184 + <> collection.nsid 1185 + <> " record where " 1186 + <> field_name 1187 + <> " matches this record's DID (null if not authenticated or no record)", 1188 + fn(ctx) { 1189 + // Get viewer DID from context 1190 + case get_viewer_did_from_context(ctx) { 1191 + Ok(viewer_did) -> { 1192 + // Get parent DID (extract from URI) 1193 + case get_field_from_context(ctx, "uri") { 1194 + Ok(parent_uri) -> { 1195 + case extract_did_from_uri(parent_uri) { 1196 + option.Some(parent_did) -> { 1197 + case viewer_state_fetcher { 1198 + option.Some(fetcher) -> { 1199 + case 1200 + dataloader.batch_fetch_viewer_state( 1201 + viewer_did, 1202 + collection.nsid, 1203 + field_name, 1204 + [parent_did], 1205 + fetcher, 1206 + ) 1207 + { 1208 + Ok(results) -> { 1209 + case dict.get(results, parent_did) { 1210 + Ok(record) -> Ok(record) 1211 + Error(_) -> Ok(value.Null) 1212 + } 1213 + } 1214 + Error(_) -> Ok(value.Null) 1215 + } 1216 + } 1217 + option.None -> Ok(value.Null) 1218 + } 1219 + } 1220 + option.None -> Ok(value.Null) 1221 + } 1222 + } 1223 + Error(_) -> Ok(value.Null) 1224 + } 1225 + } 1226 + Error(_) -> Ok(value.Null) 1227 + } 1228 + }, 1229 + ) 1230 + }) 985 1231 }) 986 1232 } 987 1233
+70
lexicon_graphql/test/collection_meta_test.gleam
··· 7 7 import lexicon_graphql/internal/lexicon/collection_meta 8 8 import lexicon_graphql/types 9 9 10 + // Test detecting DID-typed subject field (for follows) 11 + pub fn detects_did_subject_field_test() { 12 + // Follow lexicon has subject with format: "did" 13 + let lexicon = 14 + types.Lexicon( 15 + id: "social.grain.graph.follow", 16 + defs: types.Defs( 17 + main: Some( 18 + types.RecordDef(type_: "record", key: Some("tid"), properties: [ 19 + #( 20 + "subject", 21 + types.Property( 22 + type_: "string", 23 + required: True, 24 + format: Some("did"), 25 + ref: None, 26 + refs: None, 27 + items: None, 28 + ), 29 + ), 30 + ]), 31 + ), 32 + others: dict.new(), 33 + ), 34 + ) 35 + 36 + let meta = collection_meta.extract_metadata(lexicon) 37 + 38 + // Should detect subject as a DID-typed field 39 + meta.did_subject_fields 40 + |> should.equal(["subject"]) 41 + } 42 + 43 + // Test that at-uri fields are NOT detected as DID fields 44 + pub fn detects_at_uri_subject_field_not_did_test() { 45 + // Favorite lexicon has subject with format: "at-uri" 46 + let lexicon = 47 + types.Lexicon( 48 + id: "social.grain.favorite", 49 + defs: types.Defs( 50 + main: Some( 51 + types.RecordDef(type_: "record", key: Some("tid"), properties: [ 52 + #( 53 + "subject", 54 + types.Property( 55 + type_: "string", 56 + required: True, 57 + format: Some("at-uri"), 58 + ref: None, 59 + refs: None, 60 + items: None, 61 + ), 62 + ), 63 + ]), 64 + ), 65 + others: dict.new(), 66 + ), 67 + ) 68 + 69 + let meta = collection_meta.extract_metadata(lexicon) 70 + 71 + // Should detect subject as at-uri (existing behavior) 72 + meta.reverse_join_fields 73 + |> should.equal(["subject"]) 74 + 75 + // Should NOT detect as DID field 76 + meta.did_subject_fields 77 + |> should.equal([]) 78 + } 79 + 10 80 // Test extracting metadata from a lexicon with strongRef fields 11 81 pub fn extract_metadata_with_strong_ref_test() { 12 82 let lexicon =
+88
lexicon_graphql/test/dataloader_test.gleam
··· 484 484 } 485 485 } 486 486 487 + // Test viewer state batch fetching 488 + pub fn batch_fetch_viewer_state_test() { 489 + // Mock fetcher that returns records for specific viewer+subject combinations 490 + let fetcher = fn( 491 + viewer_did: String, 492 + collection: String, 493 + _reference_field: String, 494 + parent_keys: List(String), 495 + ) -> Result(dict.Dict(String, value.Value), String) { 496 + // Simulate: viewer "did:plc:viewer" liked gallery1 and gallery3 497 + case viewer_did, collection { 498 + "did:plc:viewer", "social.grain.favorite" -> { 499 + let results = 500 + list.fold(parent_keys, dict.new(), fn(acc, key) { 501 + case key { 502 + "at://did:plc:author/social.grain.gallery/gallery1" -> 503 + dict.insert( 504 + acc, 505 + key, 506 + value.Object([ 507 + #( 508 + "uri", 509 + value.String( 510 + "at://did:plc:viewer/social.grain.favorite/abc", 511 + ), 512 + ), 513 + #("subject", value.String(key)), 514 + ]), 515 + ) 516 + "at://did:plc:author/social.grain.gallery/gallery3" -> 517 + dict.insert( 518 + acc, 519 + key, 520 + value.Object([ 521 + #( 522 + "uri", 523 + value.String( 524 + "at://did:plc:viewer/social.grain.favorite/def", 525 + ), 526 + ), 527 + #("subject", value.String(key)), 528 + ]), 529 + ) 530 + _ -> acc 531 + } 532 + }) 533 + Ok(results) 534 + } 535 + _, _ -> Ok(dict.new()) 536 + } 537 + } 538 + 539 + let parent_uris = [ 540 + "at://did:plc:author/social.grain.gallery/gallery1", 541 + "at://did:plc:author/social.grain.gallery/gallery2", 542 + "at://did:plc:author/social.grain.gallery/gallery3", 543 + ] 544 + 545 + let result = 546 + dataloader.batch_fetch_viewer_state( 547 + "did:plc:viewer", 548 + "social.grain.favorite", 549 + "subject", 550 + parent_uris, 551 + fetcher, 552 + ) 553 + 554 + result 555 + |> should.be_ok 556 + 557 + let records = case result { 558 + Ok(r) -> r 559 + Error(_) -> dict.new() 560 + } 561 + 562 + // gallery1 should have a record 563 + dict.get(records, "at://did:plc:author/social.grain.gallery/gallery1") 564 + |> should.be_ok 565 + 566 + // gallery2 should NOT have a record (viewer didn't like it) 567 + dict.get(records, "at://did:plc:author/social.grain.gallery/gallery2") 568 + |> should.be_error 569 + 570 + // gallery3 should have a record 571 + dict.get(records, "at://did:plc:author/social.grain.gallery/gallery3") 572 + |> should.be_ok 573 + } 574 + 487 575 // Test backward pagination parameters 488 576 pub fn batch_fetch_backward_pagination_test() { 489 577 // Mock paginated fetcher that handles backward pagination
+1
lexicon_graphql/test/sorting_test.gleam
··· 49 49 option.Some(aggregate_fetcher), 50 50 option.None, 51 51 option.None, 52 + option.None, 52 53 ) 53 54 { 54 55 Ok(s) -> s
+10
lexicon_graphql/test/subscription_schema_test.gleam
··· 62 62 // viewer_fetcher 63 63 None, 64 64 // notification_fetcher 65 + None, 66 + // viewer_state_fetcher 65 67 ) 66 68 { 67 69 Ok(s) -> { ··· 117 119 // viewer_fetcher 118 120 None, 119 121 // notification_fetcher 122 + None, 123 + // viewer_state_fetcher 120 124 ) 121 125 { 122 126 Ok(s) -> { ··· 194 198 // viewer_fetcher 195 199 None, 196 200 // notification_fetcher 201 + None, 202 + // viewer_state_fetcher 197 203 ) 198 204 { 199 205 Ok(s) -> { ··· 264 270 // viewer_fetcher 265 271 None, 266 272 // notification_fetcher 273 + None, 274 + // viewer_state_fetcher 267 275 ) 268 276 { 269 277 Ok(s) -> { ··· 354 362 // viewer_fetcher 355 363 None, 356 364 // notification_fetcher 365 + None, 366 + // viewer_state_fetcher 357 367 ) 358 368 { 359 369 Ok(s) -> {
+176
lexicon_graphql/test/viewer_state_test.gleam
··· 1 + /// Tests for viewer state field generation 2 + /// 3 + /// Verifies that viewer fields are generated for reverse join relationships 4 + import gleam/dict 5 + import gleam/option.{None, Some} 6 + import gleam/string 7 + import gleeunit/should 8 + import lexicon_graphql/schema/database as db_schema_builder 9 + import lexicon_graphql/types 10 + import swell/introspection 11 + import swell/schema 12 + import swell/sdl 13 + 14 + // Helper to create a test schema with a mock fetcher 15 + fn create_test_schema_from_lexicons( 16 + lexicons: List(types.Lexicon), 17 + ) -> schema.Schema { 18 + // Mock fetcher that returns empty results (we're only testing schema generation) 19 + let fetcher = fn(_collection, _params) { 20 + Ok(#([], option.None, False, False, option.None)) 21 + } 22 + 23 + case 24 + db_schema_builder.build_schema_with_fetcher( 25 + lexicons, 26 + fetcher, 27 + option.None, 28 + option.None, 29 + option.None, 30 + option.None, 31 + option.None, 32 + option.None, 33 + ) 34 + { 35 + Ok(s) -> s 36 + Error(_) -> panic as "Failed to build test schema" 37 + } 38 + } 39 + 40 + // Test lexicons 41 + fn gallery_lexicon() -> types.Lexicon { 42 + types.Lexicon( 43 + id: "social.grain.gallery", 44 + defs: types.Defs( 45 + main: Some( 46 + types.RecordDef(type_: "record", key: Some("tid"), properties: [ 47 + #( 48 + "title", 49 + types.Property( 50 + type_: "string", 51 + required: True, 52 + format: None, 53 + ref: None, 54 + refs: None, 55 + items: None, 56 + ), 57 + ), 58 + ]), 59 + ), 60 + others: dict.new(), 61 + ), 62 + ) 63 + } 64 + 65 + fn favorite_lexicon() -> types.Lexicon { 66 + types.Lexicon( 67 + id: "social.grain.favorite", 68 + defs: types.Defs( 69 + main: Some( 70 + types.RecordDef(type_: "record", key: Some("tid"), properties: [ 71 + #( 72 + "subject", 73 + types.Property( 74 + type_: "string", 75 + required: True, 76 + format: Some("at-uri"), 77 + ref: None, 78 + refs: None, 79 + items: None, 80 + ), 81 + ), 82 + ]), 83 + ), 84 + others: dict.new(), 85 + ), 86 + ) 87 + } 88 + 89 + fn follow_lexicon() -> types.Lexicon { 90 + types.Lexicon( 91 + id: "social.grain.graph.follow", 92 + defs: types.Defs( 93 + main: Some( 94 + types.RecordDef(type_: "record", key: Some("tid"), properties: [ 95 + #( 96 + "subject", 97 + types.Property( 98 + type_: "string", 99 + required: True, 100 + format: Some("did"), 101 + ref: None, 102 + refs: None, 103 + items: None, 104 + ), 105 + ), 106 + ]), 107 + ), 108 + others: dict.new(), 109 + ), 110 + ) 111 + } 112 + 113 + // Test that viewer field is generated for AT-URI reverse joins 114 + pub fn generates_viewer_field_for_reverse_join_test() { 115 + let lexicons = [gallery_lexicon(), favorite_lexicon()] 116 + 117 + let test_schema = create_test_schema_from_lexicons(lexicons) 118 + 119 + let all_types = introspection.get_all_schema_types(test_schema) 120 + let serialized = sdl.print_types(all_types) 121 + 122 + // Should have the regular reverse join field 123 + string.contains(serialized, "socialGrainFavoriteViaSubject") 124 + |> should.be_true 125 + 126 + // Should also have the viewer field 127 + string.contains(serialized, "viewerSocialGrainFavoriteViaSubject") 128 + |> should.be_true 129 + } 130 + 131 + // Test that viewer field is NOT a connection (returns single nullable object) 132 + pub fn viewer_field_returns_nullable_object_test() { 133 + let lexicons = [gallery_lexicon(), favorite_lexicon()] 134 + 135 + let test_schema = create_test_schema_from_lexicons(lexicons) 136 + 137 + let all_types = introspection.get_all_schema_types(test_schema) 138 + let serialized = sdl.print_types(all_types) 139 + 140 + // The regular reverse join returns a Connection 141 + string.contains( 142 + serialized, 143 + "socialGrainFavoriteViaSubject: SocialGrainFavoriteConnection", 144 + ) 145 + |> should.be_true 146 + 147 + // The viewer field should NOT return a Connection (just the type, nullable) 148 + string.contains( 149 + serialized, 150 + "viewerSocialGrainFavoriteViaSubject: SocialGrainFavorite", 151 + ) 152 + |> should.be_true 153 + 154 + // It should NOT contain Connection for the viewer field 155 + string.contains( 156 + serialized, 157 + "viewerSocialGrainFavoriteViaSubject: SocialGrainFavoriteConnection", 158 + ) 159 + |> should.be_false 160 + } 161 + 162 + // Test that viewer field is generated for DID-based subjects (e.g., follows) 163 + // Collections with format: "did" subjects should create viewer fields on all types 164 + pub fn generates_viewer_field_for_did_subject_test() { 165 + let lexicons = [gallery_lexicon(), follow_lexicon()] 166 + 167 + let test_schema = create_test_schema_from_lexicons(lexicons) 168 + 169 + let all_types = introspection.get_all_schema_types(test_schema) 170 + let serialized = sdl.print_types(all_types) 171 + 172 + // Gallery type should have the viewer follow field 173 + // The field shows whether the viewer follows the gallery author 174 + string.contains(serialized, "viewerSocialGrainGraphFollowViaSubject") 175 + |> should.be_true 176 + }
+1
lexicon_graphql/test/where_schema_test.gleam
··· 49 49 option.Some(aggregate_fetcher), 50 50 option.None, 51 51 option.None, 52 + option.None, 52 53 ) 53 54 { 54 55 Ok(s) -> s
+1 -1
server/gleam.toml
··· 37 37 gleam_crypto = ">= 1.5.1 and < 2.0.0" 38 38 logging = ">= 1.3.0 and < 2.0.0" 39 39 group_registry = ">= 1.0.0 and < 2.0.0" 40 - swell = ">= 2.1.3 and < 3.0.0" 40 + swell = ">= 2.1.4 and < 3.0.0" 41 41 honk = ">= 1.0.0 and < 2.0.0" 42 42 43 43 [dev-dependencies]
+2 -2
server/manifest.toml
··· 52 52 { name = "simplifile", version = "2.3.2", build_tools = ["gleam"], requirements = ["filepath", "gleam_stdlib"], otp_app = "simplifile", source = "hex", outer_checksum = "E049B4DACD4D206D87843BCF4C775A50AE0F50A52031A2FFB40C9ED07D6EC70A" }, 53 53 { name = "sqlight", version = "1.0.3", build_tools = ["gleam"], requirements = ["esqlite", "gleam_stdlib"], otp_app = "sqlight", source = "hex", outer_checksum = "CADD79663C9B61D4BAC960A47CC2D42CA8F48EAF5804DBEB79977287750F4B16" }, 54 54 { name = "ssl_verify_fun", version = "1.1.7", build_tools = ["mix", "rebar3", "make"], requirements = [], otp_app = "ssl_verify_fun", source = "hex", outer_checksum = "FE4C190E8F37401D30167C8C405EDA19469F34577987C76DDE613E838BBC67F8" }, 55 - { name = "swell", version = "2.1.3", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "swell", source = "hex", outer_checksum = "018FF9B3F775F101E72208B4E0EA15A670A51E59C83247DD0302C3AD8C2FE9FF" }, 55 + { name = "swell", version = "2.1.4", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "swell", source = "hex", outer_checksum = "E12BCF0F90886C3B0C641AA2BB69CE9EDEEF1293279256B75DEB14A03F2F8221" }, 56 56 { name = "telemetry", version = "1.3.0", build_tools = ["rebar3"], requirements = [], otp_app = "telemetry", source = "hex", outer_checksum = "7015FC8919DBE63764F4B4B87A95B7C0996BD539E0D499BE6EC9D7F3875B79E6" }, 57 57 { name = "unicode_util_compat", version = "0.7.1", build_tools = ["rebar3"], requirements = [], otp_app = "unicode_util_compat", source = "hex", outer_checksum = "B3A917854CE3AE233619744AD1E0102E05673136776FB2FA76234F3E03B23642" }, 58 58 { name = "wisp", version = "2.1.1", build_tools = ["gleam"], requirements = ["directories", "exception", "filepath", "gleam_crypto", "gleam_erlang", "gleam_http", "gleam_json", "gleam_stdlib", "houdini", "logging", "marceau", "mist", "simplifile"], otp_app = "wisp", source = "hex", outer_checksum = "46E2E31DECD61A3748CF6CB317D9AC432BBC8D8A6E65655A9E787BDC69389DE0" }, ··· 84 84 pog = { version = ">= 4.0.0 and < 5.0.0" } 85 85 simplifile = { version = ">= 2.0.0 and < 3.0.0" } 86 86 sqlight = { version = ">= 1.0.0 and < 2.0.0" } 87 - swell = { version = ">= 2.1.3 and < 3.0.0" } 87 + swell = { version = ">= 2.1.4 and < 3.0.0" } 88 88 wisp = { version = ">= 2.1.0 and < 3.0.0" }
+55
server/src/database/repositories/records.gleam
··· 1039 1039 )) 1040 1040 } 1041 1041 1042 + /// Get viewer state records - records owned by viewer_did where reference field matches parent keys 1043 + /// Used for viewer fields like viewerSocialGrainFavoriteViaSubject 1044 + pub fn get_viewer_state_records( 1045 + exec: Executor, 1046 + viewer_did: String, 1047 + collection: String, 1048 + field_name: String, 1049 + parent_keys: List(String), 1050 + ) -> Result(List(Record), DbError) { 1051 + case parent_keys { 1052 + [] -> Ok([]) 1053 + _ -> { 1054 + let key_count = list.length(parent_keys) 1055 + // Placeholder 1 is viewer_did 1056 + // Placeholder 2 is collection 1057 + // Placeholders 3 to key_count+2 are first set of keys (for direct match) 1058 + // Placeholders key_count+3 to 2*key_count+2 are second set (for strongRef match) 1059 + let placeholders1 = executor.placeholders(exec, key_count, 3) 1060 + let placeholders2 = executor.placeholders(exec, key_count, key_count + 3) 1061 + 1062 + // Use dialect-specific JSON extraction 1063 + let json_field = executor.json_extract(exec, "json", field_name) 1064 + let json_uri_field = 1065 + executor.json_extract_path(exec, "json", [field_name, "uri"]) 1066 + 1067 + let sql = 1068 + "SELECT " 1069 + <> record_columns(exec) 1070 + <> " FROM record WHERE did = " 1071 + <> executor.placeholder(exec, 1) 1072 + <> " AND collection = " 1073 + <> executor.placeholder(exec, 2) 1074 + <> " AND (" 1075 + <> json_field 1076 + <> " IN (" 1077 + <> placeholders1 1078 + <> ") OR " 1079 + <> json_uri_field 1080 + <> " IN (" 1081 + <> placeholders2 1082 + <> "))" 1083 + 1084 + // Build params: viewer_did + collection + parent_keys twice 1085 + let params: List(Value) = 1086 + list.flatten([ 1087 + [Text(viewer_did), Text(collection)], 1088 + list.map(parent_keys, Text), 1089 + list.map(parent_keys, Text), 1090 + ]) 1091 + 1092 + executor.query(exec, sql, params, record_decoder()) 1093 + } 1094 + } 1095 + } 1096 + 1042 1097 /// Get records by DIDs and collection (for DID joins / DataLoader) 1043 1098 /// Finds all records in a specific collection that belong to any of the given DIDs 1044 1099 /// Uses the idx_record_did_collection index for efficient lookup
+46
server/src/graphql/lexicon/fetchers.gleam
··· 349 349 } 350 350 } 351 351 352 + /// Create a viewer state fetcher for checking viewer relationships 353 + /// This fetches records owned by the viewer that reference parent keys 354 + pub fn viewer_state_fetcher(db: Executor) { 355 + fn( 356 + viewer_did: String, 357 + collection: String, 358 + reference_field: String, 359 + parent_keys: List(String), 360 + ) -> Result(dict.Dict(String, value.Value), String) { 361 + // Fetch records owned by viewer_did that reference any of the parent_keys 362 + case 363 + records.get_viewer_state_records( 364 + db, 365 + viewer_did, 366 + collection, 367 + reference_field, 368 + parent_keys, 369 + ) 370 + { 371 + Ok(record_list) -> { 372 + // Create a dict mapping parent_key -> single record (the viewer's record) 373 + let result = 374 + list.fold(record_list, dict.new(), fn(acc, record) { 375 + let graphql_value = converters.record_to_graphql_value(record, db) 376 + // Extract the reference field value to find which parent this belongs to 377 + case 378 + converters.extract_reference_uri(record.json, reference_field) 379 + { 380 + Ok(parent_key) -> { 381 + // Only keep first record per parent (there should only be one) 382 + case dict.has_key(acc, parent_key) { 383 + True -> acc 384 + False -> dict.insert(acc, parent_key, graphql_value) 385 + } 386 + } 387 + Error(_) -> acc 388 + } 389 + }) 390 + Ok(result) 391 + } 392 + Error(_) -> 393 + Error("Failed to fetch viewer state records for " <> collection) 394 + } 395 + } 396 + } 397 + 352 398 /// Create a notification fetcher for cross-collection DID mention queries 353 399 pub fn notification_fetcher(db: Executor) { 354 400 fn(
+37 -10
server/src/graphql/lexicon/schema.gleam
··· 2 2 /// 3 3 /// Public API for building and executing the lexicon-driven GraphQL schema. 4 4 /// External code should import this module for all lexicon GraphQL operations. 5 + import atproto_auth 5 6 import backfill 6 7 import database/executor.{type Executor} 7 8 import database/repositories/config as config_repo ··· 121 122 // Step 6: Create notification fetcher 122 123 let notification_fetcher = fetchers.notification_fetcher(db) 123 124 124 - // Step 7: Build schema with database-backed resolvers, mutations, and subscriptions 125 + // Step 7: Create viewer state fetcher 126 + let viewer_state_fetcher = fetchers.viewer_state_fetcher(db) 127 + 128 + // Step 8: Build schema with database-backed resolvers, mutations, and subscriptions 125 129 database.build_schema_with_subscriptions( 126 130 parsed_lexicons, 127 131 record_fetcher, ··· 134 138 option.Some(aggregate_fetcher), 135 139 option.Some(viewer_fetcher), 136 140 option.Some(notification_fetcher), 141 + option.Some(viewer_state_fetcher), 137 142 ) 138 143 } 139 144 } ··· 169 174 domain_authority, 170 175 )) 171 176 172 - // Create context with auth token if provided 173 - let ctx_data = case auth_token { 177 + // Convert json variables to Dict(String, value.Value) 178 + // SECURITY: Strip any client-provided viewer_did - it must come from auth token only 179 + let variables_dict = 180 + json_string_to_variables_dict(variables_json_str) 181 + |> dict.delete("viewer_did") 182 + 183 + // Extract viewer DID from auth token and add to variables 184 + // This is stored in variables (not ctx.data) because ctx.data gets 185 + // overwritten with parent values during field resolution 186 + let #(ctx_data, variables_with_viewer) = case auth_token { 174 187 Ok(token) -> { 175 - // Add auth token to context for mutation resolvers 176 - option.Some(value.Object([#("auth_token", value.String(token))])) 188 + case atproto_auth.verify_token(db, token) { 189 + Ok(user_info) -> { 190 + // Add viewer_did to variables for viewer state fields 191 + let vars_with_viewer = 192 + dict.insert( 193 + variables_dict, 194 + "viewer_did", 195 + value.String(user_info.did), 196 + ) 197 + // Keep auth_token in ctx.data for mutation resolvers 198 + let data = 199 + option.Some(value.Object([#("auth_token", value.String(token))])) 200 + #(data, vars_with_viewer) 201 + } 202 + Error(_) -> { 203 + // Token invalid/expired - allow query but without viewer context 204 + #(option.None, variables_dict) 205 + } 206 + } 177 207 } 178 - Error(_) -> option.None 208 + Error(_) -> #(option.None, variables_dict) 179 209 } 180 210 181 - // Convert json variables to Dict(String, value.Value) 182 - let variables_dict = json_string_to_variables_dict(variables_json_str) 183 - 184 - let ctx = schema.context_with_variables(ctx_data, variables_dict) 211 + let ctx = schema.context_with_variables(ctx_data, variables_with_viewer) 185 212 186 213 // Execute the query 187 214 use response <- result.try(swell_executor.execute(
+42 -1
server/src/handlers/graphql_ws.gleam
··· 1 1 /// GraphQL WebSocket Handler 2 2 /// 3 3 /// Handles WebSocket connections for GraphQL subscriptions using the graphql-ws protocol 4 + import atproto_auth 4 5 import database/executor.{type Executor} 5 6 import database/repositories/actors 6 7 import gleam/dict.{type Dict} ··· 175 176 subscription_subject: process.Subject(websocket_ffi.SubscriptionMessage), 176 177 // GraphQL schema for executing subscription queries 177 178 schema: schema.Schema, 179 + // Authenticated viewer DID (extracted from auth token) 180 + viewer_did: Option(String), 178 181 ) 179 182 } 180 183 ··· 188 191 plc_url: String, 189 192 domain_authority: String, 190 193 ) -> response.Response(ResponseData) { 194 + // Extract auth token from request headers before WebSocket upgrade 195 + let auth_token = case request.get_header(req, "authorization") { 196 + Ok(auth_header) -> { 197 + case string.starts_with(auth_header, "Bearer ") { 198 + True -> Some(string.drop_start(auth_header, 7)) 199 + False -> None 200 + } 201 + } 202 + Error(_) -> None 203 + } 204 + 205 + // Verify auth token and extract viewer DID 206 + let viewer_did = case auth_token { 207 + Some(token) -> { 208 + case atproto_auth.verify_token(db, token) { 209 + Ok(user_info) -> Some(user_info.did) 210 + Error(_) -> None 211 + } 212 + } 213 + None -> None 214 + } 215 + 191 216 mist.websocket( 192 217 request: req, 193 218 on_init: fn(conn) { ··· 231 256 conn: conn, 232 257 subscription_subject: subscription_subject, 233 258 schema: graphql_schema, 259 + viewer_did: viewer_did, 234 260 ) 235 261 236 262 #(state, Some(selector)) ··· 356 382 } 357 383 Ok(field_name) -> { 358 384 // Parse variables from JSON 359 - let variables = case variables_opt { 385 + // SECURITY: Strip any client-provided viewer_did - it must come from auth token only 386 + let base_variables = case variables_opt { 360 387 Some(vars_json) -> 361 388 lexicon_schema.json_string_to_variables_dict(vars_json) 389 + |> dict.delete("viewer_did") 362 390 None -> dict.new() 391 + } 392 + 393 + // Inject viewer_did from auth token into variables 394 + // This is stored in variables (not ctx.data) because ctx.data 395 + // gets overwritten with parent values during field resolution 396 + let variables = case state.viewer_did { 397 + Some(did) -> 398 + dict.insert( 399 + base_variables, 400 + "viewer_did", 401 + value.String(did), 402 + ) 403 + None -> base_variables 363 404 } 364 405 365 406 logging.log(
+2
server/test/groupby_enum_validation_test.gleam
··· 57 57 option.Some(stub_aggregate_fetcher), 58 58 option.None, 59 59 option.None, 60 + option.None, 60 61 ) 61 62 62 63 // Introspection query to check if AppBskyFeedPostGroupByField enum exists ··· 205 206 option.None, 206 207 option.None, 207 208 option.Some(stub_aggregate_fetcher), 209 + option.None, 208 210 option.None, 209 211 option.None, 210 212 )
+24
server/test/test_helpers.gleam
··· 320 320 use _ <- result.try(create_oauth_tables(exec)) 321 321 create_admin_session_table(exec) 322 322 } 323 + 324 + /// Insert a test token that maps to a DID for testing viewer authentication 325 + pub fn insert_test_token( 326 + exec: Executor, 327 + token: String, 328 + did: String, 329 + ) -> Result(Nil, DbError) { 330 + // First, insert a test client if it doesn't exist (required for foreign key) 331 + use _ <- result.try( 332 + executor.exec( 333 + exec, 334 + "INSERT OR IGNORE INTO oauth_client (client_id, client_name, redirect_uris, grant_types, response_types, token_endpoint_auth_method, client_type, created_at, updated_at) VALUES ('test-client', 'Test Client', '[]', '[]', '[]', 'none', 'public', 0, 0)", 335 + [], 336 + ), 337 + ) 338 + 339 + let far_future = 9_999_999_999 340 + // Won't expire 341 + executor.exec( 342 + exec, 343 + "INSERT INTO oauth_access_token (token, token_type, client_id, user_id, scope, created_at, expires_at, revoked) VALUES (?, 'Bearer', 'test-client', ?, 'atproto', 0, ?, 0)", 344 + [executor.Text(token), executor.Text(did), executor.Int(far_future)], 345 + ) 346 + }
+522
server/test/viewer_state_integration_test.gleam
··· 1 + /// Integration tests for viewer state fields 2 + /// 3 + /// Verifies that viewer fields show the authenticated viewer's relationship 4 + /// to records (e.g., viewer's like on a gallery) 5 + import database/repositories/lexicons 6 + import database/repositories/records 7 + import gleam/http 8 + import gleam/json 9 + import gleam/option.{None} 10 + import gleam/string 11 + import gleeunit/should 12 + import handlers/graphql as graphql_handler 13 + import lib/oauth/did_cache 14 + import test_helpers 15 + import wisp 16 + import wisp/simulate 17 + 18 + // Gallery lexicon with subject field for favorites 19 + fn create_gallery_lexicon() -> String { 20 + json.object([ 21 + #("lexicon", json.int(1)), 22 + #("id", json.string("social.grain.gallery")), 23 + #( 24 + "defs", 25 + json.object([ 26 + #( 27 + "main", 28 + json.object([ 29 + #("type", json.string("record")), 30 + #("key", json.string("tid")), 31 + #( 32 + "record", 33 + json.object([ 34 + #("type", json.string("object")), 35 + #( 36 + "required", 37 + json.array([json.string("title")], of: fn(x) { x }), 38 + ), 39 + #( 40 + "properties", 41 + json.object([ 42 + #("title", json.object([#("type", json.string("string"))])), 43 + ]), 44 + ), 45 + ]), 46 + ), 47 + ]), 48 + ), 49 + ]), 50 + ), 51 + ]) 52 + |> json.to_string 53 + } 54 + 55 + // Favorite lexicon with AT-URI subject field 56 + fn create_favorite_lexicon() -> String { 57 + json.object([ 58 + #("lexicon", json.int(1)), 59 + #("id", json.string("social.grain.favorite")), 60 + #( 61 + "defs", 62 + json.object([ 63 + #( 64 + "main", 65 + json.object([ 66 + #("type", json.string("record")), 67 + #("key", json.string("tid")), 68 + #( 69 + "record", 70 + json.object([ 71 + #("type", json.string("object")), 72 + #( 73 + "required", 74 + json.array([json.string("subject")], of: fn(x) { x }), 75 + ), 76 + #( 77 + "properties", 78 + json.object([ 79 + #( 80 + "subject", 81 + json.object([ 82 + #("type", json.string("string")), 83 + #("format", json.string("at-uri")), 84 + ]), 85 + ), 86 + ]), 87 + ), 88 + ]), 89 + ), 90 + ]), 91 + ), 92 + ]), 93 + ), 94 + ]) 95 + |> json.to_string 96 + } 97 + 98 + // Follow lexicon with DID subject field 99 + fn create_follow_lexicon() -> String { 100 + json.object([ 101 + #("lexicon", json.int(1)), 102 + #("id", json.string("social.grain.graph.follow")), 103 + #( 104 + "defs", 105 + json.object([ 106 + #( 107 + "main", 108 + json.object([ 109 + #("type", json.string("record")), 110 + #("key", json.string("tid")), 111 + #( 112 + "record", 113 + json.object([ 114 + #("type", json.string("object")), 115 + #( 116 + "required", 117 + json.array([json.string("subject")], of: fn(x) { x }), 118 + ), 119 + #( 120 + "properties", 121 + json.object([ 122 + #( 123 + "subject", 124 + json.object([ 125 + #("type", json.string("string")), 126 + #("format", json.string("did")), 127 + ]), 128 + ), 129 + ]), 130 + ), 131 + ]), 132 + ), 133 + ]), 134 + ), 135 + ]), 136 + ), 137 + ]) 138 + |> json.to_string 139 + } 140 + 141 + /// Test: Viewer favorite field returns null when not favorited 142 + pub fn viewer_favorite_null_when_not_favorited_test() { 143 + // Setup database 144 + let assert Ok(exec) = test_helpers.create_test_db() 145 + let assert Ok(_) = test_helpers.create_lexicon_table(exec) 146 + let assert Ok(_) = test_helpers.create_record_table(exec) 147 + let assert Ok(_) = test_helpers.create_config_table(exec) 148 + let assert Ok(_) = test_helpers.create_actor_table(exec) 149 + let assert Ok(_) = test_helpers.create_oauth_tables(exec) 150 + let assert Ok(_) = 151 + test_helpers.insert_test_token(exec, "test-viewer-token", "did:plc:viewer") 152 + 153 + // Insert lexicons 154 + let assert Ok(_) = 155 + lexicons.insert(exec, "social.grain.gallery", create_gallery_lexicon()) 156 + let assert Ok(_) = 157 + lexicons.insert(exec, "social.grain.favorite", create_favorite_lexicon()) 158 + 159 + // Create a gallery record 160 + let gallery_uri = "at://did:plc:author/social.grain.gallery/gallery1" 161 + let gallery_json = 162 + json.object([#("title", json.string("Test Gallery"))]) 163 + |> json.to_string 164 + 165 + let assert Ok(_) = 166 + records.insert( 167 + exec, 168 + gallery_uri, 169 + "cid1", 170 + "did:plc:author", 171 + "social.grain.gallery", 172 + gallery_json, 173 + ) 174 + 175 + // Query with auth token - should show null for viewerSocialGrainFavoriteViaSubject 176 + let query = 177 + json.object([ 178 + #( 179 + "query", 180 + json.string( 181 + "{ socialGrainGallery { edges { node { uri viewerSocialGrainFavoriteViaSubject { uri } } } } }", 182 + ), 183 + ), 184 + ]) 185 + |> json.to_string 186 + 187 + let request = 188 + simulate.request(http.Post, "/graphql") 189 + |> simulate.string_body(query) 190 + |> simulate.header("content-type", "application/json") 191 + |> simulate.header("authorization", "Bearer test-viewer-token") 192 + 193 + let assert Ok(cache) = did_cache.start() 194 + let response = 195 + graphql_handler.handle_graphql_request(request, exec, cache, None, "", "") 196 + 197 + let assert wisp.Text(body) = response.body 198 + 199 + // Debug: print the response if not 200 200 + case response.status { 201 + 200 -> Nil 202 + _ -> { 203 + // Print error for debugging 204 + should.fail() 205 + } 206 + } 207 + 208 + // Should contain the gallery URI 209 + string.contains(body, gallery_uri) |> should.be_true 210 + 211 + // The viewer field should be null since there's no favorite 212 + // JSON formatting may have spaces, so check for the field and null value 213 + string.contains(body, "viewerSocialGrainFavoriteViaSubject") 214 + |> should.be_true 215 + string.contains(body, "null") 216 + |> should.be_true 217 + } 218 + 219 + /// Test: Schema includes viewer favorite field and query succeeds 220 + pub fn viewer_favorite_schema_test() { 221 + // Setup database 222 + let assert Ok(exec) = test_helpers.create_test_db() 223 + let assert Ok(_) = test_helpers.create_lexicon_table(exec) 224 + let assert Ok(_) = test_helpers.create_record_table(exec) 225 + let assert Ok(_) = test_helpers.create_config_table(exec) 226 + let assert Ok(_) = test_helpers.create_actor_table(exec) 227 + let assert Ok(_) = test_helpers.create_oauth_tables(exec) 228 + let assert Ok(_) = 229 + test_helpers.insert_test_token(exec, "test-viewer-token", "did:plc:viewer") 230 + 231 + // Insert lexicons 232 + let assert Ok(_) = 233 + lexicons.insert(exec, "social.grain.gallery", create_gallery_lexicon()) 234 + let assert Ok(_) = 235 + lexicons.insert(exec, "social.grain.favorite", create_favorite_lexicon()) 236 + 237 + // Create a gallery record 238 + let gallery_uri = "at://did:plc:author/social.grain.gallery/gallery1" 239 + let gallery_json = 240 + json.object([#("title", json.string("Test Gallery"))]) 241 + |> json.to_string 242 + 243 + let assert Ok(_) = 244 + records.insert( 245 + exec, 246 + gallery_uri, 247 + "cid1", 248 + "did:plc:author", 249 + "social.grain.gallery", 250 + gallery_json, 251 + ) 252 + 253 + // Query WITH viewer field (verifies schema includes it) 254 + let query = 255 + json.object([ 256 + #( 257 + "query", 258 + json.string( 259 + "{ socialGrainGallery { edges { node { uri viewerSocialGrainFavoriteViaSubject { uri subject } } } } }", 260 + ), 261 + ), 262 + ]) 263 + |> json.to_string 264 + 265 + let request = 266 + simulate.request(http.Post, "/graphql") 267 + |> simulate.string_body(query) 268 + |> simulate.header("content-type", "application/json") 269 + |> simulate.header("authorization", "Bearer test-viewer-token") 270 + 271 + let assert Ok(cache) = did_cache.start() 272 + let response = 273 + graphql_handler.handle_graphql_request(request, exec, cache, None, "", "") 274 + 275 + let assert wisp.Text(body) = response.body 276 + 277 + // Query should succeed (schema generation works) 278 + response.status |> should.equal(200) 279 + 280 + // Should contain the gallery URI 281 + string.contains(body, gallery_uri) |> should.be_true 282 + 283 + // Viewer field should exist in response (currently null, data lookup needs work) 284 + string.contains(body, "viewerSocialGrainFavoriteViaSubject") 285 + |> should.be_true 286 + } 287 + 288 + /// Test: Viewer follow field returns null when not following 289 + pub fn viewer_follow_null_when_not_following_test() { 290 + // Setup database 291 + let assert Ok(exec) = test_helpers.create_test_db() 292 + let assert Ok(_) = test_helpers.create_lexicon_table(exec) 293 + let assert Ok(_) = test_helpers.create_record_table(exec) 294 + let assert Ok(_) = test_helpers.create_config_table(exec) 295 + let assert Ok(_) = test_helpers.create_actor_table(exec) 296 + let assert Ok(_) = test_helpers.create_oauth_tables(exec) 297 + let assert Ok(_) = 298 + test_helpers.insert_test_token(exec, "test-viewer-token", "did:plc:viewer") 299 + 300 + // Insert lexicons 301 + let assert Ok(_) = 302 + lexicons.insert(exec, "social.grain.gallery", create_gallery_lexicon()) 303 + let assert Ok(_) = 304 + lexicons.insert(exec, "social.grain.graph.follow", create_follow_lexicon()) 305 + 306 + // Create a gallery record by a different author 307 + let gallery_uri = "at://did:plc:author/social.grain.gallery/gallery1" 308 + let gallery_json = 309 + json.object([#("title", json.string("Author's Gallery"))]) 310 + |> json.to_string 311 + 312 + let assert Ok(_) = 313 + records.insert( 314 + exec, 315 + gallery_uri, 316 + "cid1", 317 + "did:plc:author", 318 + "social.grain.gallery", 319 + gallery_json, 320 + ) 321 + 322 + // Query with auth token - should show null for viewerSocialGrainGraphFollowViaSubject 323 + let query = 324 + json.object([ 325 + #( 326 + "query", 327 + json.string( 328 + "{ socialGrainGallery { edges { node { uri did viewerSocialGrainGraphFollowViaSubject { uri } } } } }", 329 + ), 330 + ), 331 + ]) 332 + |> json.to_string 333 + 334 + let request = 335 + simulate.request(http.Post, "/graphql") 336 + |> simulate.string_body(query) 337 + |> simulate.header("content-type", "application/json") 338 + |> simulate.header("authorization", "Bearer test-viewer-token") 339 + 340 + let assert Ok(cache) = did_cache.start() 341 + let response = 342 + graphql_handler.handle_graphql_request(request, exec, cache, None, "", "") 343 + 344 + let assert wisp.Text(body) = response.body 345 + 346 + response.status |> should.equal(200) 347 + 348 + // Should contain the gallery URI 349 + string.contains(body, gallery_uri) |> should.be_true 350 + 351 + // The viewer follow field should be null since viewer doesn't follow the author 352 + string.contains(body, "viewerSocialGrainGraphFollowViaSubject") 353 + |> should.be_true 354 + string.contains(body, "null") 355 + |> should.be_true 356 + } 357 + 358 + /// Test: Viewer follow field returns follow when viewer follows the author 359 + pub fn viewer_follow_returns_follow_when_following_test() { 360 + // Setup database 361 + let assert Ok(exec) = test_helpers.create_test_db() 362 + let assert Ok(_) = test_helpers.create_lexicon_table(exec) 363 + let assert Ok(_) = test_helpers.create_record_table(exec) 364 + let assert Ok(_) = test_helpers.create_config_table(exec) 365 + let assert Ok(_) = test_helpers.create_actor_table(exec) 366 + let assert Ok(_) = test_helpers.create_oauth_tables(exec) 367 + let assert Ok(_) = 368 + test_helpers.insert_test_token(exec, "test-viewer-token", "did:plc:viewer") 369 + 370 + // Insert lexicons 371 + let assert Ok(_) = 372 + lexicons.insert(exec, "social.grain.gallery", create_gallery_lexicon()) 373 + let assert Ok(_) = 374 + lexicons.insert(exec, "social.grain.graph.follow", create_follow_lexicon()) 375 + 376 + // Create a gallery record by a different author 377 + let gallery_uri = "at://did:plc:author/social.grain.gallery/gallery1" 378 + let gallery_json = 379 + json.object([#("title", json.string("Author's Gallery"))]) 380 + |> json.to_string 381 + 382 + let assert Ok(_) = 383 + records.insert( 384 + exec, 385 + gallery_uri, 386 + "cid1", 387 + "did:plc:author", 388 + "social.grain.gallery", 389 + gallery_json, 390 + ) 391 + 392 + // Create a follow record from the viewer to the author 393 + let follow_uri = "at://did:plc:viewer/social.grain.graph.follow/follow1" 394 + let follow_json = 395 + json.object([#("subject", json.string("did:plc:author"))]) 396 + |> json.to_string 397 + 398 + let assert Ok(_) = 399 + records.insert( 400 + exec, 401 + follow_uri, 402 + "cid2", 403 + "did:plc:viewer", 404 + "social.grain.graph.follow", 405 + follow_json, 406 + ) 407 + 408 + // Query with auth token only (no variables) 409 + let query = 410 + json.object([ 411 + #( 412 + "query", 413 + json.string( 414 + "{ socialGrainGallery { edges { node { uri did viewerSocialGrainGraphFollowViaSubject { uri } } } } }", 415 + ), 416 + ), 417 + ]) 418 + |> json.to_string 419 + 420 + let request = 421 + simulate.request(http.Post, "/graphql") 422 + |> simulate.string_body(query) 423 + |> simulate.header("content-type", "application/json") 424 + |> simulate.header("authorization", "Bearer test-viewer-token") 425 + 426 + let assert Ok(cache) = did_cache.start() 427 + let response = 428 + graphql_handler.handle_graphql_request(request, exec, cache, None, "", "") 429 + 430 + let assert wisp.Text(body) = response.body 431 + 432 + response.status |> should.equal(200) 433 + 434 + // Should contain the gallery URI 435 + string.contains(body, gallery_uri) |> should.be_true 436 + 437 + // The viewer follow field should contain the follow URI 438 + string.contains(body, follow_uri) |> should.be_true 439 + } 440 + 441 + /// Test: Viewer favorite field returns favorite when viewer has favorited (AT-URI subject) 442 + pub fn viewer_favorite_returns_favorite_when_favorited_test() { 443 + // Setup database 444 + let assert Ok(exec) = test_helpers.create_test_db() 445 + let assert Ok(_) = test_helpers.create_lexicon_table(exec) 446 + let assert Ok(_) = test_helpers.create_record_table(exec) 447 + let assert Ok(_) = test_helpers.create_config_table(exec) 448 + let assert Ok(_) = test_helpers.create_actor_table(exec) 449 + let assert Ok(_) = test_helpers.create_oauth_tables(exec) 450 + let assert Ok(_) = 451 + test_helpers.insert_test_token(exec, "test-viewer-token", "did:plc:viewer") 452 + 453 + // Insert lexicons 454 + let assert Ok(_) = 455 + lexicons.insert(exec, "social.grain.gallery", create_gallery_lexicon()) 456 + let assert Ok(_) = 457 + lexicons.insert(exec, "social.grain.favorite", create_favorite_lexicon()) 458 + 459 + // Create a gallery record 460 + let gallery_uri = "at://did:plc:author/social.grain.gallery/gallery1" 461 + let gallery_json = 462 + json.object([#("title", json.string("Test Gallery"))]) 463 + |> json.to_string 464 + 465 + let assert Ok(_) = 466 + records.insert( 467 + exec, 468 + gallery_uri, 469 + "cid1", 470 + "did:plc:author", 471 + "social.grain.gallery", 472 + gallery_json, 473 + ) 474 + 475 + // Create a favorite record from the viewer for the gallery 476 + let favorite_uri = "at://did:plc:viewer/social.grain.favorite/fav1" 477 + let favorite_json = 478 + json.object([#("subject", json.string(gallery_uri))]) 479 + |> json.to_string 480 + 481 + let assert Ok(_) = 482 + records.insert( 483 + exec, 484 + favorite_uri, 485 + "cid2", 486 + "did:plc:viewer", 487 + "social.grain.favorite", 488 + favorite_json, 489 + ) 490 + 491 + // Query with auth token 492 + let query = 493 + json.object([ 494 + #( 495 + "query", 496 + json.string( 497 + "{ socialGrainGallery { edges { node { uri viewerSocialGrainFavoriteViaSubject { uri subject } } } } }", 498 + ), 499 + ), 500 + ]) 501 + |> json.to_string 502 + 503 + let request = 504 + simulate.request(http.Post, "/graphql") 505 + |> simulate.string_body(query) 506 + |> simulate.header("content-type", "application/json") 507 + |> simulate.header("authorization", "Bearer test-viewer-token") 508 + 509 + let assert Ok(cache) = did_cache.start() 510 + let response = 511 + graphql_handler.handle_graphql_request(request, exec, cache, None, "", "") 512 + 513 + let assert wisp.Text(body) = response.body 514 + 515 + response.status |> should.equal(200) 516 + 517 + // Should contain the gallery URI 518 + string.contains(body, gallery_uri) |> should.be_true 519 + 520 + // The viewer favorite field should contain the favorite URI 521 + string.contains(body, favorite_uri) |> should.be_true 522 + }