Auto-indexing service and GraphQL API for AT Protocol Records

refactor: reorganize admin GraphQL schema into modular structure

Split client_schema.gleam (2060 lines) into 5 logical modules:
- graphql/admin/types.gleam - Object types + get_field() helper
- graphql/admin/converters.gleam - Domain-to-GraphQL transformations
- graphql/admin/queries.gleam - Query resolvers
- graphql/admin/mutations.gleam - Mutation resolvers
- graphql/admin/schema.gleam - Public build_schema entry point

Additional improvements:
- Add TimeRange Gleam type with parser for type-safe enum handling
- DRY up activityBuckets resolver using the new type
- Rename handler from client_graphql to admin_graphql
- Remove redundant is_admin wrapper functions
- Fix import consistency in converters module

+1671 -980
+3
.gitignore
··· 15 15 .lustre 16 16 build/ 17 17 node_modules/ 18 + 19 + # Git worktrees 20 + .worktrees/
+941
dev-docs/plans/2025-12-10-admin-graphql-refactor-design.md
··· 1 + # Admin GraphQL Schema Refactor Implementation Plan 2 + 3 + > **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. 4 + 5 + **Goal:** Break up `server/src/client_schema.gleam` (2060 lines) into 5 logical modules for improved readability and maintainability. 6 + 7 + **Architecture:** Split by concern - types, converters, queries, mutations, and schema entry point. Add a `get_field()` helper to eliminate ~270 lines of boilerplate in type resolvers. 8 + 9 + **Tech Stack:** Gleam, swell (GraphQL library) 10 + 11 + --- 12 + 13 + ### Task 1: Create types.gleam with get_field helper 14 + 15 + **Files:** 16 + - Create: `server/src/graphql/admin/types.gleam` 17 + 18 + **Step 1: Create the directory and types.gleam file** 19 + 20 + ```gleam 21 + /// GraphQL type definitions for admin API 22 + /// 23 + /// Contains all object types, enum types, and the get_field helper 24 + import gleam/list 25 + import gleam/option.{type Option, Some} 26 + import swell/schema 27 + import swell/value 28 + 29 + /// Extract a field from GraphQL context data, returning Null if not found 30 + pub fn get_field(ctx: schema.Context, field_name: String) -> value.Value { 31 + case ctx.data { 32 + Some(value.Object(fields)) -> { 33 + case list.key_find(fields, field_name) { 34 + Ok(v) -> v 35 + Error(_) -> value.Null 36 + } 37 + } 38 + _ -> value.Null 39 + } 40 + } 41 + 42 + /// TimeRange enum for activity queries 43 + pub fn time_range_enum() -> schema.Type { 44 + schema.enum_type("TimeRange", "Time range for activity data", [ 45 + schema.enum_value("ONE_HOUR", "Last 1 hour (5-min buckets)"), 46 + schema.enum_value("THREE_HOURS", "Last 3 hours (15-min buckets)"), 47 + schema.enum_value("SIX_HOURS", "Last 6 hours (30-min buckets)"), 48 + schema.enum_value("ONE_DAY", "Last 24 hours (1-hour buckets)"), 49 + schema.enum_value("SEVEN_DAYS", "Last 7 days (daily buckets)"), 50 + ]) 51 + } 52 + 53 + /// Statistics type showing record, actor, and lexicon counts 54 + pub fn statistics_type() -> schema.Type { 55 + schema.object_type("Statistics", "System statistics", [ 56 + schema.field( 57 + "recordCount", 58 + schema.non_null(schema.int_type()), 59 + "Total number of records", 60 + fn(ctx) { Ok(get_field(ctx, "recordCount")) }, 61 + ), 62 + schema.field( 63 + "actorCount", 64 + schema.non_null(schema.int_type()), 65 + "Total number of actors", 66 + fn(ctx) { Ok(get_field(ctx, "actorCount")) }, 67 + ), 68 + schema.field( 69 + "lexiconCount", 70 + schema.non_null(schema.int_type()), 71 + "Total number of lexicons", 72 + fn(ctx) { Ok(get_field(ctx, "lexiconCount")) }, 73 + ), 74 + ]) 75 + } 76 + 77 + /// CurrentSession type for authenticated user information 78 + pub fn current_session_type() -> schema.Type { 79 + schema.object_type("CurrentSession", "Current authenticated user session", [ 80 + schema.field( 81 + "did", 82 + schema.non_null(schema.string_type()), 83 + "User's DID", 84 + fn(ctx) { Ok(get_field(ctx, "did")) }, 85 + ), 86 + schema.field( 87 + "handle", 88 + schema.non_null(schema.string_type()), 89 + "User's handle", 90 + fn(ctx) { Ok(get_field(ctx, "handle")) }, 91 + ), 92 + schema.field( 93 + "isAdmin", 94 + schema.non_null(schema.boolean_type()), 95 + "Whether the user is an admin", 96 + fn(ctx) { Ok(get_field(ctx, "isAdmin")) }, 97 + ), 98 + ]) 99 + } 100 + 101 + /// ActivityBucket type for aggregated activity data 102 + pub fn activity_bucket_type() -> schema.Type { 103 + schema.object_type("ActivityBucket", "Time-bucketed activity counts", [ 104 + schema.field( 105 + "timestamp", 106 + schema.non_null(schema.string_type()), 107 + "Bucket timestamp", 108 + fn(ctx) { Ok(get_field(ctx, "timestamp")) }, 109 + ), 110 + schema.field( 111 + "total", 112 + schema.non_null(schema.int_type()), 113 + "Total operations in bucket", 114 + fn(ctx) { Ok(get_field(ctx, "total")) }, 115 + ), 116 + schema.field( 117 + "creates", 118 + schema.non_null(schema.int_type()), 119 + "Create operations", 120 + fn(ctx) { Ok(get_field(ctx, "creates")) }, 121 + ), 122 + schema.field( 123 + "updates", 124 + schema.non_null(schema.int_type()), 125 + "Update operations", 126 + fn(ctx) { Ok(get_field(ctx, "updates")) }, 127 + ), 128 + schema.field( 129 + "deletes", 130 + schema.non_null(schema.int_type()), 131 + "Delete operations", 132 + fn(ctx) { Ok(get_field(ctx, "deletes")) }, 133 + ), 134 + ]) 135 + } 136 + 137 + /// Lexicon type for AT Protocol lexicon schemas 138 + pub fn lexicon_type() -> schema.Type { 139 + schema.object_type("Lexicon", "AT Protocol lexicon schema definition", [ 140 + schema.field( 141 + "id", 142 + schema.non_null(schema.string_type()), 143 + "Lexicon NSID (e.g., app.bsky.feed.post)", 144 + fn(ctx) { Ok(get_field(ctx, "id")) }, 145 + ), 146 + schema.field( 147 + "json", 148 + schema.non_null(schema.string_type()), 149 + "Full lexicon JSON content", 150 + fn(ctx) { Ok(get_field(ctx, "json")) }, 151 + ), 152 + schema.field( 153 + "createdAt", 154 + schema.non_null(schema.string_type()), 155 + "Timestamp when lexicon was created", 156 + fn(ctx) { Ok(get_field(ctx, "createdAt")) }, 157 + ), 158 + ]) 159 + } 160 + 161 + /// Settings type for configuration 162 + pub fn settings_type() -> schema.Type { 163 + schema.object_type("Settings", "System settings and configuration", [ 164 + schema.field( 165 + "id", 166 + schema.non_null(schema.string_type()), 167 + "Global ID for normalization", 168 + fn(_ctx) { Ok(value.String("Settings:singleton")) }, 169 + ), 170 + schema.field( 171 + "domainAuthority", 172 + schema.non_null(schema.string_type()), 173 + "Domain authority configuration", 174 + fn(ctx) { Ok(get_field(ctx, "domainAuthority")) }, 175 + ), 176 + schema.field( 177 + "adminDids", 178 + schema.non_null(schema.list_type(schema.non_null(schema.string_type()))), 179 + "List of admin DIDs", 180 + fn(ctx) { 181 + case get_field(ctx, "adminDids") { 182 + value.Null -> Ok(value.List([])) 183 + other -> Ok(other) 184 + } 185 + }, 186 + ), 187 + schema.field( 188 + "relayUrl", 189 + schema.non_null(schema.string_type()), 190 + "AT Protocol relay URL for backfill operations", 191 + fn(ctx) { Ok(get_field(ctx, "relayUrl")) }, 192 + ), 193 + schema.field( 194 + "plcDirectoryUrl", 195 + schema.non_null(schema.string_type()), 196 + "PLC directory URL for DID resolution", 197 + fn(ctx) { Ok(get_field(ctx, "plcDirectoryUrl")) }, 198 + ), 199 + schema.field( 200 + "jetstreamUrl", 201 + schema.non_null(schema.string_type()), 202 + "Jetstream WebSocket endpoint for real-time indexing", 203 + fn(ctx) { Ok(get_field(ctx, "jetstreamUrl")) }, 204 + ), 205 + schema.field( 206 + "oauthSupportedScopes", 207 + schema.non_null(schema.string_type()), 208 + "Space-separated OAuth scopes supported by this server", 209 + fn(ctx) { Ok(get_field(ctx, "oauthSupportedScopes")) }, 210 + ), 211 + ]) 212 + } 213 + 214 + /// OAuthClient type for client registration management 215 + pub fn oauth_client_type() -> schema.Type { 216 + schema.object_type("OAuthClient", "OAuth client registration", [ 217 + schema.field( 218 + "clientId", 219 + schema.non_null(schema.string_type()), 220 + "Client ID", 221 + fn(ctx) { Ok(get_field(ctx, "clientId")) }, 222 + ), 223 + schema.field( 224 + "clientSecret", 225 + schema.string_type(), 226 + "Client secret (confidential clients only)", 227 + fn(ctx) { Ok(get_field(ctx, "clientSecret")) }, 228 + ), 229 + schema.field( 230 + "clientName", 231 + schema.non_null(schema.string_type()), 232 + "Client display name", 233 + fn(ctx) { Ok(get_field(ctx, "clientName")) }, 234 + ), 235 + schema.field( 236 + "clientType", 237 + schema.non_null(schema.string_type()), 238 + "PUBLIC or CONFIDENTIAL", 239 + fn(ctx) { Ok(get_field(ctx, "clientType")) }, 240 + ), 241 + schema.field( 242 + "redirectUris", 243 + schema.non_null(schema.list_type(schema.non_null(schema.string_type()))), 244 + "Allowed redirect URIs", 245 + fn(ctx) { Ok(get_field(ctx, "redirectUris")) }, 246 + ), 247 + schema.field( 248 + "scope", 249 + schema.string_type(), 250 + "OAuth scopes for this client (space-separated)", 251 + fn(ctx) { Ok(get_field(ctx, "scope")) }, 252 + ), 253 + schema.field( 254 + "createdAt", 255 + schema.non_null(schema.int_type()), 256 + "Creation timestamp", 257 + fn(ctx) { Ok(get_field(ctx, "createdAt")) }, 258 + ), 259 + ]) 260 + } 261 + 262 + /// ActivityEntry type for individual activity records 263 + pub fn activity_entry_type() -> schema.Type { 264 + schema.object_type("ActivityEntry", "Individual activity log entry", [ 265 + schema.field( 266 + "id", 267 + schema.non_null(schema.int_type()), 268 + "Entry ID", 269 + fn(ctx) { Ok(get_field(ctx, "id")) }, 270 + ), 271 + schema.field( 272 + "timestamp", 273 + schema.non_null(schema.string_type()), 274 + "Timestamp", 275 + fn(ctx) { Ok(get_field(ctx, "timestamp")) }, 276 + ), 277 + schema.field( 278 + "operation", 279 + schema.non_null(schema.string_type()), 280 + "Operation type", 281 + fn(ctx) { Ok(get_field(ctx, "operation")) }, 282 + ), 283 + schema.field( 284 + "collection", 285 + schema.non_null(schema.string_type()), 286 + "Collection name", 287 + fn(ctx) { Ok(get_field(ctx, "collection")) }, 288 + ), 289 + schema.field( 290 + "did", 291 + schema.non_null(schema.string_type()), 292 + "DID", 293 + fn(ctx) { Ok(get_field(ctx, "did")) }, 294 + ), 295 + schema.field( 296 + "status", 297 + schema.non_null(schema.string_type()), 298 + "Processing status", 299 + fn(ctx) { Ok(get_field(ctx, "status")) }, 300 + ), 301 + schema.field( 302 + "errorMessage", 303 + schema.string_type(), 304 + "Error message if failed", 305 + fn(ctx) { Ok(get_field(ctx, "errorMessage")) }, 306 + ), 307 + schema.field( 308 + "eventJson", 309 + schema.string_type(), 310 + "Raw event JSON", 311 + fn(ctx) { Ok(get_field(ctx, "eventJson")) }, 312 + ), 313 + ]) 314 + } 315 + ``` 316 + 317 + **Step 2: Run gleam check to verify syntax** 318 + 319 + Run: `cd /Users/chadmiller/code/quickslice/server && gleam check` 320 + Expected: No errors for types.gleam 321 + 322 + **Step 3: Commit** 323 + 324 + ```bash 325 + git add src/graphql/admin/types.gleam 326 + git commit -m "refactor: extract admin GraphQL types to separate module" 327 + ``` 328 + 329 + --- 330 + 331 + ### Task 2: Create converters.gleam 332 + 333 + **Files:** 334 + - Create: `server/src/graphql/admin/converters.gleam` 335 + 336 + **Step 1: Create converters.gleam with all *_to_value functions** 337 + 338 + ```gleam 339 + /// Value converters for admin GraphQL API 340 + /// 341 + /// Transform domain types to GraphQL value.Value objects 342 + import database/types.{type ActivityBucket, type ActivityEntry, type Lexicon} 343 + import gleam/list 344 + import gleam/option.{None, Some} 345 + import swell/value 346 + 347 + /// Convert CurrentSession data to GraphQL value 348 + pub fn current_session_to_value( 349 + did: String, 350 + handle: String, 351 + is_admin: Bool, 352 + ) -> value.Value { 353 + value.Object([ 354 + #("did", value.String(did)), 355 + #("handle", value.String(handle)), 356 + #("isAdmin", value.Boolean(is_admin)), 357 + ]) 358 + } 359 + 360 + /// Convert statistics counts to GraphQL value 361 + pub fn statistics_to_value( 362 + record_count: Int, 363 + actor_count: Int, 364 + lexicon_count: Int, 365 + ) -> value.Value { 366 + value.Object([ 367 + #("recordCount", value.Int(record_count)), 368 + #("actorCount", value.Int(actor_count)), 369 + #("lexiconCount", value.Int(lexicon_count)), 370 + ]) 371 + } 372 + 373 + /// Convert ActivityBucket domain type to GraphQL value 374 + pub fn activity_bucket_to_value(bucket: ActivityBucket) -> value.Value { 375 + let total = bucket.create_count + bucket.update_count + bucket.delete_count 376 + value.Object([ 377 + #("timestamp", value.String(bucket.timestamp)), 378 + #("total", value.Int(total)), 379 + #("creates", value.Int(bucket.create_count)), 380 + #("updates", value.Int(bucket.update_count)), 381 + #("deletes", value.Int(bucket.delete_count)), 382 + ]) 383 + } 384 + 385 + /// Convert ActivityEntry domain type to GraphQL value 386 + pub fn activity_entry_to_value(entry: ActivityEntry) -> value.Value { 387 + let error_msg_value = case entry.error_message { 388 + Some(msg) -> value.String(msg) 389 + None -> value.Null 390 + } 391 + 392 + value.Object([ 393 + #("id", value.Int(entry.id)), 394 + #("timestamp", value.String(entry.timestamp)), 395 + #("operation", value.String(entry.operation)), 396 + #("collection", value.String(entry.collection)), 397 + #("did", value.String(entry.did)), 398 + #("status", value.String(entry.status)), 399 + #("errorMessage", error_msg_value), 400 + #("eventJson", value.String(entry.event_json)), 401 + ]) 402 + } 403 + 404 + /// Convert Settings data to GraphQL value 405 + pub fn settings_to_value( 406 + domain_authority: String, 407 + admin_dids: List(String), 408 + relay_url: String, 409 + plc_directory_url: String, 410 + jetstream_url: String, 411 + oauth_supported_scopes: String, 412 + ) -> value.Value { 413 + value.Object([ 414 + #("id", value.String("Settings:singleton")), 415 + #("domainAuthority", value.String(domain_authority)), 416 + #("adminDids", value.List(list.map(admin_dids, value.String))), 417 + #("relayUrl", value.String(relay_url)), 418 + #("plcDirectoryUrl", value.String(plc_directory_url)), 419 + #("jetstreamUrl", value.String(jetstream_url)), 420 + #("oauthSupportedScopes", value.String(oauth_supported_scopes)), 421 + ]) 422 + } 423 + 424 + /// Convert OAuthClient domain type to GraphQL value 425 + pub fn oauth_client_to_value(client: types.OAuthClient) -> value.Value { 426 + let secret_value = case client.client_secret { 427 + Some(s) -> value.String(s) 428 + None -> value.Null 429 + } 430 + let scope_value = case client.scope { 431 + Some(s) -> value.String(s) 432 + None -> value.Null 433 + } 434 + value.Object([ 435 + #("clientId", value.String(client.client_id)), 436 + #("clientSecret", secret_value), 437 + #("clientName", value.String(client.client_name)), 438 + #( 439 + "clientType", 440 + value.String(types.client_type_to_string(client.client_type)), 441 + ), 442 + #("redirectUris", value.List(list.map(client.redirect_uris, value.String))), 443 + #("scope", scope_value), 444 + #("createdAt", value.Int(client.created_at)), 445 + ]) 446 + } 447 + 448 + /// Convert Lexicon domain type to GraphQL value 449 + pub fn lexicon_to_value(lexicon: Lexicon) -> value.Value { 450 + value.Object([ 451 + #("id", value.String(lexicon.id)), 452 + #("json", value.String(lexicon.json)), 453 + #("createdAt", value.String(lexicon.created_at)), 454 + ]) 455 + } 456 + ``` 457 + 458 + **Step 2: Run gleam check** 459 + 460 + Run: `cd /Users/chadmiller/code/quickslice/server && gleam check` 461 + Expected: No errors 462 + 463 + **Step 3: Commit** 464 + 465 + ```bash 466 + git add src/graphql/admin/converters.gleam 467 + git commit -m "refactor: extract admin GraphQL converters to separate module" 468 + ``` 469 + 470 + --- 471 + 472 + ### Task 3: Create queries.gleam 473 + 474 + **Files:** 475 + - Create: `server/src/graphql/admin/queries.gleam` 476 + 477 + **Step 1: Create queries.gleam with query_type function** 478 + 479 + ```gleam 480 + /// Query resolvers for admin GraphQL API 481 + import admin_session as session 482 + import backfill_state 483 + import database/repositories/actors 484 + import database/repositories/config as config_repo 485 + import database/repositories/jetstream_activity 486 + import database/repositories/lexicons 487 + import database/repositories/oauth_clients 488 + import database/repositories/records 489 + import gleam/erlang/process.{type Subject} 490 + import gleam/list 491 + import gleam/option.{None, Some} 492 + import gleam/otp/actor 493 + import graphql/admin/converters 494 + import graphql/admin/types as admin_types 495 + import lib/oauth/did_cache 496 + import sqlight 497 + import swell/schema 498 + import swell/value 499 + import wisp 500 + 501 + /// Check if a DID is in the admin list 502 + fn is_admin(conn: sqlight.Connection, did: String) -> Bool { 503 + config_repo.is_admin(conn, did) 504 + } 505 + 506 + /// Build the Query root type with all query resolvers 507 + pub fn query_type( 508 + conn: sqlight.Connection, 509 + req: wisp.Request, 510 + did_cache: Subject(did_cache.Message), 511 + backfill_state_subject: Subject(backfill_state.Message), 512 + ) -> schema.Type { 513 + schema.object_type("Query", "Root query type", [ 514 + // currentSession query 515 + schema.field( 516 + "currentSession", 517 + admin_types.current_session_type(), 518 + "Get current authenticated user session (null if not authenticated)", 519 + fn(_ctx) { 520 + case session.get_current_session(req, conn, did_cache) { 521 + Ok(sess) -> { 522 + let user_is_admin = is_admin(conn, sess.did) 523 + Ok(converters.current_session_to_value( 524 + sess.did, 525 + sess.handle, 526 + user_is_admin, 527 + )) 528 + } 529 + Error(_) -> Ok(value.Null) 530 + } 531 + }, 532 + ), 533 + // statistics query 534 + schema.field( 535 + "statistics", 536 + schema.non_null(admin_types.statistics_type()), 537 + "Get system statistics", 538 + fn(_ctx) { 539 + case 540 + records.get_count(conn), 541 + actors.get_count(conn), 542 + lexicons.get_count(conn) 543 + { 544 + Ok(record_count), Ok(actor_count), Ok(lexicon_count) -> { 545 + Ok(converters.statistics_to_value( 546 + record_count, 547 + actor_count, 548 + lexicon_count, 549 + )) 550 + } 551 + _, _, _ -> Error("Failed to fetch statistics") 552 + } 553 + }, 554 + ), 555 + // settings query 556 + schema.field( 557 + "settings", 558 + schema.non_null(admin_types.settings_type()), 559 + "Get system settings", 560 + fn(_ctx) { 561 + let domain_authority = case config_repo.get(conn, "domain_authority") { 562 + Ok(authority) -> authority 563 + Error(_) -> "" 564 + } 565 + let admin_dids = config_repo.get_admin_dids(conn) 566 + let relay_url = config_repo.get_relay_url(conn) 567 + let plc_directory_url = config_repo.get_plc_directory_url(conn) 568 + let jetstream_url = config_repo.get_jetstream_url(conn) 569 + let oauth_supported_scopes = config_repo.get_oauth_supported_scopes(conn) 570 + 571 + Ok(converters.settings_to_value( 572 + domain_authority, 573 + admin_dids, 574 + relay_url, 575 + plc_directory_url, 576 + jetstream_url, 577 + oauth_supported_scopes, 578 + )) 579 + }, 580 + ), 581 + // isBackfilling query 582 + schema.field( 583 + "isBackfilling", 584 + schema.non_null(schema.boolean_type()), 585 + "Check if a backfill operation is currently running", 586 + fn(_ctx) { 587 + let is_backfilling = 588 + actor.call( 589 + backfill_state_subject, 590 + waiting: 100, 591 + sending: backfill_state.IsBackfilling, 592 + ) 593 + Ok(value.Boolean(is_backfilling)) 594 + }, 595 + ), 596 + // lexicons query 597 + schema.field( 598 + "lexicons", 599 + schema.non_null(schema.list_type(schema.non_null(admin_types.lexicon_type()))), 600 + "Get all lexicons", 601 + fn(_ctx) { 602 + case lexicons.get_all(conn) { 603 + Ok(lexicon_list) -> 604 + Ok(value.List(list.map(lexicon_list, converters.lexicon_to_value))) 605 + Error(_) -> Error("Failed to fetch lexicons") 606 + } 607 + }, 608 + ), 609 + // oauthClients query (admin only) 610 + schema.field( 611 + "oauthClients", 612 + schema.non_null(schema.list_type(schema.non_null(admin_types.oauth_client_type()))), 613 + "Get all OAuth client registrations (admin only)", 614 + fn(_ctx) { 615 + case session.get_current_session(req, conn, did_cache) { 616 + Ok(sess) -> { 617 + case is_admin(conn, sess.did) { 618 + True -> { 619 + case oauth_clients.get_all(conn) { 620 + Ok(clients) -> 621 + Ok(value.List(list.map(clients, converters.oauth_client_to_value))) 622 + Error(_) -> Error("Failed to fetch OAuth clients") 623 + } 624 + } 625 + False -> Error("Admin privileges required") 626 + } 627 + } 628 + Error(_) -> Error("Authentication required") 629 + } 630 + }, 631 + ), 632 + // activityBuckets query with TimeRange argument 633 + schema.field_with_args( 634 + "activityBuckets", 635 + schema.non_null(schema.list_type(schema.non_null(admin_types.activity_bucket_type()))), 636 + "Get activity data bucketed by time range", 637 + [ 638 + schema.argument( 639 + "range", 640 + schema.non_null(admin_types.time_range_enum()), 641 + "Time range for bucketing", 642 + None, 643 + ), 644 + ], 645 + fn(ctx) { 646 + case schema.get_argument(ctx, "range") { 647 + Some(value.String("ONE_HOUR")) -> { 648 + case jetstream_activity.get_activity_1hr(conn) { 649 + Ok(buckets) -> 650 + Ok(value.List(list.map(buckets, converters.activity_bucket_to_value))) 651 + Error(_) -> Error("Failed to fetch 1-hour activity data") 652 + } 653 + } 654 + Some(value.String("THREE_HOURS")) -> { 655 + case jetstream_activity.get_activity_3hr(conn) { 656 + Ok(buckets) -> 657 + Ok(value.List(list.map(buckets, converters.activity_bucket_to_value))) 658 + Error(_) -> Error("Failed to fetch 3-hour activity data") 659 + } 660 + } 661 + Some(value.String("SIX_HOURS")) -> { 662 + case jetstream_activity.get_activity_6hr(conn) { 663 + Ok(buckets) -> 664 + Ok(value.List(list.map(buckets, converters.activity_bucket_to_value))) 665 + Error(_) -> Error("Failed to fetch 6-hour activity data") 666 + } 667 + } 668 + Some(value.String("ONE_DAY")) -> { 669 + case jetstream_activity.get_activity_1day(conn) { 670 + Ok(buckets) -> 671 + Ok(value.List(list.map(buckets, converters.activity_bucket_to_value))) 672 + Error(_) -> Error("Failed to fetch 1-day activity data") 673 + } 674 + } 675 + Some(value.String("SEVEN_DAYS")) -> { 676 + case jetstream_activity.get_activity_7day(conn) { 677 + Ok(buckets) -> 678 + Ok(value.List(list.map(buckets, converters.activity_bucket_to_value))) 679 + Error(_) -> Error("Failed to fetch 7-day activity data") 680 + } 681 + } 682 + _ -> Error("Invalid or missing time range argument") 683 + } 684 + }, 685 + ), 686 + // recentActivity query with hours argument 687 + schema.field_with_args( 688 + "recentActivity", 689 + schema.non_null(schema.list_type(schema.non_null(admin_types.activity_entry_type()))), 690 + "Get recent activity entries", 691 + [ 692 + schema.argument( 693 + "hours", 694 + schema.non_null(schema.int_type()), 695 + "Number of hours to look back", 696 + None, 697 + ), 698 + ], 699 + fn(ctx) { 700 + case schema.get_argument(ctx, "hours") { 701 + Some(value.Int(hours)) -> { 702 + case jetstream_activity.get_recent_activity(conn, hours) { 703 + Ok(entries) -> 704 + Ok(value.List(list.map(entries, converters.activity_entry_to_value))) 705 + Error(_) -> Error("Failed to fetch recent activity") 706 + } 707 + } 708 + _ -> Error("Invalid or missing hours argument") 709 + } 710 + }, 711 + ), 712 + ]) 713 + } 714 + ``` 715 + 716 + **Step 2: Run gleam check** 717 + 718 + Run: `cd /Users/chadmiller/code/quickslice/server && gleam check` 719 + Expected: No errors 720 + 721 + **Step 3: Commit** 722 + 723 + ```bash 724 + git add src/graphql/admin/queries.gleam 725 + git commit -m "refactor: extract admin GraphQL queries to separate module" 726 + ``` 727 + 728 + --- 729 + 730 + ### Task 4: Create mutations.gleam 731 + 732 + **Files:** 733 + - Create: `server/src/graphql/admin/mutations.gleam` 734 + 735 + **Step 1: Create mutations.gleam with mutation_type function** 736 + 737 + This is a large file (~750 lines). Create the file with the full mutation_type function containing all mutation resolvers: 738 + - `updateSettings` 739 + - `uploadLexicons` 740 + - `resetAll` 741 + - `triggerBackfill` 742 + - `backfillActor` 743 + - `createOAuthClient` 744 + - `updateOAuthClient` 745 + - `deleteOAuthClient` 746 + 747 + Copy the mutation_type function from `client_schema.gleam` lines 1003-2037, updating imports to use: 748 + - `import graphql/admin/converters` 749 + - `import graphql/admin/types as admin_types` 750 + 751 + Replace direct type references with `admin_types.*` and converter calls with `converters.*`. 752 + 753 + Add these helper functions at the top of the file (copy from client_schema.gleam): 754 + - `is_valid_did()` (lines 32-48) 755 + - `validate_scope_against_supported()` (lines 58-81) 756 + - `is_admin()` (lines 53-55) 757 + 758 + **Step 2: Run gleam check** 759 + 760 + Run: `cd /Users/chadmiller/code/quickslice/server && gleam check` 761 + Expected: No errors 762 + 763 + **Step 3: Commit** 764 + 765 + ```bash 766 + git add src/graphql/admin/mutations.gleam 767 + git commit -m "refactor: extract admin GraphQL mutations to separate module" 768 + ``` 769 + 770 + --- 771 + 772 + ### Task 5: Create schema.gleam entry point 773 + 774 + **Files:** 775 + - Create: `server/src/graphql/admin/schema.gleam` 776 + 777 + **Step 1: Create schema.gleam with build_schema function** 778 + 779 + ```gleam 780 + /// Admin GraphQL schema entry point 781 + /// 782 + /// This is the public API for the admin GraphQL schema. 783 + /// External code should import this module to build the schema. 784 + import backfill_state 785 + import gleam/erlang/process.{type Subject} 786 + import gleam/option.{type Option, Some} 787 + import graphql/admin/mutations 788 + import graphql/admin/queries 789 + import jetstream_consumer 790 + import lib/oauth/did_cache 791 + import sqlight 792 + import swell/schema 793 + import wisp 794 + 795 + /// Build the complete GraphQL schema for admin queries 796 + pub fn build_schema( 797 + conn: sqlight.Connection, 798 + req: wisp.Request, 799 + jetstream_subject: Option(Subject(jetstream_consumer.ManagerMessage)), 800 + did_cache: Subject(did_cache.Message), 801 + oauth_supported_scopes: List(String), 802 + backfill_state_subject: Subject(backfill_state.Message), 803 + ) -> schema.Schema { 804 + schema.schema( 805 + queries.query_type(conn, req, did_cache, backfill_state_subject), 806 + Some(mutations.mutation_type( 807 + conn, 808 + req, 809 + jetstream_subject, 810 + did_cache, 811 + oauth_supported_scopes, 812 + backfill_state_subject, 813 + )), 814 + ) 815 + } 816 + ``` 817 + 818 + **Step 2: Run gleam check** 819 + 820 + Run: `cd /Users/chadmiller/code/quickslice/server && gleam check` 821 + Expected: No errors 822 + 823 + **Step 3: Commit** 824 + 825 + ```bash 826 + git add src/graphql/admin/schema.gleam 827 + git commit -m "refactor: add admin GraphQL schema entry point" 828 + ``` 829 + 830 + --- 831 + 832 + ### Task 6: Update client_graphql handler to use new module 833 + 834 + **Files:** 835 + - Modify: `server/src/handlers/client_graphql.gleam:6` 836 + 837 + **Step 1: Update import** 838 + 839 + Change line 6 from: 840 + ```gleam 841 + import client_schema 842 + ``` 843 + 844 + To: 845 + ```gleam 846 + import graphql/admin/schema as admin_schema 847 + ``` 848 + 849 + **Step 2: Update build_schema call** 850 + 851 + Change line 126 from: 852 + ```gleam 853 + client_schema.build_schema( 854 + ``` 855 + 856 + To: 857 + ```gleam 858 + admin_schema.build_schema( 859 + ``` 860 + 861 + **Step 3: Run gleam check** 862 + 863 + Run: `cd /Users/chadmiller/code/quickslice/server && gleam check` 864 + Expected: No errors 865 + 866 + **Step 4: Commit** 867 + 868 + ```bash 869 + git add src/handlers/client_graphql.gleam 870 + git commit -m "refactor: update client_graphql handler to use new admin schema module" 871 + ``` 872 + 873 + --- 874 + 875 + ### Task 7: Delete old client_schema.gleam 876 + 877 + **Files:** 878 + - Delete: `server/src/client_schema.gleam` 879 + 880 + **Step 1: Delete the file** 881 + 882 + ```bash 883 + rm src/client_schema.gleam 884 + ``` 885 + 886 + **Step 2: Run gleam check to verify no broken imports** 887 + 888 + Run: `cd /Users/chadmiller/code/quickslice/server && gleam check` 889 + Expected: No errors 890 + 891 + **Step 3: Run gleam build to verify full compilation** 892 + 893 + Run: `cd /Users/chadmiller/code/quickslice/server && gleam build` 894 + Expected: Compiles successfully 895 + 896 + **Step 4: Commit** 897 + 898 + ```bash 899 + git add -A 900 + git commit -m "refactor: remove old client_schema.gleam (replaced by graphql/admin/)" 901 + ``` 902 + 903 + --- 904 + 905 + ### Task 8: Final verification 906 + 907 + **Step 1: Run full test suite** 908 + 909 + Run: `cd /Users/chadmiller/code/quickslice/server && gleam test` 910 + Expected: All tests pass 911 + 912 + **Step 2: Start server and verify admin GraphQL works** 913 + 914 + Run: `cd /Users/chadmiller/code/quickslice/server && gleam run` 915 + 916 + Test with curl: 917 + ```bash 918 + curl -X POST http://localhost:8080/admin/graphql \ 919 + -H "Content-Type: application/json" \ 920 + -d '{"query": "{ statistics { recordCount actorCount lexiconCount } }"}' 921 + ``` 922 + 923 + Expected: JSON response with statistics data 924 + 925 + **Step 3: Final commit (if any fixes needed)** 926 + 927 + If all tests pass and server works, the refactor is complete. 928 + 929 + --- 930 + 931 + ## Summary 932 + 933 + | Module | Lines | Responsibility | 934 + |--------|-------|----------------| 935 + | types.gleam | ~150 | Object types + get_field() helper | 936 + | converters.gleam | ~100 | *_to_value transformation functions | 937 + | queries.gleam | ~200 | Query root type + resolvers | 938 + | mutations.gleam | ~750 | Mutation root type + resolvers | 939 + | schema.gleam | ~30 | Public build_schema entry point | 940 + 941 + **Total: ~1230 lines** (down from 2060, thanks to get_field() helper eliminating ~270 lines of boilerplate)
+23 -972
server/src/client_schema.gleam server/src/graphql/admin/mutations.gleam
··· 1 - /// GraphQL schema for client statistics and activity queries 2 - /// 3 - /// This schema is separate from the main /graphql endpoint and serves 4 - /// the client SPA with stats and activity data via /admin/graphql 1 + /// Mutation resolvers for admin GraphQL API 5 2 import admin_session as session 6 3 import backfill 7 4 import backfill_state ··· 11 8 import database/repositories/lexicons 12 9 import database/repositories/oauth_clients 13 10 import database/repositories/records 14 - import database/types.{type ActivityBucket, type ActivityEntry, type Lexicon} 11 + import database/types 15 12 import gleam/erlang/process.{type Subject} 16 13 import gleam/list 17 14 import gleam/option.{type Option, None, Some} 18 - import gleam/otp/actor 19 15 import gleam/string 16 + import graphql/admin/converters 17 + import graphql/admin/types as admin_types 20 18 import importer 21 19 import jetstream_consumer 22 20 import lib/oauth/did_cache ··· 35 33 case string.starts_with(s, "did:") { 36 34 False -> False 37 35 True -> { 38 - // Split into parts: should be at least 3 parts (did, method, identifier) 39 36 let parts = string.split(s, ":") 40 37 case parts { 41 - ["did", method, identifier, ..] -> 42 - // Method must be non-empty and identifier must be non-empty 43 - method != "" && identifier != "" 38 + ["did", method, identifier, ..] -> method != "" && identifier != "" 44 39 _ -> False 45 40 } 46 41 } 47 42 } 48 43 } 49 44 50 - // ===== Helper Functions ===== 51 - 52 - /// Check if a DID is in the admin list 53 - fn is_admin(conn: sqlight.Connection, did: String) -> Bool { 54 - config_repo.is_admin(conn, did) 55 - } 56 - 57 45 /// Validate that all requested scopes are in the supported list 58 46 fn validate_scope_against_supported( 59 47 requested_scope: String, ··· 80 68 } 81 69 } 82 70 83 - /// Convert CurrentSession data to GraphQL value 84 - fn current_session_to_value( 85 - did: String, 86 - handle: String, 87 - is_admin: Bool, 88 - ) -> value.Value { 89 - value.Object([ 90 - #("did", value.String(did)), 91 - #("handle", value.String(handle)), 92 - #("isAdmin", value.Boolean(is_admin)), 93 - ]) 94 - } 95 - 96 - // ===== Enum Types ===== 97 - 98 - /// TimeRange enum for activity queries 99 - pub fn time_range_enum() -> schema.Type { 100 - schema.enum_type("TimeRange", "Time range for activity data", [ 101 - schema.enum_value("ONE_HOUR", "Last 1 hour (5-min buckets)"), 102 - schema.enum_value("THREE_HOURS", "Last 3 hours (15-min buckets)"), 103 - schema.enum_value("SIX_HOURS", "Last 6 hours (30-min buckets)"), 104 - schema.enum_value("ONE_DAY", "Last 24 hours (1-hour buckets)"), 105 - schema.enum_value("SEVEN_DAYS", "Last 7 days (daily buckets)"), 106 - ]) 107 - } 108 - 109 - // ===== Object Types ===== 110 - 111 - /// Statistics type showing record, actor, and lexicon counts 112 - pub fn statistics_type() -> schema.Type { 113 - schema.object_type("Statistics", "System statistics", [ 114 - schema.field( 115 - "recordCount", 116 - schema.non_null(schema.int_type()), 117 - "Total number of records", 118 - fn(ctx) { 119 - case ctx.data { 120 - Some(value.Object(fields)) -> { 121 - case list.key_find(fields, "recordCount") { 122 - Ok(count) -> Ok(count) 123 - Error(_) -> Ok(value.Null) 124 - } 125 - } 126 - _ -> Ok(value.Null) 127 - } 128 - }, 129 - ), 130 - schema.field( 131 - "actorCount", 132 - schema.non_null(schema.int_type()), 133 - "Total number of actors", 134 - fn(ctx) { 135 - case ctx.data { 136 - Some(value.Object(fields)) -> { 137 - case list.key_find(fields, "actorCount") { 138 - Ok(count) -> Ok(count) 139 - Error(_) -> Ok(value.Null) 140 - } 141 - } 142 - _ -> Ok(value.Null) 143 - } 144 - }, 145 - ), 146 - schema.field( 147 - "lexiconCount", 148 - schema.non_null(schema.int_type()), 149 - "Total number of lexicons", 150 - fn(ctx) { 151 - case ctx.data { 152 - Some(value.Object(fields)) -> { 153 - case list.key_find(fields, "lexiconCount") { 154 - Ok(count) -> Ok(count) 155 - Error(_) -> Ok(value.Null) 156 - } 157 - } 158 - _ -> Ok(value.Null) 159 - } 160 - }, 161 - ), 162 - ]) 163 - } 164 - 165 - /// CurrentSession type for authenticated user information 166 - pub fn current_session_type() -> schema.Type { 167 - schema.object_type("CurrentSession", "Current authenticated user session", [ 168 - schema.field( 169 - "did", 170 - schema.non_null(schema.string_type()), 171 - "User's DID", 172 - fn(ctx) { 173 - case ctx.data { 174 - Some(value.Object(fields)) -> { 175 - case list.key_find(fields, "did") { 176 - Ok(did) -> Ok(did) 177 - Error(_) -> Ok(value.Null) 178 - } 179 - } 180 - _ -> Ok(value.Null) 181 - } 182 - }, 183 - ), 184 - schema.field( 185 - "handle", 186 - schema.non_null(schema.string_type()), 187 - "User's handle", 188 - fn(ctx) { 189 - case ctx.data { 190 - Some(value.Object(fields)) -> { 191 - case list.key_find(fields, "handle") { 192 - Ok(handle) -> Ok(handle) 193 - Error(_) -> Ok(value.Null) 194 - } 195 - } 196 - _ -> Ok(value.Null) 197 - } 198 - }, 199 - ), 200 - schema.field( 201 - "isAdmin", 202 - schema.non_null(schema.boolean_type()), 203 - "Whether the user is an admin", 204 - fn(ctx) { 205 - case ctx.data { 206 - Some(value.Object(fields)) -> { 207 - case list.key_find(fields, "isAdmin") { 208 - Ok(is_admin) -> Ok(is_admin) 209 - Error(_) -> Ok(value.Null) 210 - } 211 - } 212 - _ -> Ok(value.Null) 213 - } 214 - }, 215 - ), 216 - ]) 217 - } 218 - 219 - /// ActivityBucket type for aggregated activity data 220 - pub fn activity_bucket_type() -> schema.Type { 221 - schema.object_type("ActivityBucket", "Time-bucketed activity counts", [ 222 - schema.field( 223 - "timestamp", 224 - schema.non_null(schema.string_type()), 225 - "Bucket timestamp", 226 - fn(ctx) { 227 - case ctx.data { 228 - Some(value.Object(fields)) -> { 229 - case list.key_find(fields, "timestamp") { 230 - Ok(ts) -> Ok(ts) 231 - Error(_) -> Ok(value.Null) 232 - } 233 - } 234 - _ -> Ok(value.Null) 235 - } 236 - }, 237 - ), 238 - schema.field( 239 - "total", 240 - schema.non_null(schema.int_type()), 241 - "Total operations in bucket", 242 - fn(ctx) { 243 - case ctx.data { 244 - Some(value.Object(fields)) -> { 245 - case list.key_find(fields, "total") { 246 - Ok(total) -> Ok(total) 247 - Error(_) -> Ok(value.Null) 248 - } 249 - } 250 - _ -> Ok(value.Null) 251 - } 252 - }, 253 - ), 254 - schema.field( 255 - "creates", 256 - schema.non_null(schema.int_type()), 257 - "Create operations", 258 - fn(ctx) { 259 - case ctx.data { 260 - Some(value.Object(fields)) -> { 261 - case list.key_find(fields, "creates") { 262 - Ok(creates) -> Ok(creates) 263 - Error(_) -> Ok(value.Null) 264 - } 265 - } 266 - _ -> Ok(value.Null) 267 - } 268 - }, 269 - ), 270 - schema.field( 271 - "updates", 272 - schema.non_null(schema.int_type()), 273 - "Update operations", 274 - fn(ctx) { 275 - case ctx.data { 276 - Some(value.Object(fields)) -> { 277 - case list.key_find(fields, "updates") { 278 - Ok(updates) -> Ok(updates) 279 - Error(_) -> Ok(value.Null) 280 - } 281 - } 282 - _ -> Ok(value.Null) 283 - } 284 - }, 285 - ), 286 - schema.field( 287 - "deletes", 288 - schema.non_null(schema.int_type()), 289 - "Delete operations", 290 - fn(ctx) { 291 - case ctx.data { 292 - Some(value.Object(fields)) -> { 293 - case list.key_find(fields, "deletes") { 294 - Ok(deletes) -> Ok(deletes) 295 - Error(_) -> Ok(value.Null) 296 - } 297 - } 298 - _ -> Ok(value.Null) 299 - } 300 - }, 301 - ), 302 - ]) 303 - } 304 - 305 - /// Lexicon type for AT Protocol lexicon schemas 306 - pub fn lexicon_type() -> schema.Type { 307 - schema.object_type("Lexicon", "AT Protocol lexicon schema definition", [ 308 - schema.field( 309 - "id", 310 - schema.non_null(schema.string_type()), 311 - "Lexicon NSID (e.g., app.bsky.feed.post)", 312 - fn(ctx) { 313 - case ctx.data { 314 - Some(value.Object(fields)) -> { 315 - case list.key_find(fields, "id") { 316 - Ok(id) -> Ok(id) 317 - Error(_) -> Ok(value.Null) 318 - } 319 - } 320 - _ -> Ok(value.Null) 321 - } 322 - }, 323 - ), 324 - schema.field( 325 - "json", 326 - schema.non_null(schema.string_type()), 327 - "Full lexicon JSON content", 328 - fn(ctx) { 329 - case ctx.data { 330 - Some(value.Object(fields)) -> { 331 - case list.key_find(fields, "json") { 332 - Ok(json) -> Ok(json) 333 - Error(_) -> Ok(value.Null) 334 - } 335 - } 336 - _ -> Ok(value.Null) 337 - } 338 - }, 339 - ), 340 - schema.field( 341 - "createdAt", 342 - schema.non_null(schema.string_type()), 343 - "Timestamp when lexicon was created", 344 - fn(ctx) { 345 - case ctx.data { 346 - Some(value.Object(fields)) -> { 347 - case list.key_find(fields, "createdAt") { 348 - Ok(created_at) -> Ok(created_at) 349 - Error(_) -> Ok(value.Null) 350 - } 351 - } 352 - _ -> Ok(value.Null) 353 - } 354 - }, 355 - ), 356 - ]) 357 - } 358 - 359 - /// Settings type for configuration 360 - pub fn settings_type() -> schema.Type { 361 - schema.object_type("Settings", "System settings and configuration", [ 362 - schema.field( 363 - "id", 364 - schema.non_null(schema.string_type()), 365 - "Global ID for normalization", 366 - fn(_ctx) { 367 - // Settings is a singleton, so we use a constant ID 368 - Ok(value.String("Settings:singleton")) 369 - }, 370 - ), 371 - schema.field( 372 - "domainAuthority", 373 - schema.non_null(schema.string_type()), 374 - "Domain authority configuration", 375 - fn(ctx) { 376 - case ctx.data { 377 - Some(value.Object(fields)) -> { 378 - case list.key_find(fields, "domainAuthority") { 379 - Ok(authority) -> Ok(authority) 380 - Error(_) -> Ok(value.Null) 381 - } 382 - } 383 - _ -> Ok(value.Null) 384 - } 385 - }, 386 - ), 387 - schema.field( 388 - "adminDids", 389 - schema.non_null(schema.list_type(schema.non_null(schema.string_type()))), 390 - "List of admin DIDs", 391 - fn(ctx) { 392 - case ctx.data { 393 - Some(value.Object(fields)) -> { 394 - case list.key_find(fields, "adminDids") { 395 - Ok(dids) -> Ok(dids) 396 - Error(_) -> Ok(value.List([])) 397 - } 398 - } 399 - _ -> Ok(value.List([])) 400 - } 401 - }, 402 - ), 403 - schema.field( 404 - "relayUrl", 405 - schema.non_null(schema.string_type()), 406 - "AT Protocol relay URL for backfill operations", 407 - fn(ctx) { 408 - case ctx.data { 409 - Some(value.Object(fields)) -> { 410 - case list.key_find(fields, "relayUrl") { 411 - Ok(url) -> Ok(url) 412 - Error(_) -> Ok(value.Null) 413 - } 414 - } 415 - _ -> Ok(value.Null) 416 - } 417 - }, 418 - ), 419 - schema.field( 420 - "plcDirectoryUrl", 421 - schema.non_null(schema.string_type()), 422 - "PLC directory URL for DID resolution", 423 - fn(ctx) { 424 - case ctx.data { 425 - Some(value.Object(fields)) -> { 426 - case list.key_find(fields, "plcDirectoryUrl") { 427 - Ok(url) -> Ok(url) 428 - Error(_) -> Ok(value.Null) 429 - } 430 - } 431 - _ -> Ok(value.Null) 432 - } 433 - }, 434 - ), 435 - schema.field( 436 - "jetstreamUrl", 437 - schema.non_null(schema.string_type()), 438 - "Jetstream WebSocket endpoint for real-time indexing", 439 - fn(ctx) { 440 - case ctx.data { 441 - Some(value.Object(fields)) -> { 442 - case list.key_find(fields, "jetstreamUrl") { 443 - Ok(url) -> Ok(url) 444 - Error(_) -> Ok(value.Null) 445 - } 446 - } 447 - _ -> Ok(value.Null) 448 - } 449 - }, 450 - ), 451 - schema.field( 452 - "oauthSupportedScopes", 453 - schema.non_null(schema.string_type()), 454 - "Space-separated OAuth scopes supported by this server", 455 - fn(ctx) { 456 - case ctx.data { 457 - Some(value.Object(fields)) -> { 458 - case list.key_find(fields, "oauthSupportedScopes") { 459 - Ok(scopes) -> Ok(scopes) 460 - Error(_) -> Ok(value.Null) 461 - } 462 - } 463 - _ -> Ok(value.Null) 464 - } 465 - }, 466 - ), 467 - ]) 468 - } 469 - 470 - /// OAuthClient type for client registration management 471 - pub fn oauth_client_type() -> schema.Type { 472 - schema.object_type("OAuthClient", "OAuth client registration", [ 473 - schema.field( 474 - "clientId", 475 - schema.non_null(schema.string_type()), 476 - "Client ID", 477 - fn(ctx) { 478 - case ctx.data { 479 - Some(value.Object(fields)) -> { 480 - case list.key_find(fields, "clientId") { 481 - Ok(v) -> Ok(v) 482 - Error(_) -> Ok(value.Null) 483 - } 484 - } 485 - _ -> Ok(value.Null) 486 - } 487 - }, 488 - ), 489 - schema.field( 490 - "clientSecret", 491 - schema.string_type(), 492 - "Client secret (confidential clients only)", 493 - fn(ctx) { 494 - case ctx.data { 495 - Some(value.Object(fields)) -> { 496 - case list.key_find(fields, "clientSecret") { 497 - Ok(v) -> Ok(v) 498 - Error(_) -> Ok(value.Null) 499 - } 500 - } 501 - _ -> Ok(value.Null) 502 - } 503 - }, 504 - ), 505 - schema.field( 506 - "clientName", 507 - schema.non_null(schema.string_type()), 508 - "Client display name", 509 - fn(ctx) { 510 - case ctx.data { 511 - Some(value.Object(fields)) -> { 512 - case list.key_find(fields, "clientName") { 513 - Ok(v) -> Ok(v) 514 - Error(_) -> Ok(value.Null) 515 - } 516 - } 517 - _ -> Ok(value.Null) 518 - } 519 - }, 520 - ), 521 - schema.field( 522 - "clientType", 523 - schema.non_null(schema.string_type()), 524 - "PUBLIC or CONFIDENTIAL", 525 - fn(ctx) { 526 - case ctx.data { 527 - Some(value.Object(fields)) -> { 528 - case list.key_find(fields, "clientType") { 529 - Ok(v) -> Ok(v) 530 - Error(_) -> Ok(value.Null) 531 - } 532 - } 533 - _ -> Ok(value.Null) 534 - } 535 - }, 536 - ), 537 - schema.field( 538 - "redirectUris", 539 - schema.non_null(schema.list_type(schema.non_null(schema.string_type()))), 540 - "Allowed redirect URIs", 541 - fn(ctx) { 542 - case ctx.data { 543 - Some(value.Object(fields)) -> { 544 - case list.key_find(fields, "redirectUris") { 545 - Ok(v) -> Ok(v) 546 - Error(_) -> Ok(value.Null) 547 - } 548 - } 549 - _ -> Ok(value.Null) 550 - } 551 - }, 552 - ), 553 - schema.field( 554 - "scope", 555 - schema.string_type(), 556 - "OAuth scopes for this client (space-separated)", 557 - fn(ctx) { 558 - case ctx.data { 559 - Some(value.Object(fields)) -> { 560 - case list.key_find(fields, "scope") { 561 - Ok(v) -> Ok(v) 562 - Error(_) -> Ok(value.Null) 563 - } 564 - } 565 - _ -> Ok(value.Null) 566 - } 567 - }, 568 - ), 569 - schema.field( 570 - "createdAt", 571 - schema.non_null(schema.int_type()), 572 - "Creation timestamp", 573 - fn(ctx) { 574 - case ctx.data { 575 - Some(value.Object(fields)) -> { 576 - case list.key_find(fields, "createdAt") { 577 - Ok(v) -> Ok(v) 578 - Error(_) -> Ok(value.Null) 579 - } 580 - } 581 - _ -> Ok(value.Null) 582 - } 583 - }, 584 - ), 585 - ]) 586 - } 587 - 588 - /// ActivityEntry type for individual activity records 589 - pub fn activity_entry_type() -> schema.Type { 590 - schema.object_type("ActivityEntry", "Individual activity log entry", [ 591 - schema.field("id", schema.non_null(schema.int_type()), "Entry ID", fn(ctx) { 592 - case ctx.data { 593 - Some(value.Object(fields)) -> { 594 - case list.key_find(fields, "id") { 595 - Ok(id) -> Ok(id) 596 - Error(_) -> Ok(value.Null) 597 - } 598 - } 599 - _ -> Ok(value.Null) 600 - } 601 - }), 602 - schema.field( 603 - "timestamp", 604 - schema.non_null(schema.string_type()), 605 - "Timestamp", 606 - fn(ctx) { 607 - case ctx.data { 608 - Some(value.Object(fields)) -> { 609 - case list.key_find(fields, "timestamp") { 610 - Ok(ts) -> Ok(ts) 611 - Error(_) -> Ok(value.Null) 612 - } 613 - } 614 - _ -> Ok(value.Null) 615 - } 616 - }, 617 - ), 618 - schema.field( 619 - "operation", 620 - schema.non_null(schema.string_type()), 621 - "Operation type", 622 - fn(ctx) { 623 - case ctx.data { 624 - Some(value.Object(fields)) -> { 625 - case list.key_find(fields, "operation") { 626 - Ok(op) -> Ok(op) 627 - Error(_) -> Ok(value.Null) 628 - } 629 - } 630 - _ -> Ok(value.Null) 631 - } 632 - }, 633 - ), 634 - schema.field( 635 - "collection", 636 - schema.non_null(schema.string_type()), 637 - "Collection name", 638 - fn(ctx) { 639 - case ctx.data { 640 - Some(value.Object(fields)) -> { 641 - case list.key_find(fields, "collection") { 642 - Ok(coll) -> Ok(coll) 643 - Error(_) -> Ok(value.Null) 644 - } 645 - } 646 - _ -> Ok(value.Null) 647 - } 648 - }, 649 - ), 650 - schema.field("did", schema.non_null(schema.string_type()), "DID", fn(ctx) { 651 - case ctx.data { 652 - Some(value.Object(fields)) -> { 653 - case list.key_find(fields, "did") { 654 - Ok(did) -> Ok(did) 655 - Error(_) -> Ok(value.Null) 656 - } 657 - } 658 - _ -> Ok(value.Null) 659 - } 660 - }), 661 - schema.field( 662 - "status", 663 - schema.non_null(schema.string_type()), 664 - "Processing status", 665 - fn(ctx) { 666 - case ctx.data { 667 - Some(value.Object(fields)) -> { 668 - case list.key_find(fields, "status") { 669 - Ok(status) -> Ok(status) 670 - Error(_) -> Ok(value.Null) 671 - } 672 - } 673 - _ -> Ok(value.Null) 674 - } 675 - }, 676 - ), 677 - schema.field( 678 - "errorMessage", 679 - schema.string_type(), 680 - "Error message if failed", 681 - fn(ctx) { 682 - case ctx.data { 683 - Some(value.Object(fields)) -> { 684 - case list.key_find(fields, "errorMessage") { 685 - Ok(err_msg) -> Ok(err_msg) 686 - Error(_) -> Ok(value.Null) 687 - } 688 - } 689 - _ -> Ok(value.Null) 690 - } 691 - }, 692 - ), 693 - schema.field("eventJson", schema.string_type(), "Raw event JSON", fn(ctx) { 694 - case ctx.data { 695 - Some(value.Object(fields)) -> { 696 - case list.key_find(fields, "eventJson") { 697 - Ok(json) -> Ok(json) 698 - Error(_) -> Ok(value.Null) 699 - } 700 - } 701 - _ -> Ok(value.Null) 702 - } 703 - }), 704 - ]) 705 - } 706 - 707 - // ===== Conversion Helpers ===== 708 - 709 - fn statistics_to_value( 710 - record_count: Int, 711 - actor_count: Int, 712 - lexicon_count: Int, 713 - ) -> value.Value { 714 - value.Object([ 715 - #("recordCount", value.Int(record_count)), 716 - #("actorCount", value.Int(actor_count)), 717 - #("lexiconCount", value.Int(lexicon_count)), 718 - ]) 719 - } 720 - 721 - fn activity_bucket_to_value(bucket: ActivityBucket) -> value.Value { 722 - let total = bucket.create_count + bucket.update_count + bucket.delete_count 723 - value.Object([ 724 - #("timestamp", value.String(bucket.timestamp)), 725 - #("total", value.Int(total)), 726 - #("creates", value.Int(bucket.create_count)), 727 - #("updates", value.Int(bucket.update_count)), 728 - #("deletes", value.Int(bucket.delete_count)), 729 - ]) 730 - } 731 - 732 - fn activity_entry_to_value(entry: ActivityEntry) -> value.Value { 733 - let error_msg_value = case entry.error_message { 734 - Some(msg) -> value.String(msg) 735 - None -> value.Null 736 - } 737 - 738 - value.Object([ 739 - #("id", value.Int(entry.id)), 740 - #("timestamp", value.String(entry.timestamp)), 741 - #("operation", value.String(entry.operation)), 742 - #("collection", value.String(entry.collection)), 743 - #("did", value.String(entry.did)), 744 - #("status", value.String(entry.status)), 745 - #("errorMessage", error_msg_value), 746 - #("eventJson", value.String(entry.event_json)), 747 - ]) 748 - } 749 - 750 - fn settings_to_value( 751 - domain_authority: String, 752 - admin_dids: List(String), 753 - relay_url: String, 754 - plc_directory_url: String, 755 - jetstream_url: String, 756 - oauth_supported_scopes: String, 757 - ) -> value.Value { 758 - value.Object([ 759 - #("id", value.String("Settings:singleton")), 760 - #("domainAuthority", value.String(domain_authority)), 761 - #("adminDids", value.List(list.map(admin_dids, value.String))), 762 - #("relayUrl", value.String(relay_url)), 763 - #("plcDirectoryUrl", value.String(plc_directory_url)), 764 - #("jetstreamUrl", value.String(jetstream_url)), 765 - #("oauthSupportedScopes", value.String(oauth_supported_scopes)), 766 - ]) 767 - } 768 - 769 - fn oauth_client_to_value(client: types.OAuthClient) -> value.Value { 770 - let secret_value = case client.client_secret { 771 - Some(s) -> value.String(s) 772 - None -> value.Null 773 - } 774 - let scope_value = case client.scope { 775 - Some(s) -> value.String(s) 776 - None -> value.Null 777 - } 778 - value.Object([ 779 - #("clientId", value.String(client.client_id)), 780 - #("clientSecret", secret_value), 781 - #("clientName", value.String(client.client_name)), 782 - #( 783 - "clientType", 784 - value.String(types.client_type_to_string(client.client_type)), 785 - ), 786 - #("redirectUris", value.List(list.map(client.redirect_uris, value.String))), 787 - #("scope", scope_value), 788 - #("createdAt", value.Int(client.created_at)), 789 - ]) 790 - } 791 - 792 - fn lexicon_to_value(lexicon: Lexicon) -> value.Value { 793 - value.Object([ 794 - #("id", value.String(lexicon.id)), 795 - #("json", value.String(lexicon.json)), 796 - #("createdAt", value.String(lexicon.created_at)), 797 - ]) 798 - } 799 - 800 - // ===== Query Type ===== 801 - 802 - pub fn query_type( 803 - conn: sqlight.Connection, 804 - req: wisp.Request, 805 - did_cache: Subject(did_cache.Message), 806 - backfill_state_subject: Subject(backfill_state.Message), 807 - ) -> schema.Type { 808 - schema.object_type("Query", "Root query type", [ 809 - // currentSession query 810 - schema.field( 811 - "currentSession", 812 - current_session_type(), 813 - "Get current authenticated user session (null if not authenticated)", 814 - fn(_ctx) { 815 - case session.get_current_session(req, conn, did_cache) { 816 - Ok(sess) -> { 817 - let user_is_admin = is_admin(conn, sess.did) 818 - Ok(current_session_to_value(sess.did, sess.handle, user_is_admin)) 819 - } 820 - Error(_) -> Ok(value.Null) 821 - } 822 - }, 823 - ), 824 - // statistics query 825 - schema.field( 826 - "statistics", 827 - schema.non_null(statistics_type()), 828 - "Get system statistics", 829 - fn(_ctx) { 830 - case 831 - records.get_count(conn), 832 - actors.get_count(conn), 833 - lexicons.get_count(conn) 834 - { 835 - Ok(record_count), Ok(actor_count), Ok(lexicon_count) -> { 836 - Ok(statistics_to_value(record_count, actor_count, lexicon_count)) 837 - } 838 - _, _, _ -> Error("Failed to fetch statistics") 839 - } 840 - }, 841 - ), 842 - // settings query 843 - schema.field( 844 - "settings", 845 - schema.non_null(settings_type()), 846 - "Get system settings", 847 - fn(_ctx) { 848 - let domain_authority = case config_repo.get(conn, "domain_authority") { 849 - Ok(authority) -> authority 850 - Error(_) -> "" 851 - } 852 - let admin_dids = config_repo.get_admin_dids(conn) 853 - let relay_url = config_repo.get_relay_url(conn) 854 - let plc_directory_url = config_repo.get_plc_directory_url(conn) 855 - let jetstream_url = config_repo.get_jetstream_url(conn) 856 - let oauth_supported_scopes = 857 - config_repo.get_oauth_supported_scopes(conn) 858 - 859 - Ok(settings_to_value( 860 - domain_authority, 861 - admin_dids, 862 - relay_url, 863 - plc_directory_url, 864 - jetstream_url, 865 - oauth_supported_scopes, 866 - )) 867 - }, 868 - ), 869 - // isBackfilling query 870 - schema.field( 871 - "isBackfilling", 872 - schema.non_null(schema.boolean_type()), 873 - "Check if a backfill operation is currently running", 874 - fn(_ctx) { 875 - let is_backfilling = 876 - actor.call( 877 - backfill_state_subject, 878 - waiting: 100, 879 - sending: backfill_state.IsBackfilling, 880 - ) 881 - Ok(value.Boolean(is_backfilling)) 882 - }, 883 - ), 884 - // lexicons query 885 - schema.field( 886 - "lexicons", 887 - schema.non_null(schema.list_type(schema.non_null(lexicon_type()))), 888 - "Get all lexicons", 889 - fn(_ctx) { 890 - case lexicons.get_all(conn) { 891 - Ok(lexicon_list) -> 892 - Ok(value.List(list.map(lexicon_list, lexicon_to_value))) 893 - Error(_) -> Error("Failed to fetch lexicons") 894 - } 895 - }, 896 - ), 897 - // oauthClients query (admin only) 898 - schema.field( 899 - "oauthClients", 900 - schema.non_null(schema.list_type(schema.non_null(oauth_client_type()))), 901 - "Get all OAuth client registrations (admin only)", 902 - fn(_ctx) { 903 - case session.get_current_session(req, conn, did_cache) { 904 - Ok(sess) -> { 905 - case is_admin(conn, sess.did) { 906 - True -> { 907 - case oauth_clients.get_all(conn) { 908 - Ok(clients) -> 909 - Ok(value.List(list.map(clients, oauth_client_to_value))) 910 - Error(_) -> Error("Failed to fetch OAuth clients") 911 - } 912 - } 913 - False -> Error("Admin privileges required") 914 - } 915 - } 916 - Error(_) -> Error("Authentication required") 917 - } 918 - }, 919 - ), 920 - // activityBuckets query with TimeRange argument 921 - schema.field_with_args( 922 - "activityBuckets", 923 - schema.non_null(schema.list_type(schema.non_null(activity_bucket_type()))), 924 - "Get activity data bucketed by time range", 925 - [ 926 - schema.argument( 927 - "range", 928 - schema.non_null(time_range_enum()), 929 - "Time range for bucketing", 930 - None, 931 - ), 932 - ], 933 - fn(ctx) { 934 - case schema.get_argument(ctx, "range") { 935 - Some(value.String("ONE_HOUR")) -> { 936 - case jetstream_activity.get_activity_1hr(conn) { 937 - Ok(buckets) -> 938 - Ok(value.List(list.map(buckets, activity_bucket_to_value))) 939 - Error(_) -> Error("Failed to fetch 1-hour activity data") 940 - } 941 - } 942 - Some(value.String("THREE_HOURS")) -> { 943 - case jetstream_activity.get_activity_3hr(conn) { 944 - Ok(buckets) -> 945 - Ok(value.List(list.map(buckets, activity_bucket_to_value))) 946 - Error(_) -> Error("Failed to fetch 3-hour activity data") 947 - } 948 - } 949 - Some(value.String("SIX_HOURS")) -> { 950 - case jetstream_activity.get_activity_6hr(conn) { 951 - Ok(buckets) -> 952 - Ok(value.List(list.map(buckets, activity_bucket_to_value))) 953 - Error(_) -> Error("Failed to fetch 6-hour activity data") 954 - } 955 - } 956 - Some(value.String("ONE_DAY")) -> { 957 - case jetstream_activity.get_activity_1day(conn) { 958 - Ok(buckets) -> 959 - Ok(value.List(list.map(buckets, activity_bucket_to_value))) 960 - Error(_) -> Error("Failed to fetch 1-day activity data") 961 - } 962 - } 963 - Some(value.String("SEVEN_DAYS")) -> { 964 - case jetstream_activity.get_activity_7day(conn) { 965 - Ok(buckets) -> 966 - Ok(value.List(list.map(buckets, activity_bucket_to_value))) 967 - Error(_) -> Error("Failed to fetch 7-day activity data") 968 - } 969 - } 970 - _ -> Error("Invalid or missing time range argument") 971 - } 972 - }, 973 - ), 974 - // recentActivity query with hours argument 975 - schema.field_with_args( 976 - "recentActivity", 977 - schema.non_null(schema.list_type(schema.non_null(activity_entry_type()))), 978 - "Get recent activity entries", 979 - [ 980 - schema.argument( 981 - "hours", 982 - schema.non_null(schema.int_type()), 983 - "Number of hours to look back", 984 - None, 985 - ), 986 - ], 987 - fn(ctx) { 988 - case schema.get_argument(ctx, "hours") { 989 - Some(value.Int(hours)) -> { 990 - case jetstream_activity.get_recent_activity(conn, hours) { 991 - Ok(entries) -> 992 - Ok(value.List(list.map(entries, activity_entry_to_value))) 993 - Error(_) -> Error("Failed to fetch recent activity") 994 - } 995 - } 996 - _ -> Error("Invalid or missing hours argument") 997 - } 998 - }, 999 - ), 1000 - ]) 1001 - } 1002 - 1003 - /// Mutation type for settings updates 71 + /// Build the Mutation root type with all mutation resolvers 1004 72 pub fn mutation_type( 1005 73 conn: sqlight.Connection, 1006 74 req: wisp.Request, ··· 1013 81 // updateSettings mutation - consolidated settings update 1014 82 schema.field_with_args( 1015 83 "updateSettings", 1016 - schema.non_null(settings_type()), 84 + schema.non_null(admin_types.settings_type()), 1017 85 "Update system settings (domain authority and/or admin DIDs)", 1018 86 [ 1019 87 schema.argument( ··· 1058 126 case session.get_current_session(req, conn, did_cache) { 1059 127 Error(_) -> Error("Authentication required") 1060 128 Ok(sess) -> { 1061 - case is_admin(conn, sess.did) { 129 + case config_repo.is_admin(conn, sess.did) { 1062 130 False -> Error("Admin privileges required") 1063 131 True -> { 1064 132 // Update domain authority if provided ··· 1375 443 conn, 1376 444 ) 1377 445 1378 - Ok(settings_to_value( 446 + Ok(converters.settings_to_value( 1379 447 final_authority, 1380 448 final_admin_dids, 1381 449 final_relay_url, ··· 1481 549 // Check if user is authenticated and admin 1482 550 case session.get_current_session(req, conn, did_cache) { 1483 551 Ok(sess) -> { 1484 - case is_admin(conn, sess.did) { 552 + case config_repo.is_admin(conn, sess.did) { 1485 553 True -> { 1486 554 case schema.get_argument(ctx, "confirm") { 1487 555 Some(value.String("RESET")) -> { ··· 1527 595 // Check if user is authenticated and admin 1528 596 case session.get_current_session(req, conn, did_cache) { 1529 597 Ok(sess) -> { 1530 - case is_admin(conn, sess.did) { 598 + case config_repo.is_admin(conn, sess.did) { 1531 599 True -> { 1532 600 // Mark backfill as started 1533 601 process.send( ··· 1691 759 // createOAuthClient mutation 1692 760 schema.field_with_args( 1693 761 "createOAuthClient", 1694 - schema.non_null(oauth_client_type()), 762 + schema.non_null(admin_types.oauth_client_type()), 1695 763 "Create a new OAuth client (admin only)", 1696 764 [ 1697 765 schema.argument( ··· 1724 792 fn(ctx) { 1725 793 case session.get_current_session(req, conn, did_cache) { 1726 794 Ok(sess) -> { 1727 - case is_admin(conn, sess.did) { 795 + case config_repo.is_admin(conn, sess.did) { 1728 796 True -> { 1729 797 case 1730 798 schema.get_argument(ctx, "clientName"), ··· 1830 898 jwks: None, 1831 899 ) 1832 900 case oauth_clients.insert(conn, client) { 1833 - Ok(_) -> Ok(oauth_client_to_value(client)) 901 + Ok(_) -> 902 + Ok(converters.oauth_client_to_value( 903 + client, 904 + )) 1834 905 Error(_) -> 1835 906 Error("Failed to create OAuth client") 1836 907 } ··· 1856 927 // updateOAuthClient mutation 1857 928 schema.field_with_args( 1858 929 "updateOAuthClient", 1859 - schema.non_null(oauth_client_type()), 930 + schema.non_null(admin_types.oauth_client_type()), 1860 931 "Update an existing OAuth client (admin only)", 1861 932 [ 1862 933 schema.argument( ··· 1889 960 fn(ctx) { 1890 961 case session.get_current_session(req, conn, did_cache) { 1891 962 Ok(sess) -> { 1892 - case is_admin(conn, sess.did) { 963 + case config_repo.is_admin(conn, sess.did) { 1893 964 True -> { 1894 965 case 1895 966 schema.get_argument(ctx, "clientId"), ··· 1965 1036 oauth_clients.update(conn, updated) 1966 1037 { 1967 1038 Ok(_) -> 1968 - Ok(oauth_client_to_value(updated)) 1039 + Ok(converters.oauth_client_to_value( 1040 + updated, 1041 + )) 1969 1042 Error(_) -> 1970 1043 Error( 1971 1044 "Failed to update OAuth client", ··· 2010 1083 fn(ctx) { 2011 1084 case session.get_current_session(req, conn, did_cache) { 2012 1085 Ok(sess) -> { 2013 - case is_admin(conn, sess.did) { 1086 + case config_repo.is_admin(conn, sess.did) { 2014 1087 True -> { 2015 1088 case schema.get_argument(ctx, "clientId") { 2016 1089 Some(value.String(client_id)) -> { ··· 2036 1109 ), 2037 1110 ]) 2038 1111 } 2039 - 2040 - /// Build the complete GraphQL schema for client queries 2041 - pub fn build_schema( 2042 - conn: sqlight.Connection, 2043 - req: wisp.Request, 2044 - jetstream_subject: Option(Subject(jetstream_consumer.ManagerMessage)), 2045 - did_cache: Subject(did_cache.Message), 2046 - oauth_supported_scopes: List(String), 2047 - backfill_state_subject: Subject(backfill_state.Message), 2048 - ) -> schema.Schema { 2049 - schema.schema( 2050 - query_type(conn, req, did_cache, backfill_state_subject), 2051 - Some(mutation_type( 2052 - conn, 2053 - req, 2054 - jetstream_subject, 2055 - did_cache, 2056 - oauth_supported_scopes, 2057 - backfill_state_subject, 2058 - )), 2059 - ) 2060 - }
+117
server/src/graphql/admin/converters.gleam
··· 1 + /// Value converters for admin GraphQL API 2 + /// 3 + /// Transform domain types to GraphQL value.Value objects 4 + import database/types.{ 5 + type ActivityBucket, type ActivityEntry, type Lexicon, type OAuthClient, 6 + client_type_to_string, 7 + } 8 + import gleam/list 9 + import gleam/option.{None, Some} 10 + import swell/value 11 + 12 + /// Convert CurrentSession data to GraphQL value 13 + pub fn current_session_to_value( 14 + did: String, 15 + handle: String, 16 + is_admin: Bool, 17 + ) -> value.Value { 18 + value.Object([ 19 + #("did", value.String(did)), 20 + #("handle", value.String(handle)), 21 + #("isAdmin", value.Boolean(is_admin)), 22 + ]) 23 + } 24 + 25 + /// Convert statistics counts to GraphQL value 26 + pub fn statistics_to_value( 27 + record_count: Int, 28 + actor_count: Int, 29 + lexicon_count: Int, 30 + ) -> value.Value { 31 + value.Object([ 32 + #("recordCount", value.Int(record_count)), 33 + #("actorCount", value.Int(actor_count)), 34 + #("lexiconCount", value.Int(lexicon_count)), 35 + ]) 36 + } 37 + 38 + /// Convert ActivityBucket domain type to GraphQL value 39 + pub fn activity_bucket_to_value(bucket: ActivityBucket) -> value.Value { 40 + let total = bucket.create_count + bucket.update_count + bucket.delete_count 41 + value.Object([ 42 + #("timestamp", value.String(bucket.timestamp)), 43 + #("total", value.Int(total)), 44 + #("creates", value.Int(bucket.create_count)), 45 + #("updates", value.Int(bucket.update_count)), 46 + #("deletes", value.Int(bucket.delete_count)), 47 + ]) 48 + } 49 + 50 + /// Convert ActivityEntry domain type to GraphQL value 51 + pub fn activity_entry_to_value(entry: ActivityEntry) -> value.Value { 52 + let error_msg_value = case entry.error_message { 53 + Some(msg) -> value.String(msg) 54 + None -> value.Null 55 + } 56 + 57 + value.Object([ 58 + #("id", value.Int(entry.id)), 59 + #("timestamp", value.String(entry.timestamp)), 60 + #("operation", value.String(entry.operation)), 61 + #("collection", value.String(entry.collection)), 62 + #("did", value.String(entry.did)), 63 + #("status", value.String(entry.status)), 64 + #("errorMessage", error_msg_value), 65 + #("eventJson", value.String(entry.event_json)), 66 + ]) 67 + } 68 + 69 + /// Convert Settings data to GraphQL value 70 + pub fn settings_to_value( 71 + domain_authority: String, 72 + admin_dids: List(String), 73 + relay_url: String, 74 + plc_directory_url: String, 75 + jetstream_url: String, 76 + oauth_supported_scopes: String, 77 + ) -> value.Value { 78 + value.Object([ 79 + #("id", value.String("Settings:singleton")), 80 + #("domainAuthority", value.String(domain_authority)), 81 + #("adminDids", value.List(list.map(admin_dids, value.String))), 82 + #("relayUrl", value.String(relay_url)), 83 + #("plcDirectoryUrl", value.String(plc_directory_url)), 84 + #("jetstreamUrl", value.String(jetstream_url)), 85 + #("oauthSupportedScopes", value.String(oauth_supported_scopes)), 86 + ]) 87 + } 88 + 89 + /// Convert OAuthClient domain type to GraphQL value 90 + pub fn oauth_client_to_value(client: OAuthClient) -> value.Value { 91 + let secret_value = case client.client_secret { 92 + Some(s) -> value.String(s) 93 + None -> value.Null 94 + } 95 + let scope_value = case client.scope { 96 + Some(s) -> value.String(s) 97 + None -> value.Null 98 + } 99 + value.Object([ 100 + #("clientId", value.String(client.client_id)), 101 + #("clientSecret", secret_value), 102 + #("clientName", value.String(client.client_name)), 103 + #("clientType", value.String(client_type_to_string(client.client_type))), 104 + #("redirectUris", value.List(list.map(client.redirect_uris, value.String))), 105 + #("scope", scope_value), 106 + #("createdAt", value.Int(client.created_at)), 107 + ]) 108 + } 109 + 110 + /// Convert Lexicon domain type to GraphQL value 111 + pub fn lexicon_to_value(lexicon: Lexicon) -> value.Value { 112 + value.Object([ 113 + #("id", value.String(lexicon.id)), 114 + #("json", value.String(lexicon.json)), 115 + #("createdAt", value.String(lexicon.created_at)), 116 + ]) 117 + }
+237
server/src/graphql/admin/queries.gleam
··· 1 + /// Query resolvers for admin GraphQL API 2 + import admin_session as session 3 + import backfill_state 4 + import database/repositories/actors 5 + import database/repositories/config as config_repo 6 + import database/repositories/jetstream_activity 7 + import database/repositories/lexicons 8 + import database/repositories/oauth_clients 9 + import database/repositories/records 10 + import gleam/erlang/process.{type Subject} 11 + import gleam/list 12 + import gleam/option.{None, Some} 13 + import gleam/otp/actor 14 + import graphql/admin/converters 15 + import graphql/admin/types as admin_types 16 + import lib/oauth/did_cache 17 + import sqlight 18 + import swell/schema 19 + import swell/value 20 + import wisp 21 + 22 + /// Fetch activity buckets for a given time range 23 + fn fetch_activity_buckets( 24 + conn: sqlight.Connection, 25 + range: admin_types.TimeRange, 26 + ) -> Result(value.Value, String) { 27 + let fetch_result = case range { 28 + admin_types.OneHour -> jetstream_activity.get_activity_1hr(conn) 29 + admin_types.ThreeHours -> jetstream_activity.get_activity_3hr(conn) 30 + admin_types.SixHours -> jetstream_activity.get_activity_6hr(conn) 31 + admin_types.OneDay -> jetstream_activity.get_activity_1day(conn) 32 + admin_types.SevenDays -> jetstream_activity.get_activity_7day(conn) 33 + } 34 + case fetch_result { 35 + Ok(buckets) -> 36 + Ok(value.List(list.map(buckets, converters.activity_bucket_to_value))) 37 + Error(_) -> Error("Failed to fetch activity data") 38 + } 39 + } 40 + 41 + /// Build the Query root type with all query resolvers 42 + pub fn query_type( 43 + conn: sqlight.Connection, 44 + req: wisp.Request, 45 + did_cache: Subject(did_cache.Message), 46 + backfill_state_subject: Subject(backfill_state.Message), 47 + ) -> schema.Type { 48 + schema.object_type("Query", "Root query type", [ 49 + // currentSession query 50 + schema.field( 51 + "currentSession", 52 + admin_types.current_session_type(), 53 + "Get current authenticated user session (null if not authenticated)", 54 + fn(_ctx) { 55 + case session.get_current_session(req, conn, did_cache) { 56 + Ok(sess) -> { 57 + let user_is_admin = config_repo.is_admin(conn, sess.did) 58 + Ok(converters.current_session_to_value( 59 + sess.did, 60 + sess.handle, 61 + user_is_admin, 62 + )) 63 + } 64 + Error(_) -> Ok(value.Null) 65 + } 66 + }, 67 + ), 68 + // statistics query 69 + schema.field( 70 + "statistics", 71 + schema.non_null(admin_types.statistics_type()), 72 + "Get system statistics", 73 + fn(_ctx) { 74 + case 75 + records.get_count(conn), 76 + actors.get_count(conn), 77 + lexicons.get_count(conn) 78 + { 79 + Ok(record_count), Ok(actor_count), Ok(lexicon_count) -> { 80 + Ok(converters.statistics_to_value( 81 + record_count, 82 + actor_count, 83 + lexicon_count, 84 + )) 85 + } 86 + _, _, _ -> Error("Failed to fetch statistics") 87 + } 88 + }, 89 + ), 90 + // settings query 91 + schema.field( 92 + "settings", 93 + schema.non_null(admin_types.settings_type()), 94 + "Get system settings", 95 + fn(_ctx) { 96 + let domain_authority = case config_repo.get(conn, "domain_authority") { 97 + Ok(authority) -> authority 98 + Error(_) -> "" 99 + } 100 + let admin_dids = config_repo.get_admin_dids(conn) 101 + let relay_url = config_repo.get_relay_url(conn) 102 + let plc_directory_url = config_repo.get_plc_directory_url(conn) 103 + let jetstream_url = config_repo.get_jetstream_url(conn) 104 + let oauth_supported_scopes = 105 + config_repo.get_oauth_supported_scopes(conn) 106 + 107 + Ok(converters.settings_to_value( 108 + domain_authority, 109 + admin_dids, 110 + relay_url, 111 + plc_directory_url, 112 + jetstream_url, 113 + oauth_supported_scopes, 114 + )) 115 + }, 116 + ), 117 + // isBackfilling query 118 + schema.field( 119 + "isBackfilling", 120 + schema.non_null(schema.boolean_type()), 121 + "Check if a backfill operation is currently running", 122 + fn(_ctx) { 123 + let is_backfilling = 124 + actor.call( 125 + backfill_state_subject, 126 + waiting: 100, 127 + sending: backfill_state.IsBackfilling, 128 + ) 129 + Ok(value.Boolean(is_backfilling)) 130 + }, 131 + ), 132 + // lexicons query 133 + schema.field( 134 + "lexicons", 135 + schema.non_null( 136 + schema.list_type(schema.non_null(admin_types.lexicon_type())), 137 + ), 138 + "Get all lexicons", 139 + fn(_ctx) { 140 + case lexicons.get_all(conn) { 141 + Ok(lexicon_list) -> 142 + Ok(value.List(list.map(lexicon_list, converters.lexicon_to_value))) 143 + Error(_) -> Error("Failed to fetch lexicons") 144 + } 145 + }, 146 + ), 147 + // oauthClients query (admin only) 148 + schema.field( 149 + "oauthClients", 150 + schema.non_null( 151 + schema.list_type(schema.non_null(admin_types.oauth_client_type())), 152 + ), 153 + "Get all OAuth client registrations (admin only)", 154 + fn(_ctx) { 155 + case session.get_current_session(req, conn, did_cache) { 156 + Ok(sess) -> { 157 + case config_repo.is_admin(conn, sess.did) { 158 + True -> { 159 + case oauth_clients.get_all(conn) { 160 + Ok(clients) -> 161 + Ok( 162 + value.List(list.map( 163 + clients, 164 + converters.oauth_client_to_value, 165 + )), 166 + ) 167 + Error(_) -> Error("Failed to fetch OAuth clients") 168 + } 169 + } 170 + False -> Error("Admin privileges required") 171 + } 172 + } 173 + Error(_) -> Error("Authentication required") 174 + } 175 + }, 176 + ), 177 + // activityBuckets query with TimeRange argument 178 + schema.field_with_args( 179 + "activityBuckets", 180 + schema.non_null( 181 + schema.list_type(schema.non_null(admin_types.activity_bucket_type())), 182 + ), 183 + "Get activity data bucketed by time range", 184 + [ 185 + schema.argument( 186 + "range", 187 + schema.non_null(admin_types.time_range_enum()), 188 + "Time range for bucketing", 189 + None, 190 + ), 191 + ], 192 + fn(ctx) { 193 + case schema.get_argument(ctx, "range") { 194 + Some(value.String(range_str)) -> 195 + case admin_types.time_range_from_string(range_str) { 196 + Ok(range) -> fetch_activity_buckets(conn, range) 197 + Error(_) -> Error("Invalid time range argument") 198 + } 199 + _ -> Error("Missing time range argument") 200 + } 201 + }, 202 + ), 203 + // recentActivity query with hours argument 204 + schema.field_with_args( 205 + "recentActivity", 206 + schema.non_null( 207 + schema.list_type(schema.non_null(admin_types.activity_entry_type())), 208 + ), 209 + "Get recent activity entries", 210 + [ 211 + schema.argument( 212 + "hours", 213 + schema.non_null(schema.int_type()), 214 + "Number of hours to look back", 215 + None, 216 + ), 217 + ], 218 + fn(ctx) { 219 + case schema.get_argument(ctx, "hours") { 220 + Some(value.Int(hours)) -> { 221 + case jetstream_activity.get_recent_activity(conn, hours) { 222 + Ok(entries) -> 223 + Ok( 224 + value.List(list.map( 225 + entries, 226 + converters.activity_entry_to_value, 227 + )), 228 + ) 229 + Error(_) -> Error("Failed to fetch recent activity") 230 + } 231 + } 232 + _ -> Error("Invalid or missing hours argument") 233 + } 234 + }, 235 + ), 236 + ]) 237 + }
+36
server/src/graphql/admin/schema.gleam
··· 1 + /// Admin GraphQL schema entry point 2 + /// 3 + /// This is the public API for the admin GraphQL schema. 4 + /// External code should import this module to build the schema. 5 + import backfill_state 6 + import gleam/erlang/process.{type Subject} 7 + import gleam/option.{type Option, Some} 8 + import graphql/admin/mutations 9 + import graphql/admin/queries 10 + import jetstream_consumer 11 + import lib/oauth/did_cache 12 + import sqlight 13 + import swell/schema 14 + import wisp 15 + 16 + /// Build the complete GraphQL schema for admin queries 17 + pub fn build_schema( 18 + conn: sqlight.Connection, 19 + req: wisp.Request, 20 + jetstream_subject: Option(Subject(jetstream_consumer.ManagerMessage)), 21 + did_cache: Subject(did_cache.Message), 22 + oauth_supported_scopes: List(String), 23 + backfill_state_subject: Subject(backfill_state.Message), 24 + ) -> schema.Schema { 25 + schema.schema( 26 + queries.query_type(conn, req, did_cache, backfill_state_subject), 27 + Some(mutations.mutation_type( 28 + conn, 29 + req, 30 + jetstream_subject, 31 + did_cache, 32 + oauth_supported_scopes, 33 + backfill_state_subject, 34 + )), 35 + ) 36 + }
+306
server/src/graphql/admin/types.gleam
··· 1 + /// GraphQL type definitions for admin API 2 + /// 3 + /// Contains all object types, enum types, and the get_field helper 4 + import gleam/list 5 + import gleam/option.{Some} 6 + import swell/schema 7 + import swell/value 8 + 9 + /// Extract a field from GraphQL context data, returning Null if not found 10 + pub fn get_field(ctx: schema.Context, field_name: String) -> value.Value { 11 + case ctx.data { 12 + Some(value.Object(fields)) -> { 13 + case list.key_find(fields, field_name) { 14 + Ok(v) -> v 15 + Error(_) -> value.Null 16 + } 17 + } 18 + _ -> value.Null 19 + } 20 + } 21 + 22 + /// TimeRange variants for activity queries 23 + pub type TimeRange { 24 + OneHour 25 + ThreeHours 26 + SixHours 27 + OneDay 28 + SevenDays 29 + } 30 + 31 + /// Parse a GraphQL string value to TimeRange type 32 + pub fn time_range_from_string(s: String) -> Result(TimeRange, Nil) { 33 + case s { 34 + "ONE_HOUR" -> Ok(OneHour) 35 + "THREE_HOURS" -> Ok(ThreeHours) 36 + "SIX_HOURS" -> Ok(SixHours) 37 + "ONE_DAY" -> Ok(OneDay) 38 + "SEVEN_DAYS" -> Ok(SevenDays) 39 + _ -> Error(Nil) 40 + } 41 + } 42 + 43 + /// TimeRange enum for activity queries (GraphQL schema type) 44 + pub fn time_range_enum() -> schema.Type { 45 + schema.enum_type("TimeRange", "Time range for activity data", [ 46 + schema.enum_value("ONE_HOUR", "Last 1 hour (5-min buckets)"), 47 + schema.enum_value("THREE_HOURS", "Last 3 hours (15-min buckets)"), 48 + schema.enum_value("SIX_HOURS", "Last 6 hours (30-min buckets)"), 49 + schema.enum_value("ONE_DAY", "Last 24 hours (1-hour buckets)"), 50 + schema.enum_value("SEVEN_DAYS", "Last 7 days (daily buckets)"), 51 + ]) 52 + } 53 + 54 + /// Statistics type showing record, actor, and lexicon counts 55 + pub fn statistics_type() -> schema.Type { 56 + schema.object_type("Statistics", "System statistics", [ 57 + schema.field( 58 + "recordCount", 59 + schema.non_null(schema.int_type()), 60 + "Total number of records", 61 + fn(ctx) { Ok(get_field(ctx, "recordCount")) }, 62 + ), 63 + schema.field( 64 + "actorCount", 65 + schema.non_null(schema.int_type()), 66 + "Total number of actors", 67 + fn(ctx) { Ok(get_field(ctx, "actorCount")) }, 68 + ), 69 + schema.field( 70 + "lexiconCount", 71 + schema.non_null(schema.int_type()), 72 + "Total number of lexicons", 73 + fn(ctx) { Ok(get_field(ctx, "lexiconCount")) }, 74 + ), 75 + ]) 76 + } 77 + 78 + /// CurrentSession type for authenticated user information 79 + pub fn current_session_type() -> schema.Type { 80 + schema.object_type("CurrentSession", "Current authenticated user session", [ 81 + schema.field( 82 + "did", 83 + schema.non_null(schema.string_type()), 84 + "User's DID", 85 + fn(ctx) { Ok(get_field(ctx, "did")) }, 86 + ), 87 + schema.field( 88 + "handle", 89 + schema.non_null(schema.string_type()), 90 + "User's handle", 91 + fn(ctx) { Ok(get_field(ctx, "handle")) }, 92 + ), 93 + schema.field( 94 + "isAdmin", 95 + schema.non_null(schema.boolean_type()), 96 + "Whether the user is an admin", 97 + fn(ctx) { Ok(get_field(ctx, "isAdmin")) }, 98 + ), 99 + ]) 100 + } 101 + 102 + /// ActivityBucket type for aggregated activity data 103 + pub fn activity_bucket_type() -> schema.Type { 104 + schema.object_type("ActivityBucket", "Time-bucketed activity counts", [ 105 + schema.field( 106 + "timestamp", 107 + schema.non_null(schema.string_type()), 108 + "Bucket timestamp", 109 + fn(ctx) { Ok(get_field(ctx, "timestamp")) }, 110 + ), 111 + schema.field( 112 + "total", 113 + schema.non_null(schema.int_type()), 114 + "Total operations in bucket", 115 + fn(ctx) { Ok(get_field(ctx, "total")) }, 116 + ), 117 + schema.field( 118 + "creates", 119 + schema.non_null(schema.int_type()), 120 + "Create operations", 121 + fn(ctx) { Ok(get_field(ctx, "creates")) }, 122 + ), 123 + schema.field( 124 + "updates", 125 + schema.non_null(schema.int_type()), 126 + "Update operations", 127 + fn(ctx) { Ok(get_field(ctx, "updates")) }, 128 + ), 129 + schema.field( 130 + "deletes", 131 + schema.non_null(schema.int_type()), 132 + "Delete operations", 133 + fn(ctx) { Ok(get_field(ctx, "deletes")) }, 134 + ), 135 + ]) 136 + } 137 + 138 + /// Lexicon type for AT Protocol lexicon schemas 139 + pub fn lexicon_type() -> schema.Type { 140 + schema.object_type("Lexicon", "AT Protocol lexicon schema definition", [ 141 + schema.field( 142 + "id", 143 + schema.non_null(schema.string_type()), 144 + "Lexicon NSID (e.g., app.bsky.feed.post)", 145 + fn(ctx) { Ok(get_field(ctx, "id")) }, 146 + ), 147 + schema.field( 148 + "json", 149 + schema.non_null(schema.string_type()), 150 + "Full lexicon JSON content", 151 + fn(ctx) { Ok(get_field(ctx, "json")) }, 152 + ), 153 + schema.field( 154 + "createdAt", 155 + schema.non_null(schema.string_type()), 156 + "Timestamp when lexicon was created", 157 + fn(ctx) { Ok(get_field(ctx, "createdAt")) }, 158 + ), 159 + ]) 160 + } 161 + 162 + /// Settings type for configuration 163 + pub fn settings_type() -> schema.Type { 164 + schema.object_type("Settings", "System settings and configuration", [ 165 + schema.field( 166 + "id", 167 + schema.non_null(schema.string_type()), 168 + "Global ID for normalization", 169 + fn(_ctx) { Ok(value.String("Settings:singleton")) }, 170 + ), 171 + schema.field( 172 + "domainAuthority", 173 + schema.non_null(schema.string_type()), 174 + "Domain authority configuration", 175 + fn(ctx) { Ok(get_field(ctx, "domainAuthority")) }, 176 + ), 177 + schema.field( 178 + "adminDids", 179 + schema.non_null(schema.list_type(schema.non_null(schema.string_type()))), 180 + "List of admin DIDs", 181 + fn(ctx) { 182 + case get_field(ctx, "adminDids") { 183 + value.Null -> Ok(value.List([])) 184 + other -> Ok(other) 185 + } 186 + }, 187 + ), 188 + schema.field( 189 + "relayUrl", 190 + schema.non_null(schema.string_type()), 191 + "AT Protocol relay URL for backfill operations", 192 + fn(ctx) { Ok(get_field(ctx, "relayUrl")) }, 193 + ), 194 + schema.field( 195 + "plcDirectoryUrl", 196 + schema.non_null(schema.string_type()), 197 + "PLC directory URL for DID resolution", 198 + fn(ctx) { Ok(get_field(ctx, "plcDirectoryUrl")) }, 199 + ), 200 + schema.field( 201 + "jetstreamUrl", 202 + schema.non_null(schema.string_type()), 203 + "Jetstream WebSocket endpoint for real-time indexing", 204 + fn(ctx) { Ok(get_field(ctx, "jetstreamUrl")) }, 205 + ), 206 + schema.field( 207 + "oauthSupportedScopes", 208 + schema.non_null(schema.string_type()), 209 + "Space-separated OAuth scopes supported by this server", 210 + fn(ctx) { Ok(get_field(ctx, "oauthSupportedScopes")) }, 211 + ), 212 + ]) 213 + } 214 + 215 + /// OAuthClient type for client registration management 216 + pub fn oauth_client_type() -> schema.Type { 217 + schema.object_type("OAuthClient", "OAuth client registration", [ 218 + schema.field( 219 + "clientId", 220 + schema.non_null(schema.string_type()), 221 + "Client ID", 222 + fn(ctx) { Ok(get_field(ctx, "clientId")) }, 223 + ), 224 + schema.field( 225 + "clientSecret", 226 + schema.string_type(), 227 + "Client secret (confidential clients only)", 228 + fn(ctx) { Ok(get_field(ctx, "clientSecret")) }, 229 + ), 230 + schema.field( 231 + "clientName", 232 + schema.non_null(schema.string_type()), 233 + "Client display name", 234 + fn(ctx) { Ok(get_field(ctx, "clientName")) }, 235 + ), 236 + schema.field( 237 + "clientType", 238 + schema.non_null(schema.string_type()), 239 + "PUBLIC or CONFIDENTIAL", 240 + fn(ctx) { Ok(get_field(ctx, "clientType")) }, 241 + ), 242 + schema.field( 243 + "redirectUris", 244 + schema.non_null(schema.list_type(schema.non_null(schema.string_type()))), 245 + "Allowed redirect URIs", 246 + fn(ctx) { Ok(get_field(ctx, "redirectUris")) }, 247 + ), 248 + schema.field( 249 + "scope", 250 + schema.string_type(), 251 + "OAuth scopes for this client (space-separated)", 252 + fn(ctx) { Ok(get_field(ctx, "scope")) }, 253 + ), 254 + schema.field( 255 + "createdAt", 256 + schema.non_null(schema.int_type()), 257 + "Creation timestamp", 258 + fn(ctx) { Ok(get_field(ctx, "createdAt")) }, 259 + ), 260 + ]) 261 + } 262 + 263 + /// ActivityEntry type for individual activity records 264 + pub fn activity_entry_type() -> schema.Type { 265 + schema.object_type("ActivityEntry", "Individual activity log entry", [ 266 + schema.field("id", schema.non_null(schema.int_type()), "Entry ID", fn(ctx) { 267 + Ok(get_field(ctx, "id")) 268 + }), 269 + schema.field( 270 + "timestamp", 271 + schema.non_null(schema.string_type()), 272 + "Timestamp", 273 + fn(ctx) { Ok(get_field(ctx, "timestamp")) }, 274 + ), 275 + schema.field( 276 + "operation", 277 + schema.non_null(schema.string_type()), 278 + "Operation type", 279 + fn(ctx) { Ok(get_field(ctx, "operation")) }, 280 + ), 281 + schema.field( 282 + "collection", 283 + schema.non_null(schema.string_type()), 284 + "Collection name", 285 + fn(ctx) { Ok(get_field(ctx, "collection")) }, 286 + ), 287 + schema.field("did", schema.non_null(schema.string_type()), "DID", fn(ctx) { 288 + Ok(get_field(ctx, "did")) 289 + }), 290 + schema.field( 291 + "status", 292 + schema.non_null(schema.string_type()), 293 + "Processing status", 294 + fn(ctx) { Ok(get_field(ctx, "status")) }, 295 + ), 296 + schema.field( 297 + "errorMessage", 298 + schema.string_type(), 299 + "Error message if failed", 300 + fn(ctx) { Ok(get_field(ctx, "errorMessage")) }, 301 + ), 302 + schema.field("eventJson", schema.string_type(), "Raw event JSON", fn(ctx) { 303 + Ok(get_field(ctx, "eventJson")) 304 + }), 305 + ]) 306 + }
+6 -6
server/src/handlers/client_graphql.gleam server/src/handlers/admin_graphql.gleam
··· 1 - /// GraphQL HTTP handler for client statistics and activity API 1 + /// GraphQL HTTP handler for admin API 2 2 /// 3 3 /// This handler serves the /admin/graphql endpoint which provides 4 - /// stats and activity data to the client SPA using a separate schema 4 + /// stats, settings, and activity data to the admin SPA 5 5 import backfill_state 6 - import client_schema 7 6 import gleam/bit_array 8 7 import gleam/dict 9 8 import gleam/dynamic/decode ··· 12 11 import gleam/json 13 12 import gleam/list 14 13 import gleam/option 14 + import graphql/admin/schema as admin_schema 15 15 import jetstream_consumer 16 16 import lib/oauth/did_cache 17 17 import sqlight ··· 20 20 import swell/value 21 21 import wisp 22 22 23 - /// Handle GraphQL HTTP requests for client API 24 - pub fn handle_client_graphql_request( 23 + /// Handle GraphQL HTTP requests for admin API 24 + pub fn handle_admin_graphql_request( 25 25 req: wisp.Request, 26 26 db: sqlight.Connection, 27 27 jetstream_subject: option.Option(Subject(jetstream_consumer.ManagerMessage)), ··· 123 123 ) -> wisp.Response { 124 124 // Build the schema 125 125 let graphql_schema = 126 - client_schema.build_schema( 126 + admin_schema.build_schema( 127 127 db, 128 128 req, 129 129 jetstream_subject,
+2 -2
server/src/server.gleam
··· 15 15 import gleam/option 16 16 import gleam/string 17 17 import gleam/uri 18 + import handlers/admin_graphql as admin_graphql_handler 18 19 import handlers/admin_oauth_authorize as admin_oauth_authorize_handler 19 20 import handlers/admin_oauth_callback as admin_oauth_callback_handler 20 - import handlers/client_graphql as client_graphql_handler 21 21 import handlers/graphiql as graphiql_handler 22 22 import handlers/graphql as graphql_handler 23 23 import handlers/graphql_ws as graphql_ws_handler ··· 480 480 ) 481 481 } 482 482 ["admin", "graphql"] -> 483 - client_graphql_handler.handle_client_graphql_request( 483 + admin_graphql_handler.handle_admin_graphql_request( 484 484 req, 485 485 ctx.db, 486 486 ctx.jetstream_consumer,