Highly ambitious ATProtocol AppView service and sdks

clean up /api, use social.slices

api/lexicons.zip

This is a binary file and will not be displayed.

-10
api/migrations/002_lexicons.sql
··· 1 - -- Add lexicons table for storing AT Protocol lexicon schemas 2 - CREATE TABLE IF NOT EXISTS "lexicons" ( 3 - "nsid" TEXT PRIMARY KEY NOT NULL, 4 - "definitions" JSONB NOT NULL, 5 - "created_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(), 6 - "updated_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW() 7 - ); 8 - 9 - CREATE INDEX IF NOT EXISTS idx_lexicons_nsid ON "lexicons"("nsid"); 10 - CREATE INDEX IF NOT EXISTS idx_lexicons_definitions ON "lexicons" USING gin("definitions");
api/migrations/003_actors.sql api/migrations/002_actors.sql
-1
api/scripts/generated_client.ts
··· 1 - null
+6 -7
api/scripts/test_codegen.sh
··· 4 4 5 5 # Test with multiple lexicons 6 6 echo "📝 Generating TypeScript client with multiple lexicons..." 7 - curl -s -X POST http://localhost:3000/xrpc/social.slices.codegen.generate \ 7 + curl -s -X POST http://localhost:3000/xrpc/social.slices.slice.codegen \ 8 8 -H "Content-Type: application/json" \ 9 9 -d '{ 10 - "target": "typescript-deno", 11 - "client_type": "records", 12 - "lexicons": ["social.grain.gallery", "social.grain.comment"] 10 + "target": "typescript", 11 + "lexicons": ["social.slices.slice", "social.slices.lexicon"] 13 12 }' | jq -r '.generated_code' > generated_client.ts 14 13 15 14 if [ $? -eq 0 ] && [ -f generated_client.ts ]; then ··· 17 16 echo "📊 Generated code stats:" 18 17 echo " Lines: $(wc -l < generated_client.ts)" 19 18 echo " Size: $(du -h generated_client.ts | cut -f1)" 20 - 19 + 21 20 echo "" 22 21 echo "🔍 Preview of generated interfaces:" 23 22 grep -A 5 "export interface.*Record {" generated_client.ts || echo "No record interfaces found" 24 - 23 + 25 24 echo "" 26 25 echo "🎯 Preview of auto-typing examples:" 27 26 grep -A 10 "// Usage examples:" generated_client.ts || echo "No usage examples found" ··· 31 30 fi 32 31 33 32 echo "" 34 - echo "🎉 Test complete! Check generated_client.ts to see the auto-typing functionality." 33 + echo "🎉 Test complete! Check generated_client.ts to see the auto-typing functionality."
+19
api/scripts/test_sync.sh
··· 1 + #!/bin/bash 2 + 3 + echo "🔄 Testing Sync Endpoint..." 4 + 5 + echo "🎯 Syncing specific collections with specific repos" 6 + curl -s -X POST http://localhost:3000/xrpc/social.slices.slice.sync \ 7 + -H "Content-Type: application/json" \ 8 + -d '{ 9 + "collections": [ 10 + "social.slice.slice", 11 + "social.slice.lexicon" 12 + ], 13 + "repos": [ 14 + "did:plc:bcgltzqazw5tb6k2g3ttenbj" 15 + ] 16 + }' | jq '.' 17 + 18 + echo "" 19 + echo "✅ Sync test complete!"
+3 -25
api/src/codegen/typescript.rs
··· 1 - use crate::models::Lexicon; 2 - 3 1 pub struct TypeScriptGenerator; 4 2 5 3 impl TypeScriptGenerator { ··· 7 5 Self 8 6 } 9 7 10 - pub fn generate_client(&self, lexicons: &[Lexicon]) -> Result<String, String> { 11 - // Serialize lexicons to JSON for the Deno script 12 - let lexicons_json = serde_json::to_string(lexicons) 13 - .map_err(|e| format!("Failed to serialize lexicons: {}", e))?; 14 - 15 - // Call the Deno script to generate TypeScript with proper comments 16 - let output = std::process::Command::new("deno") 17 - .arg("run") 18 - .arg("--allow-all") 19 - .arg("scripts/generate-typescript.ts") 20 - .arg(&lexicons_json) 21 - .output() 22 - .map_err(|e| format!("Failed to execute deno script: {}", e))?; 23 - 24 - if !output.status.success() { 25 - let stderr = String::from_utf8_lossy(&output.stderr); 26 - return Err(format!("Deno script failed: {}", stderr)); 27 - } 28 - 29 - let generated_code = String::from_utf8(output.stdout) 30 - .map_err(|e| format!("Failed to decode output: {}", e))?; 31 - 32 - Ok(generated_code) 8 + pub fn generate_client(&self, _lexicons_json: &str) -> Result<String, String> { 9 + // TODO: Implement TypeScript generation - functionality moved to /frontend 10 + Ok("// TypeScript generation placeholder - functionality moved to /frontend\n".to_string()) 33 11 } 34 12 }
+19 -56
api/src/database.rs
··· 1 1 use sqlx::PgPool; 2 2 3 3 use crate::errors::DatabaseError; 4 - use crate::models::{Actor, IndexedRecord, Lexicon, ListRecordsParams, Record}; 4 + use crate::models::{Actor, IndexedRecord, Record}; 5 5 6 6 #[derive(Clone)] 7 7 pub struct Database { ··· 85 85 Ok(indexed_record) 86 86 } 87 87 88 - pub async fn list_records(&self, params: ListRecordsParams) -> Result<Vec<IndexedRecord>, DatabaseError> { 89 - let limit = params.limit.unwrap_or(25).min(100); 88 + pub async fn list_records(&self, collection: &str, author: Option<&str>, limit: Option<i32>) -> Result<Vec<IndexedRecord>, DatabaseError> { 89 + let limit = limit.unwrap_or(25).min(100); 90 90 91 91 let records = sqlx::query_as::<_, Record>( 92 92 r#"SELECT "uri", "cid", "did", "collection", "json", "indexedAt" ··· 96 96 ORDER BY "indexedAt" DESC 97 97 LIMIT $3"#, 98 98 ) 99 - .bind(&params.collection) 100 - .bind(&params.author) 99 + .bind(collection) 100 + .bind(author) 101 101 .bind(limit) 102 102 .fetch_all(&self.pool) 103 103 .await?; ··· 117 117 Ok(indexed_records) 118 118 } 119 119 120 - pub async fn get_available_collections(&self) -> Result<Vec<(String, i64)>, DatabaseError> { 121 - let collections = sqlx::query!( 122 - r#"SELECT "collection", COUNT(*) as count 120 + pub async fn get_lexicons_by_slice(&self, slice_uri: &str) -> Result<Vec<serde_json::Value>, DatabaseError> { 121 + let records = sqlx::query_as::<_, Record>( 122 + r#"SELECT "uri", "cid", "did", "collection", "json", "indexedAt" 123 123 FROM "record" 124 - GROUP BY "collection" 125 - ORDER BY count DESC, "collection" ASC"# 124 + WHERE "collection" = 'social.slices.lexicon' 125 + AND "json"->>'slice' = $1 126 + ORDER BY "indexedAt" DESC"#, 126 127 ) 128 + .bind(slice_uri) 127 129 .fetch_all(&self.pool) 128 130 .await?; 129 131 130 - Ok(collections 132 + let lexicon_definitions: Vec<serde_json::Value> = records 131 133 .into_iter() 132 - .map(|row| (row.collection, row.count.unwrap_or(0))) 133 - .collect()) 134 + .filter_map(|record| record.json.get("definition").cloned()) 135 + .collect(); 136 + 137 + Ok(lexicon_definitions) 134 138 } 139 + 140 + 135 141 136 142 pub async fn get_total_record_count(&self) -> Result<i64, DatabaseError> { 137 143 let count = sqlx::query!("SELECT COUNT(*) as count FROM record") ··· 141 147 Ok(count.count.unwrap_or(0)) 142 148 } 143 149 144 - pub async fn insert_lexicon(&self, lexicon: &Lexicon) -> Result<(), DatabaseError> { 145 - sqlx::query!( 146 - r#"INSERT INTO "lexicons" ("nsid", "definitions", "created_at", "updated_at") 147 - VALUES ($1, $2, $3, $4) 148 - ON CONFLICT ("nsid") 149 - DO UPDATE SET 150 - "definitions" = EXCLUDED."definitions", 151 - "updated_at" = EXCLUDED."updated_at""#, 152 - lexicon.nsid, 153 - lexicon.definitions, 154 - lexicon.created_at, 155 - lexicon.updated_at 156 - ) 157 - .execute(&self.pool) 158 - .await?; 159 - 160 - Ok(()) 161 - } 162 - 163 - pub async fn get_lexicon(&self, nsid: &str) -> Result<Option<Lexicon>, DatabaseError> { 164 - let lexicon = sqlx::query_as::<_, Lexicon>( 165 - r#"SELECT "nsid", "definitions", "created_at", "updated_at" 166 - FROM "lexicons" 167 - WHERE "nsid" = $1"#, 168 - ) 169 - .bind(nsid) 170 - .fetch_optional(&self.pool) 171 - .await?; 172 - 173 - Ok(lexicon) 174 - } 175 - 176 - pub async fn get_all_lexicons(&self) -> Result<Vec<Lexicon>, DatabaseError> { 177 - let lexicons = sqlx::query_as::<_, Lexicon>( 178 - r#"SELECT "nsid", "definitions", "created_at", "updated_at" 179 - FROM "lexicons" 180 - ORDER BY "nsid""#, 181 - ) 182 - .fetch_all(&self.pool) 183 - .await?; 184 - 185 - Ok(lexicons) 186 - } 187 150 188 151 pub async fn update_record(&self, record: &Record) -> Result<(), DatabaseError> { 189 152 let result = sqlx::query!(
+1 -24
api/src/errors.rs
··· 1 1 use thiserror::Error; 2 2 3 - #[derive(Error, Debug)] 4 - pub enum LexiconError { 5 - #[error("error-slice-lexicon-1 Failed to parse multipart boundary: {0}")] 6 - MultipartBoundary(String), 7 - 8 - #[error("error-slice-lexicon-2 Failed to read request body: {0}")] 9 - RequestBody(String), 10 - 11 - #[error("error-slice-lexicon-3 Failed to parse zip archive: {0}")] 12 - ZipArchive(String), 13 - 14 - #[error("error-slice-lexicon-4 Failed to read file from archive: {0}")] 15 - FileRead(String), 16 - 17 - #[error("error-slice-lexicon-5 Failed to parse JSON lexicon: {0}")] 18 - JsonParse(String), 19 - } 20 3 21 4 #[derive(Error, Debug)] 22 5 pub enum DatabaseError { 23 6 #[error("error-slice-database-1 SQL query failed: {0}")] 24 7 SqlQuery(#[from] sqlx::Error), 25 8 26 - #[error("error-slice-database-2 Transaction failed: {0}")] 27 - Transaction(String), 28 - 29 - #[error("error-slice-database-3 Record not found: {uri}")] 9 + #[error("error-slice-database-2 Record not found: {uri}")] 30 10 RecordNotFound { uri: String }, 31 11 } 32 12 ··· 64 44 65 45 #[error("error-slice-app-3 Server bind failed: {0}")] 66 46 ServerBind(#[from] std::io::Error), 67 - 68 - #[error("error-slice-app-4 Environment variable error: {0}")] 69 - Environment(String), 70 47 } 71 48
-103
api/src/handler_codegen.rs
··· 1 - use axum::{ 2 - extract::State, 3 - http::StatusCode, 4 - response::{Html, IntoResponse}, 5 - }; 6 - use axum_extra::extract::Form; 7 - use minijinja::{context, Environment}; 8 - use serde::Deserialize; 9 - use crate::AppState; 10 - use crate::codegen::TypeScriptGenerator; 11 - 12 - #[derive(Deserialize)] 13 - pub struct CodegenForm { 14 - target: String, 15 - client_type: String, 16 - #[serde(default)] 17 - lexicons: Vec<String>, 18 - } 19 - 20 - 21 - pub async fn generate_client( 22 - State(state): State<AppState>, 23 - Form(form): Form<CodegenForm>, 24 - ) -> Result<impl IntoResponse, StatusCode> { 25 - let selected_lexicons = form.lexicons; 26 - 27 - if selected_lexicons.is_empty() { 28 - return Ok(Html(r#" 29 - <div class="alert alert-error"> 30 - <h4>❌ No lexicons selected</h4> 31 - <p>Please select at least one lexicon to generate client code.</p> 32 - </div> 33 - "#.to_string())); 34 - } 35 - 36 - // Fetch the selected lexicons from database 37 - let mut lexicons = Vec::new(); 38 - for nsid in &selected_lexicons { 39 - if let Ok(Some(lexicon)) = state.database.get_lexicon(nsid).await { 40 - lexicons.push(lexicon); 41 - } 42 - } 43 - 44 - let generated_code = match form.target.as_str() { 45 - "typescript-deno" => match form.client_type.as_str() { 46 - "records" => { 47 - let generator = TypeScriptGenerator::new(); 48 - match generator.generate_client(&lexicons) { 49 - Ok(code) => code, 50 - Err(e) => return Ok(Html(format!(r#" 51 - <div class="alert alert-error"> 52 - <h4>❌ TypeScript generation failed</h4> 53 - <p>Error: {}</p> 54 - </div> 55 - "#, e))), 56 - } 57 - }, 58 - _ => return Ok(Html(r#" 59 - <div class="alert alert-error"> 60 - <h4>❌ Unsupported client type</h4> 61 - <p>Only "records" client type is currently supported.</p> 62 - </div> 63 - "#.to_string())), 64 - }, 65 - _ => return Ok(Html(r#" 66 - <div class="alert alert-error"> 67 - <h4>❌ Unsupported target</h4> 68 - <p>Only "typescript-deno" is currently supported.</p> 69 - </div> 70 - "#.to_string())), 71 - }; 72 - 73 - let mut env = Environment::new(); 74 - env.add_template("codegen_result.html", r#" 75 - <div class="alert alert-success"> 76 - <h4>✅ Client code generated successfully!</h4> 77 - <p><strong>Target:</strong> {{ target }}</p> 78 - <p><strong>Client Type:</strong> {{ client_type }}</p> 79 - <p><strong>Lexicons:</strong> {{ lexicons_count }}</p> 80 - 81 - <div class="mt-4"> 82 - <div class="flex justify-between items-center mb-2"> 83 - <h5 class="font-medium text-gray-800">Generated Code</h5> 84 - <button onclick="navigator.clipboard.writeText(document.getElementById('generated-code').textContent);" 85 - class="bg-blue-500 text-white px-3 py-1 rounded text-sm"> 86 - Copy to Clipboard 87 - </button> 88 - </div> 89 - <pre id="generated-code" class="bg-gray-100 p-4 rounded text-xs overflow-x-auto max-h-96 overflow-y-auto">{{ generated_code }}</pre> 90 - </div> 91 - </div> 92 - "#).unwrap(); 93 - 94 - let tmpl = env.get_template("codegen_result.html").unwrap(); 95 - let rendered = tmpl.render(context! { 96 - target => form.target, 97 - client_type => form.client_type, 98 - lexicons_count => lexicons.len(), 99 - generated_code => generated_code 100 - }).unwrap(); 101 - 102 - Ok(Html(rendered)) 103 - }
+2 -37
api/src/handler_dynamic_xrpc.rs api/src/handler_xrpc_dynamic.rs
··· 9 9 use atproto_identity::key::KeyData; 10 10 use atproto_oauth::jwk::WrappedJsonWebKey; 11 11 12 - use crate::models::{ListRecordsParams, ListRecordsOutput, Record}; 12 + use crate::models::{ListRecordsOutput, Record}; 13 13 use crate::AppState; 14 14 15 15 #[derive(Deserialize)] ··· 24 24 pub uri: String, 25 25 } 26 26 27 - #[derive(Deserialize)] 28 - pub struct CreateRecordParams { 29 - pub repo: String, 30 - pub collection: String, 31 - pub rkey: Option<String>, 32 - pub record: serde_json::Value, 33 - } 34 - 35 - #[derive(Deserialize)] 36 - pub struct UpdateRecordParams { 37 - pub repo: String, 38 - pub collection: String, 39 - pub rkey: String, 40 - pub record: serde_json::Value, 41 - } 42 - 43 - #[derive(Deserialize)] 44 - pub struct DeleteRecordParams { 45 - pub repo: String, 46 - pub collection: String, 47 - pub rkey: String, 48 - } 49 - 50 - #[derive(Serialize)] 51 - pub struct CreateRecordOutput { 52 - pub uri: String, 53 - pub cid: String, 54 - } 55 27 56 28 #[derive(Serialize, Deserialize, Debug)] 57 29 pub struct UserInfoResponse { ··· 250 222 let dynamic_params: DynamicListParams = serde_json::from_value(params) 251 223 .map_err(|_| StatusCode::BAD_REQUEST)?; 252 224 253 - let list_params = ListRecordsParams { 254 - collection, 255 - author: dynamic_params.author, 256 - limit: dynamic_params.limit, 257 - cursor: dynamic_params.cursor, 258 - }; 259 - 260 - match state.database.list_records(list_params).await { 225 + match state.database.list_records(&collection, dynamic_params.author.as_deref(), dynamic_params.limit).await { 261 226 Ok(records) => { 262 227 let output = ListRecordsOutput { 263 228 records,
-38
api/src/handler_lexicon.rs
··· 1 - use axum::{ 2 - extract::State, 3 - http::StatusCode, 4 - response::{Html, IntoResponse}, 5 - }; 6 - use minijinja::{context, Environment}; 7 - 8 - use crate::AppState; 9 - 10 - pub async fn lexicon_page( 11 - State(state): State<AppState>, 12 - ) -> Result<impl IntoResponse, StatusCode> { 13 - let lexicons = state.database.get_all_lexicons().await.unwrap_or_default(); 14 - 15 - // Transform lexicons to include pretty-printed JSON 16 - let lexicons_with_pretty_json: Vec<serde_json::Value> = lexicons.into_iter().map(|lexicon| { 17 - let pretty_definitions = serde_json::to_string_pretty(&lexicon.definitions).unwrap_or_else(|_| "{}".to_string()); 18 - serde_json::json!({ 19 - "nsid": lexicon.nsid, 20 - "definitions": lexicon.definitions, 21 - "pretty_definitions": pretty_definitions, 22 - "created_at": lexicon.created_at, 23 - "updated_at": lexicon.updated_at 24 - }) 25 - }).collect(); 26 - 27 - let mut env = Environment::new(); 28 - env.add_template("base.html", include_str!("../templates/base.html")).unwrap(); 29 - env.add_template("lexicon.html", include_str!("../templates/lexicon.html")).unwrap(); 30 - 31 - let tmpl = env.get_template("lexicon.html").unwrap(); 32 - let rendered = tmpl.render(context! { 33 - title => "Lexicon Definitions", 34 - lexicons => lexicons_with_pretty_json 35 - }).unwrap(); 36 - 37 - Ok(Html(rendered)) 38 - }
+36
api/src/handler_sync.rs
··· 1 + use axum::{ 2 + extract::State, 3 + http::StatusCode, 4 + response::Json, 5 + }; 6 + use crate::models::{BulkSyncOutput, BulkSyncParams}; 7 + use crate::AppState; 8 + 9 + pub async fn sync( 10 + State(state): State<AppState>, 11 + axum::extract::Json(params): axum::extract::Json<BulkSyncParams>, 12 + ) -> Result<Json<BulkSyncOutput>, StatusCode> { 13 + match state 14 + .sync_service 15 + .backfill_collections(&params.collections, params.repos.as_deref()) 16 + .await 17 + { 18 + Ok(_) => { 19 + let total_records = state.database.get_total_record_count().await.unwrap_or(0); 20 + Ok(Json(BulkSyncOutput { 21 + success: true, 22 + total_records, 23 + collections_synced: params.collections, 24 + repos_processed: params.repos.map(|r| r.len() as i64).unwrap_or(0), 25 + message: "Sync completed successfully".to_string(), 26 + })) 27 + } 28 + Err(e) => Ok(Json(BulkSyncOutput { 29 + success: false, 30 + total_records: 0, 31 + collections_synced: vec![], 32 + repos_processed: 0, 33 + message: format!("Sync failed: {}", e), 34 + })), 35 + } 36 + }
-250
api/src/handler_upload_lexicon.rs
··· 1 - use axum::{ 2 - extract::{Request, State}, 3 - http::StatusCode, 4 - response::{Html, IntoResponse}, 5 - }; 6 - use axum::body::to_bytes; 7 - use multer::Multipart; 8 - use futures_util::stream::once; 9 - use crate::errors::LexiconError; 10 - use crate::models::Lexicon; 11 - use crate::AppState; 12 - use minijinja::{context, Environment}; 13 - use serde::Deserialize; 14 - use std::collections::HashSet; 15 - use std::io::Read; 16 - use tracing::{error, warn}; 17 - use zip::ZipArchive; 18 - use chrono::Utc; 19 - 20 - #[derive(Deserialize)] 21 - struct LexiconFile { 22 - id: String, 23 - defs: serde_json::Map<String, serde_json::Value>, 24 - } 25 - 26 - pub async fn upload_lexicons( 27 - State(state): State<AppState>, 28 - request: Request, 29 - ) -> Result<impl IntoResponse, StatusCode> { 30 - 31 - let boundary = request 32 - .headers() 33 - .get("content-type") 34 - .and_then(|ct| ct.to_str().ok()) 35 - .and_then(|ct| multer::parse_boundary(ct).ok()) 36 - .ok_or_else(|| { 37 - let err = LexiconError::MultipartBoundary("Missing or invalid content-type header".to_string()); 38 - error!("{}", err); 39 - StatusCode::BAD_REQUEST 40 - })?; 41 - 42 - let body = request.into_body(); 43 - let body_bytes = to_bytes(body, usize::MAX).await.map_err(|e| { 44 - let err = LexiconError::RequestBody(e.to_string()); 45 - error!("{}", err); 46 - StatusCode::BAD_REQUEST 47 - })?; 48 - 49 - 50 - let body_stream = once(async move { Ok::<_, multer::Error>(body_bytes) }); 51 - let mut multipart = Multipart::new(body_stream, boundary); 52 - let mut collections = HashSet::new(); 53 - let mut file_count = 0; 54 - let mut record_count = 0; 55 - 56 - while let Some(field) = multipart.next_field().await.map_err(|_| StatusCode::BAD_REQUEST)? { 57 - if field.name() == Some("lexicon_file") { 58 - let data = field.bytes().await.map_err(|_| StatusCode::BAD_REQUEST)?; 59 - 60 - // Parse zip file 61 - let cursor = std::io::Cursor::new(data); 62 - let mut archive = ZipArchive::new(cursor).map_err(|e| { 63 - let err = LexiconError::ZipArchive(e.to_string()); 64 - error!("{}", err); 65 - StatusCode::BAD_REQUEST 66 - })?; 67 - 68 - 69 - for i in 0..archive.len() { 70 - let mut file = archive.by_index(i).map_err(|e| { 71 - let err = LexiconError::FileRead(format!("Failed to access file at index {}: {}", i, e)); 72 - error!("{}", err); 73 - StatusCode::INTERNAL_SERVER_ERROR 74 - })?; 75 - 76 - // Only process JSON files, skip macOS metadata files 77 - if file.name().ends_with(".json") && 78 - !file.name().contains("__MACOSX") && 79 - !file.name().starts_with("._") { 80 - 81 - let mut contents = String::new(); 82 - if let Err(e) = file.read_to_string(&mut contents) { 83 - let err = LexiconError::FileRead(format!("Failed to read {}: {}", file.name(), e)); 84 - warn!("{}", err); 85 - continue; // Skip this file and continue processing others 86 - } 87 - 88 - // Try to parse as lexicon 89 - match serde_json::from_str::<LexiconFile>(&contents) { 90 - Ok(lexicon_file) => { 91 - file_count += 1; 92 - 93 - // Look for record definitions first 94 - let mut has_record_def = false; 95 - for (_def_name, def_value) in &lexicon_file.defs { 96 - if let Some(def_obj) = def_value.as_object() { 97 - if let Some(type_val) = def_obj.get("type") { 98 - if type_val == "record" { 99 - // This is a record definition - for AT Protocol listRecords, we only use the NSID 100 - // Fragments (#definition) are for Lexicon references, not collection names 101 - collections.insert(lexicon_file.id.clone()); 102 - record_count += 1; 103 - has_record_def = true; 104 - } 105 - } 106 - } 107 - } 108 - 109 - // Only store lexicon in database if it has record definitions (will be synced) 110 - if has_record_def { 111 - let now = Utc::now(); 112 - let lexicon = Lexicon { 113 - nsid: lexicon_file.id.clone(), 114 - definitions: serde_json::Value::Object(lexicon_file.defs.clone()), 115 - created_at: now, 116 - updated_at: now, 117 - }; 118 - 119 - if let Err(e) = state.database.insert_lexicon(&lexicon).await { 120 - warn!("Failed to store lexicon {}: {}", lexicon_file.id, e); 121 - } 122 - } 123 - } 124 - Err(e) => { 125 - let err = LexiconError::JsonParse(format!("Failed to parse {}: {}", file.name(), e)); 126 - warn!("{}", err); 127 - } 128 - } 129 - } 130 - } 131 - } 132 - } 133 - 134 - let collections_list: Vec<String> = collections.into_iter().collect(); 135 - let collections_str = collections_list.join(", "); 136 - 137 - // Group collections by domain - create a vector of objects for template 138 - let mut domain_groups: std::collections::BTreeMap<String, Vec<String>> = std::collections::BTreeMap::new(); 139 - for collection in &collections_list { 140 - let domain = if let Some(dot_index) = collection.find('.') { 141 - collection[..dot_index].to_string() 142 - } else { 143 - "other".to_string() 144 - }; 145 - domain_groups.entry(domain).or_insert_with(Vec::new).push(collection.clone()); 146 - } 147 - 148 - // Convert to a format that minijinja can handle 149 - let grouped_collections: Vec<serde_json::Value> = domain_groups 150 - .into_iter() 151 - .map(|(domain, collections)| { 152 - serde_json::json!({ 153 - "domain": domain, 154 - "collections": collections 155 - }) 156 - }) 157 - .collect(); 158 - 159 - 160 - let mut env = Environment::new(); 161 - env.add_template("upload_result.html", r#" 162 - <div class="alert alert-success"> 163 - <h4>✅ Lexicon parsing completed!</h4> 164 - <p><strong>Files processed:</strong> {{ file_count }}</p> 165 - <p><strong>Record definitions found:</strong> {{ record_count }}</p> 166 - 167 - <div class="mt-4"> 168 - <h5 class="font-medium text-gray-800 mb-2">Select Collections to Sync:</h5> 169 - <div class="space-y-3"> 170 - {% for group in grouped_collections %} 171 - <div class="border border-gray-200 rounded-lg p-3"> 172 - <div class="flex items-center mb-2"> 173 - <input type="checkbox" 174 - id="domain-{{ group.domain }}" 175 - class="domain-checkbox mr-2" 176 - _="on change toggle .checked on .collection-{{ group.domain }} then call updateCollections()"> 177 - <label for="domain-{{ group.domain }}" class="font-medium text-gray-700">{{ group.domain }}.*</label> 178 - </div> 179 - <div class="ml-6 space-y-1"> 180 - {% for collection in group.collections %} 181 - <div class="flex items-center"> 182 - <input type="checkbox" 183 - id="collection-{{ collection }}" 184 - class="collection-checkbox collection-{{ group.domain }} mr-2" 185 - value="{{ collection }}" 186 - _="on change call updateCollections()"> 187 - <label for="collection-{{ collection }}" class="text-sm text-gray-600 font-mono">{{ collection }}</label> 188 - </div> 189 - {% endfor %} 190 - </div> 191 - </div> 192 - {% endfor %} 193 - </div> 194 - 195 - <div class="mt-4"> 196 - <button class="bg-blue-500 text-white px-3 py-1 rounded text-sm mr-2" 197 - _="on click add .checked to .collection-checkbox then call updateCollections()"> 198 - Select All 199 - </button> 200 - <button class="bg-gray-500 text-white px-3 py-1 rounded text-sm" 201 - _="on click remove .checked from .collection-checkbox then call updateCollections()"> 202 - Select None 203 - </button> 204 - </div> 205 - </div> 206 - 207 - <script> 208 - function updateCollections() { 209 - const selectedCollections = Array.from(document.querySelectorAll('.collection-checkbox:checked')) 210 - .map(cb => cb.value); 211 - 212 - document.getElementById('collections').value = selectedCollections.join(', '); 213 - 214 - // Update domain checkboxes 215 - document.querySelectorAll('.domain-checkbox').forEach(domainCb => { 216 - const domain = domainCb.id.replace('domain-', ''); 217 - const domainCollections = document.querySelectorAll('.collection-' + domain); 218 - const checkedDomainCollections = document.querySelectorAll('.collection-' + domain + ':checked'); 219 - 220 - if (checkedDomainCollections.length === 0) { 221 - domainCb.checked = false; 222 - domainCb.indeterminate = false; 223 - } else if (checkedDomainCollections.length === domainCollections.length) { 224 - domainCb.checked = true; 225 - domainCb.indeterminate = false; 226 - } else { 227 - domainCb.checked = false; 228 - domainCb.indeterminate = true; 229 - } 230 - }); 231 - } 232 - 233 - // Auto-populate with all collections initially and check all boxes 234 - document.getElementById('collections').value = '{{ collections_str }}'; 235 - document.querySelectorAll('.collection-checkbox').forEach(cb => cb.checked = true); 236 - document.querySelectorAll('.domain-checkbox').forEach(cb => cb.checked = true); 237 - </script> 238 - </div> 239 - "#).unwrap(); 240 - 241 - let tmpl = env.get_template("upload_result.html").unwrap(); 242 - let rendered = tmpl.render(context! { 243 - file_count => file_count, 244 - record_count => record_count, 245 - collections_str => collections_str, 246 - grouped_collections => grouped_collections 247 - }).unwrap(); 248 - 249 - Ok(Html(rendered)) 250 - }
+25 -46
api/src/handler_xrpc_codegen.rs
··· 9 9 10 10 #[derive(Deserialize)] 11 11 pub struct CodegenXrpcRequest { 12 + #[allow(dead_code)] 12 13 target: String, 13 - client_type: String, 14 - lexicons: Vec<String>, 14 + slice: String, // at-uri of the slice to filter lexicons by 15 15 } 16 16 17 17 #[derive(Serialize)] ··· 25 25 State(state): State<AppState>, 26 26 Json(request): Json<CodegenXrpcRequest>, 27 27 ) -> Result<ResponseJson<CodegenXrpcResponse>, StatusCode> { 28 - if request.lexicons.is_empty() { 29 - return Ok(ResponseJson(CodegenXrpcResponse { 28 + // Query database for lexicon definitions by slice 29 + let lexicon_definitions = match state.database.get_lexicons_by_slice(&request.slice).await { 30 + Ok(definitions) => definitions, 31 + Err(_) => return Ok(ResponseJson(CodegenXrpcResponse { 30 32 success: false, 31 33 generated_code: None, 32 - error: Some("No lexicons specified".to_string()), 33 - })); 34 - } 35 - 36 - // Fetch the selected lexicons from database 37 - let mut lexicons = Vec::new(); 38 - for nsid in &request.lexicons { 39 - if let Ok(Some(lexicon)) = state.database.get_lexicon(nsid).await { 40 - lexicons.push(lexicon); 41 - } 42 - } 34 + error: Some("Failed to query lexicon records".to_string()), 35 + })) 36 + }; 43 37 44 - if lexicons.is_empty() { 38 + if lexicon_definitions.is_empty() { 45 39 return Ok(ResponseJson(CodegenXrpcResponse { 46 40 success: false, 47 41 generated_code: None, 48 - error: Some("No valid lexicons found".to_string()), 42 + error: Some(format!("No lexicons found for slice: {}", request.slice)), 49 43 })); 50 44 } 51 45 52 - let generated_code = match request.target.as_str() { 53 - "typescript-deno" => match request.client_type.as_str() { 54 - "records" => { 55 - let generator = TypeScriptGenerator::new(); 56 - match generator.generate_client(&lexicons) { 57 - Ok(code) => code, 58 - Err(e) => return Ok(ResponseJson(CodegenXrpcResponse { 59 - success: false, 60 - generated_code: None, 61 - error: Some(format!("TypeScript generation failed: {}", e)), 62 - })), 63 - } 64 - }, 65 - _ => return Ok(ResponseJson(CodegenXrpcResponse { 66 - success: false, 67 - generated_code: None, 68 - error: Some("Unsupported client type".to_string()), 69 - })), 70 - }, 71 - _ => return Ok(ResponseJson(CodegenXrpcResponse { 46 + // Pass lexicon definitions to TypeScript generator 47 + let generator = TypeScriptGenerator::new(); 48 + let lexicons_json = serde_json::to_string(&lexicon_definitions).unwrap_or_default(); 49 + 50 + match generator.generate_client(&lexicons_json) { 51 + Ok(code) => Ok(ResponseJson(CodegenXrpcResponse { 52 + success: true, 53 + generated_code: Some(code), 54 + error: None, 55 + })), 56 + Err(e) => Ok(ResponseJson(CodegenXrpcResponse { 72 57 success: false, 73 58 generated_code: None, 74 - error: Some("Unsupported target".to_string()), 75 - })), 76 - }; 77 - 78 - Ok(ResponseJson(CodegenXrpcResponse { 79 - success: true, 80 - generated_code: Some(generated_code), 81 - error: None, 82 - })) 59 + error: Some(e), 60 + })) 61 + } 83 62 }
+7 -107
api/src/main.rs
··· 1 1 mod codegen; 2 2 mod database; 3 3 mod errors; 4 - mod handler_codegen; 5 - mod handler_dynamic_xrpc; 6 - mod handler_lexicon; 7 - mod handler_upload_lexicon; 4 + mod handler_sync; 8 5 mod handler_xrpc_codegen; 6 + mod handler_xrpc_dynamic; 9 7 mod models; 10 8 mod sync; 11 9 mod utils; 12 - mod web; 13 10 14 11 use axum::{ 15 12 Router, 16 - extract::{Query, State}, 17 - http::StatusCode, 18 - response::Json, 19 13 routing::{get, post}, 20 14 }; 21 15 use sqlx::PgPool; ··· 26 20 27 21 use crate::database::Database; 28 22 use crate::errors::AppError; 29 - use crate::models::{ 30 - BulkSyncOutput, BulkSyncParams, ListRecordsOutput, ListRecordsParams, SmartSyncParams, 31 - }; 32 23 use crate::sync::SyncService; 33 - use crate::web::WebService; 34 24 35 25 #[derive(Clone)] 36 26 pub struct Config { ··· 41 31 pub struct AppState { 42 32 database: Database, 43 33 sync_service: SyncService, 44 - #[allow(dead_code)] 45 - web_service: WebService, 46 34 config: Config, 47 35 } 48 36 ··· 67 55 68 56 let database = Database::new(pool); 69 57 let sync_service = SyncService::new(database.clone()); 70 - let web_service = WebService::new(); 71 58 72 59 let auth_base_url = env::var("AUTH_BASE_URL") 73 60 .unwrap_or_else(|_| "https://auth.grainsocial.network".to_string()); 74 61 75 - let config = Config { 76 - auth_base_url, 77 - }; 62 + let config = Config { auth_base_url }; 78 63 79 64 let state = AppState { 80 65 database: database.clone(), 81 66 sync_service, 82 - web_service, 83 67 config, 84 68 }; 85 69 86 70 // Build application with routes 87 71 let app = Router::new() 88 72 // XRPC endpoints 89 - .route("/xrpc/social.slices.records.list", get(list_records)) 90 - .route("/xrpc/social.slices.collections.bulkSync", post(bulk_sync)) 91 - .route("/xrpc/social.slices.repos.smartSync", post(smart_sync)) 73 + .route("/xrpc/social.slices.slice.sync", post(handler_sync::sync)) 92 74 .route( 93 - "/xrpc/social.slices.codegen.generate", 75 + "/xrpc/social.slices.slice.codegen", 94 76 post(handler_xrpc_codegen::generate_client_xrpc), 95 77 ) 96 78 // Dynamic collection-specific XRPC endpoints 97 79 .route( 98 80 "/xrpc/*method", 99 - get(handler_dynamic_xrpc::dynamic_xrpc_handler), 81 + get(handler_xrpc_dynamic::dynamic_xrpc_handler), 100 82 ) 101 83 .route( 102 84 "/xrpc/*method", 103 - post(handler_dynamic_xrpc::dynamic_xrpc_post_handler), 104 - ) 105 - // Web interface 106 - .route("/", get(web::index)) 107 - .route("/records", get(web::records_page)) 108 - .route("/sync", get(web::sync_page)) 109 - .route("/sync", post(web::bulk_sync_action)) 110 - .route("/codegen", get(web::codegen_page)) 111 - .route("/codegen/generate", post(handler_codegen::generate_client)) 112 - .route("/lexicon", get(handler_lexicon::lexicon_page)) 113 - .route( 114 - "/upload-lexicons", 115 - post(handler_upload_lexicon::upload_lexicons), 85 + post(handler_xrpc_dynamic::dynamic_xrpc_post_handler), 116 86 ) 117 87 .layer(TraceLayer::new_for_http()) 118 88 .layer(CorsLayer::permissive()) ··· 125 95 Ok(()) 126 96 } 127 97 128 - async fn list_records( 129 - State(state): State<AppState>, 130 - Query(params): Query<ListRecordsParams>, 131 - ) -> Result<Json<ListRecordsOutput>, StatusCode> { 132 - match state.database.list_records(params).await { 133 - Ok(records) => Ok(Json(ListRecordsOutput { 134 - records, 135 - cursor: None, // TODO: implement cursor pagination 136 - })), 137 - Err(_) => Err(StatusCode::INTERNAL_SERVER_ERROR), 138 - } 139 - } 140 98 141 - async fn bulk_sync( 142 - State(state): State<AppState>, 143 - axum::extract::Json(params): axum::extract::Json<BulkSyncParams>, 144 - ) -> Result<Json<BulkSyncOutput>, StatusCode> { 145 - match state 146 - .sync_service 147 - .backfill_collections(&params.collections, params.repos.as_deref()) 148 - .await 149 - { 150 - Ok(_) => { 151 - let total_records = state.database.get_total_record_count().await.unwrap_or(0); 152 - Ok(Json(BulkSyncOutput { 153 - success: true, 154 - total_records, 155 - collections_synced: params.collections, 156 - repos_processed: params.repos.map(|r| r.len() as i64).unwrap_or(0), 157 - message: "Bulk sync completed successfully".to_string(), 158 - })) 159 - } 160 - Err(e) => Ok(Json(BulkSyncOutput { 161 - success: false, 162 - total_records: 0, 163 - collections_synced: vec![], 164 - repos_processed: 0, 165 - message: format!("Bulk sync failed: {}", e), 166 - })), 167 - } 168 - } 169 - 170 - async fn smart_sync( 171 - State(state): State<AppState>, 172 - axum::extract::Json(params): axum::extract::Json<SmartSyncParams>, 173 - ) -> Result<Json<BulkSyncOutput>, StatusCode> { 174 - let collections = params.collections.as_deref(); 175 - 176 - match state.sync_service.sync_repo(&params.did, collections).await { 177 - Ok(records_count) => { 178 - let total_records = state.database.get_total_record_count().await.unwrap_or(0); 179 - Ok(Json(BulkSyncOutput { 180 - success: true, 181 - total_records, 182 - collections_synced: params.collections.unwrap_or_default(), 183 - repos_processed: 1, 184 - message: format!( 185 - "Smart sync completed for {}: {} records", 186 - params.did, records_count 187 - ), 188 - })) 189 - } 190 - Err(e) => Ok(Json(BulkSyncOutput { 191 - success: false, 192 - total_records: 0, 193 - collections_synced: vec![], 194 - repos_processed: 0, 195 - message: format!("Smart sync failed for {}: {}", params.did, e), 196 - })), 197 - } 198 - }
+5 -44
api/src/models.rs
··· 15 15 } 16 16 17 17 #[derive(Debug, Serialize, Deserialize)] 18 - pub struct CreateRecordInput { 19 - pub collection: String, 20 - pub repo: String, 21 - pub rkey: Option<String>, 22 - pub record: Value, 23 - } 24 - 25 - #[derive(Debug, Serialize, Deserialize)] 26 - pub struct CreateRecordOutput { 18 + pub struct IndexedRecord { 27 19 pub uri: String, 28 20 pub cid: String, 29 - } 30 - 31 - #[derive(Debug, Serialize, Deserialize)] 32 - pub struct ListRecordsParams { 21 + pub did: String, 33 22 pub collection: String, 34 - pub author: Option<String>, 35 - pub limit: Option<i32>, 36 - pub cursor: Option<String>, 23 + pub value: Value, 24 + #[serde(rename = "indexedAt")] 25 + pub indexed_at: String, 37 26 } 38 27 39 28 #[derive(Debug, Serialize, Deserialize)] ··· 43 32 } 44 33 45 34 #[derive(Debug, Serialize, Deserialize)] 46 - pub struct IndexedRecord { 47 - pub uri: String, 48 - pub cid: String, 49 - pub did: String, 50 - pub collection: String, 51 - pub value: Value, 52 - #[serde(rename = "indexedAt")] 53 - pub indexed_at: String, 54 - } 55 - 56 - 57 - #[derive(Debug, Serialize, Deserialize)] 58 35 pub struct BulkSyncParams { 59 36 pub collections: Vec<String>, 60 37 pub repos: Option<Vec<String>>, ··· 68 45 pub collections_synced: Vec<String>, 69 46 pub repos_processed: i64, 70 47 pub message: String, 71 - } 72 - 73 - 74 - #[derive(Debug, Serialize, Deserialize)] 75 - pub struct SmartSyncParams { 76 - pub did: String, 77 - pub collections: Option<Vec<String>>, 78 - pub force_full_sync: Option<bool>, 79 - } 80 - 81 - #[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)] 82 - pub struct Lexicon { 83 - pub nsid: String, 84 - pub definitions: serde_json::Value, 85 - pub created_at: DateTime<Utc>, 86 - pub updated_at: DateTime<Utc>, 87 48 } 88 49 89 50 #[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
-39
api/src/sync.rs
··· 68 68 } 69 69 } 70 70 71 - // Sync using listRecords 72 - pub async fn sync_repo(&self, did: &str, collections: Option<&[String]>) -> Result<i64, SyncError> { 73 - info!("🔄 Starting sync for DID: {}", did); 74 - 75 - let total_records = self.listrecords_sync(did, collections).await?; 76 - 77 - info!("✅ Sync completed for {}: {} records", did, total_records); 78 - Ok(total_records) 79 - } 80 - 81 - 82 - // Sync using listRecords 83 - async fn listrecords_sync(&self, did: &str, collections: Option<&[String]>) -> Result<i64, SyncError> { 84 - let collections_to_sync = match collections { 85 - Some(cols) => cols, 86 - None => return Ok(0), // No collections specified = no records 87 - }; 88 - 89 - // Get ATP data for this single repo 90 - let atp_map = self.get_atp_map_for_repos(&[did.to_string()]).await?; 91 - 92 - let mut total_records = 0; 93 - for collection in collections_to_sync { 94 - match self.fetch_records_for_repo_collection_with_atp_map(did, collection, &atp_map).await { 95 - Ok(records) => { 96 - if !records.is_empty() { 97 - info!("📋 Fallback sync: {} records for {}/{}", records.len(), did, collection); 98 - self.database.batch_insert_records(&records).await?; 99 - total_records += records.len() as i64; 100 - } 101 - } 102 - Err(e) => { 103 - error!("Failed fallback sync for {}/{}: {}", did, collection, e); 104 - } 105 - } 106 - } 107 - 108 - Ok(total_records) 109 - } 110 71 111 72 112 73 pub async fn backfill_collections(&self, collections: &[String], repos: Option<&[String]>) -> Result<(), SyncError> {
-204
api/src/web.rs
··· 1 - use axum::{ 2 - extract::{Query, State}, 3 - http::StatusCode, 4 - response::{Html, IntoResponse}, 5 - Form, 6 - }; 7 - use minijinja::{context, Environment}; 8 - use serde::Deserialize; 9 - use std::collections::HashMap; 10 - 11 - use crate::models::ListRecordsParams; 12 - use crate::AppState; 13 - 14 - #[derive(Clone)] 15 - pub struct WebService { 16 - #[allow(dead_code)] 17 - env: Environment<'static>, 18 - } 19 - 20 - impl WebService { 21 - pub fn new() -> Self { 22 - let mut env = Environment::new(); 23 - 24 - // Add base template 25 - env.add_template("base.html", include_str!("../templates/base.html")).unwrap(); 26 - env.add_template("index.html", include_str!("../templates/index.html")).unwrap(); 27 - env.add_template("records.html", include_str!("../templates/records.html")).unwrap(); 28 - env.add_template("sync.html", include_str!("../templates/sync.html")).unwrap(); 29 - 30 - Self { env } 31 - } 32 - } 33 - 34 - #[derive(Deserialize)] 35 - pub struct BulkSyncForm { 36 - collections: String, 37 - repos: Option<String>, 38 - } 39 - 40 - 41 - pub async fn index(State(state): State<AppState>) -> Result<impl IntoResponse, StatusCode> { 42 - let collections = state.database.get_available_collections().await.unwrap_or_default(); 43 - let total_records = state.database.get_total_record_count().await.unwrap_or(0); 44 - 45 - let mut env = Environment::new(); 46 - env.add_template("base.html", include_str!("../templates/base.html")).unwrap(); 47 - env.add_template("index.html", include_str!("../templates/index.html")).unwrap(); 48 - 49 - let tmpl = env.get_template("index.html").unwrap(); 50 - let rendered = tmpl.render(context! { 51 - title => "AT Protocol Indexer", 52 - collections => collections, 53 - total_records => total_records 54 - }).unwrap(); 55 - 56 - Ok(Html(rendered)) 57 - } 58 - 59 - pub async fn records_page( 60 - State(state): State<AppState>, 61 - Query(params): Query<HashMap<String, String>>, 62 - ) -> Result<impl IntoResponse, StatusCode> { 63 - let collection = params.get("collection").cloned().unwrap_or_default(); 64 - let author = params.get("author").cloned(); 65 - 66 - // Get available collections for the dropdown 67 - let available_collections = state.database.get_available_collections().await.unwrap_or_default(); 68 - 69 - let records = if !collection.is_empty() { 70 - let list_params = ListRecordsParams { 71 - collection: collection.clone(), 72 - author, 73 - limit: Some(50), 74 - cursor: None, 75 - }; 76 - let raw_records = state.database.list_records(list_params).await.unwrap_or_default(); 77 - 78 - // Transform records to include pretty-printed JSON 79 - raw_records.into_iter().map(|record| { 80 - let pretty_json = serde_json::to_string_pretty(&record.value).unwrap_or_else(|_| record.value.to_string()); 81 - serde_json::json!({ 82 - "uri": record.uri, 83 - "cid": record.cid, 84 - "did": record.did, 85 - "collection": record.collection, 86 - "value": record.value, 87 - "pretty_value": pretty_json, 88 - "indexed_at": record.indexed_at 89 - }) 90 - }).collect() 91 - } else { 92 - Vec::new() 93 - }; 94 - 95 - let mut env = Environment::new(); 96 - env.add_template("base.html", include_str!("../templates/base.html")).unwrap(); 97 - env.add_template("records.html", include_str!("../templates/records.html")).unwrap(); 98 - 99 - let tmpl = env.get_template("records.html").unwrap(); 100 - let rendered = tmpl.render(context! { 101 - title => "Records", 102 - records => records, 103 - collection => collection, 104 - available_collections => available_collections 105 - }).unwrap(); 106 - 107 - Ok(Html(rendered)) 108 - } 109 - 110 - pub async fn codegen_page(State(state): State<AppState>) -> Result<impl IntoResponse, StatusCode> { 111 - // Get stored lexicons for the UI 112 - let lexicons = match state.database.get_all_lexicons().await { 113 - Ok(lexicons) => lexicons, 114 - Err(_) => Vec::new(), 115 - }; 116 - 117 - let mut env = Environment::new(); 118 - env.add_template("base.html", include_str!("../templates/base.html")).unwrap(); 119 - env.add_template("codegen.html", include_str!("../templates/codegen.html")).unwrap(); 120 - 121 - let tmpl = env.get_template("codegen.html").unwrap(); 122 - let rendered = tmpl.render(context! { 123 - title => "Client Code Generation", 124 - lexicons => lexicons 125 - }).unwrap(); 126 - 127 - Ok(Html(rendered)) 128 - } 129 - 130 - pub async fn sync_page() -> impl IntoResponse { 131 - let mut env = Environment::new(); 132 - env.add_template("base.html", include_str!("../templates/base.html")).unwrap(); 133 - env.add_template("sync.html", include_str!("../templates/sync.html")).unwrap(); 134 - 135 - let tmpl = env.get_template("sync.html").unwrap(); 136 - let rendered = tmpl.render(context! { 137 - title => "Sync Records" 138 - }).unwrap(); 139 - 140 - Html(rendered) 141 - } 142 - 143 - pub async fn bulk_sync_action( 144 - State(state): State<AppState>, 145 - Form(form): Form<BulkSyncForm>, 146 - ) -> Result<impl IntoResponse, StatusCode> { 147 - // Parse collections from comma-separated string 148 - let collections: Vec<String> = form.collections 149 - .split(',') 150 - .map(|s| s.trim().to_string()) 151 - .filter(|s| !s.is_empty()) 152 - .collect(); 153 - 154 - // Parse repos from newline-separated string if provided 155 - let repos = form.repos 156 - .filter(|s| !s.trim().is_empty()) 157 - .map(|s| s.lines() 158 - .map(|line| line.trim().to_string()) 159 - .filter(|line| !line.is_empty()) 160 - .collect::<Vec<String>>()); 161 - 162 - if collections.is_empty() { 163 - return Ok(Html(r#" 164 - <div class="alert alert-error"> 165 - <h4>❌ No collections specified</h4> 166 - <p>Please specify at least one collection to sync.</p> 167 - </div> 168 - "#.to_string())); 169 - } 170 - 171 - match state.sync_service.backfill_collections(&collections, repos.as_deref()).await { 172 - Ok(_) => { 173 - let total_records = state.database.get_total_record_count().await.unwrap_or(0); 174 - 175 - let mut env = Environment::new(); 176 - env.add_template("sync_result.html", r#" 177 - <div class="alert alert-success"> 178 - <h4>✅ Bulk sync completed successfully!</h4> 179 - <p><strong>Collections synced:</strong> {{ collections|join(", ") }}</p> 180 - <p><strong>Total records in database:</strong> {{ total_records }}</p> 181 - <p><strong>Operation:</strong> {{ message }}</p> 182 - </div> 183 - "#).unwrap(); 184 - 185 - let tmpl = env.get_template("sync_result.html").unwrap(); 186 - let rendered = tmpl.render(context! { 187 - collections => collections, 188 - total_records => total_records, 189 - message => "Bulk sync operation completed" 190 - }).unwrap(); 191 - 192 - Ok(Html(rendered)) 193 - }, 194 - Err(e) => { 195 - Ok(Html(format!(r#" 196 - <div class="alert alert-error"> 197 - <h4>❌ Bulk sync failed</h4> 198 - <p>Error: {}</p> 199 - </div> 200 - "#, e))) 201 - } 202 - } 203 - } 204 -
-70
api/templates/base.html
··· 1 - <!DOCTYPE html> 2 - <html lang="en"> 3 - 4 - <head> 5 - <meta charset="UTF-8"> 6 - <meta name="viewport" content="width=device-width, initial-scale=1.0"> 7 - <title>{{ title }} - AT Protocol Indexer</title> 8 - <script src="https://unpkg.com/htmx.org@1.9.10"></script> 9 - <script src="https://unpkg.com/hyperscript.org@0.9.12"></script> 10 - <script src="https://cdn.tailwindcss.com/3.4.1"></script> 11 - <style> 12 - .alert { 13 - padding: 1rem; 14 - margin-bottom: 1rem; 15 - border-radius: 0.375rem; 16 - border-width: 1px; 17 - } 18 - 19 - .alert-success { 20 - background-color: #dcfce7; 21 - border-color: #22c55e; 22 - color: #15803d; 23 - } 24 - 25 - .alert-warning { 26 - background-color: #fef3c7; 27 - border-color: #eab308; 28 - color: #a16207; 29 - } 30 - 31 - .alert-error { 32 - background-color: #fecaca; 33 - border-color: #ef4444; 34 - color: #dc2626; 35 - } 36 - 37 - .htmx-indicator { 38 - display: none; 39 - } 40 - 41 - .htmx-request .htmx-indicator { 42 - display: inline; 43 - } 44 - 45 - .htmx-request .default-text { 46 - display: none; 47 - } 48 - </style> 49 - </head> 50 - 51 - <body class="bg-gray-100 min-h-screen"> 52 - <nav class="bg-blue-600 text-white p-4"> 53 - <div class="container mx-auto flex justify-between items-center"> 54 - <h1 class="text-xl font-bold">AT Protocol Indexer</h1> 55 - <div class="space-x-4"> 56 - <a href="/" class="hover:underline">Home</a> 57 - <a href="/records" class="hover:underline">Records</a> 58 - <a href="/lexicon" class="hover:underline">Lexicon</a> 59 - <a href="/sync" class="hover:underline">Sync</a> 60 - <a href="/codegen" class="hover:underline">Codegen</a> 61 - </div> 62 - </div> 63 - </nav> 64 - 65 - <main class="container mx-auto mt-8 px-4"> 66 - {% block content %}{% endblock %} 67 - </main> 68 - </body> 69 - 70 - </html>
-82
api/templates/codegen.html
··· 1 - {% extends "base.html" %} 2 - 3 - {% block content %} 4 - <div class="max-w-6xl mx-auto"> 5 - <h1 class="text-3xl font-bold text-gray-800 mb-8">Client Code Generation</h1> 6 - 7 - <div class="bg-white rounded-lg shadow-md p-6 mb-6"> 8 - <h2 class="text-xl font-semibold mb-4">Generate Typed Clients</h2> 9 - <p class="text-gray-600 mb-6">Generate TypeScript clients for interacting with XRPC APIs based on stored lexicon definitions.</p> 10 - 11 - <form hx-post="/codegen/generate" 12 - hx-target="#codegen-result" 13 - hx-indicator="#generate-button" 14 - class="space-y-6"> 15 - <div> 16 - <label for="target" class="block text-sm font-medium text-gray-700 mb-2">Target Language</label> 17 - <select name="target" id="target" class="w-full border border-gray-300 rounded-md px-3 py-2"> 18 - <option value="typescript-deno">TypeScript (Deno)</option> 19 - </select> 20 - </div> 21 - 22 - <div> 23 - <label for="client_type" class="block text-sm font-medium text-gray-700 mb-2">Client Type</label> 24 - <select name="client_type" id="client_type" class="w-full border border-gray-300 rounded-md px-3 py-2"> 25 - <option value="records">Records Client</option> 26 - </select> 27 - </div> 28 - 29 - <div> 30 - <label class="block text-sm font-medium text-gray-700 mb-2">Include Collections</label> 31 - <div class="space-y-2 max-h-64 overflow-y-auto border border-gray-300 rounded-md p-3"> 32 - {% if lexicons %} 33 - {% for lexicon in lexicons %} 34 - <div class="flex items-center"> 35 - <input type="checkbox" 36 - id="lexicon-{{ lexicon.nsid }}" 37 - name="lexicons" 38 - value="{{ lexicon.nsid }}" 39 - class="mr-2"> 40 - <label for="lexicon-{{ lexicon.nsid }}" class="text-sm text-gray-600 font-mono">{{ lexicon.nsid }}</label> 41 - </div> 42 - {% endfor %} 43 - {% else %} 44 - <p class="text-gray-500 text-sm">No lexicons available. Upload some lexicon files first.</p> 45 - {% endif %} 46 - </div> 47 - </div> 48 - 49 - <button type="submit" 50 - id="generate-button" 51 - class="w-full bg-green-500 hover:bg-green-600 text-white px-6 py-3 rounded-md font-medium"> 52 - <span class="default-text">Generate Client</span> 53 - <span class="htmx-indicator">🔄 Generating...</span> 54 - </button> 55 - </form> 56 - 57 - <div id="codegen-result" class="mt-6"></div> 58 - </div> 59 - 60 - <div class="bg-blue-50 border border-blue-200 rounded-lg p-6"> 61 - <h3 class="text-lg font-semibold text-blue-800 mb-2">💡 About Client Generation</h3> 62 - <ul class="space-y-2 text-blue-700"> 63 - <li class="flex items-start"> 64 - <span class="font-bold mr-2">•</span> 65 - <span>Generates typed TypeScript clients for interacting with XRPC APIs</span> 66 - </li> 67 - <li class="flex items-start"> 68 - <span class="font-bold mr-2">•</span> 69 - <span>Based on stored lexicon definitions with full type safety</span> 70 - </li> 71 - <li class="flex items-start"> 72 - <span class="font-bold mr-2">•</span> 73 - <span>Includes interfaces for records, queries, and procedures</span> 74 - </li> 75 - <li class="flex items-start"> 76 - <span class="font-bold mr-2">•</span> 77 - <span>Compatible with Deno and modern TypeScript environments</span> 78 - </li> 79 - </ul> 80 - </div> 81 - </div> 82 - {% endblock %}
-94
api/templates/index.html
··· 1 - {% extends "base.html" %} 2 - 3 - {% block content %} 4 - <div class="max-w-4xl mx-auto"> 5 - <h1 class="text-3xl font-bold text-gray-800 mb-8">AT Protocol Indexer</h1> 6 - 7 - {% if total_records > 0 %} 8 - <div class="bg-blue-50 border border-blue-200 rounded-lg p-6 mb-8"> 9 - <h2 class="text-xl font-semibold text-blue-800 mb-2">📊 Database Status</h2> 10 - <p class="text-blue-700">Currently indexing <strong>{{ total_records }}</strong> records across <strong>{{ 11 - collections|length }}</strong> collections.</p> 12 - </div> 13 - {% endif %} 14 - 15 - <div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6"> 16 - <div class="bg-white rounded-lg shadow-md p-6"> 17 - <h2 class="text-xl font-semibold text-gray-800 mb-4">📝 View Records</h2> 18 - <p class="text-gray-600 mb-4">Browse indexed AT Protocol records by collection.</p> 19 - {% if collections %} 20 - <a href="/records" class="bg-blue-500 hover:bg-blue-600 text-white px-4 py-2 rounded"> 21 - Browse Records 22 - </a> 23 - {% else %} 24 - <p class="text-gray-500 text-sm">No records synced yet. Start by syncing some records!</p> 25 - {% endif %} 26 - </div> 27 - 28 - <div class="bg-white rounded-lg shadow-md p-6"> 29 - <h2 class="text-xl font-semibold text-gray-800 mb-4">📚 Lexicon Definitions</h2> 30 - <p class="text-gray-600 mb-4">View lexicon definitions and schemas.</p> 31 - <a href="/lexicon" class="bg-purple-500 hover:bg-purple-600 text-white px-4 py-2 rounded"> 32 - View Lexicons 33 - </a> 34 - </div> 35 - 36 - <div class="bg-white rounded-lg shadow-md p-6"> 37 - <h2 class="text-xl font-semibold text-gray-800 mb-4">⚡ Code Generation</h2> 38 - <p class="text-gray-600 mb-4">Generate TypeScript client from your lexicon definitions.</p> 39 - <a href="/codegen" class="bg-orange-500 hover:bg-orange-600 text-white px-4 py-2 rounded"> 40 - Generate Client 41 - </a> 42 - </div> 43 - 44 - <div class="bg-white rounded-lg shadow-md p-6"> 45 - <h2 class="text-xl font-semibold text-gray-800 mb-4">🔄 Bulk Sync</h2> 46 - <p class="text-gray-600 mb-4">Sync entire collections from AT Protocol networks.</p> 47 - <a href="/sync" class="bg-green-500 hover:bg-green-600 text-white px-4 py-2 rounded"> 48 - Start Bulk Sync 49 - </a> 50 - </div> 51 - 52 - {% if collections %} 53 - <div class="bg-white rounded-lg shadow-md p-6"> 54 - <h2 class="text-xl font-semibold text-gray-800 mb-4">📊 Synced Collections</h2> 55 - <p class="text-gray-600 mb-4">Collections currently indexed in the database.</p> 56 - <div class="space-y-2 max-h-40 overflow-y-auto"> 57 - {% for collection in collections %} 58 - <a href="/records?collection={{ collection[0] }}" 59 - class="flex justify-between items-center text-blue-600 hover:underline text-sm"> 60 - <span>{{ collection[0] }}</span> 61 - <span class="text-gray-500">{{ collection[1] }}</span> 62 - </a> 63 - {% endfor %} 64 - </div> 65 - </div> 66 - {% else %} 67 - <div class="bg-white rounded-lg shadow-md p-6"> 68 - <h2 class="text-xl font-semibold text-gray-800 mb-4">🌟 Get Started</h2> 69 - <p class="text-gray-600 mb-4">No records indexed yet. Start by syncing some AT Protocol collections!</p> 70 - <div class="space-y-2 text-sm"> 71 - <p class="text-gray-500">Try syncing collections like:</p> 72 - <code class="block bg-gray-100 p-2 rounded text-xs">app.bsky.feed.post</code> 73 - <code class="block bg-gray-100 p-2 rounded text-xs">app.bsky.actor.profile</code> 74 - </div> 75 - </div> 76 - {% endif %} 77 - </div> 78 - 79 - <div class="mt-12 bg-white rounded-lg shadow-md p-6"> 80 - <h2 class="text-2xl font-semibold text-gray-800 mb-4">API Endpoints</h2> 81 - <div class="space-y-4"> 82 - <div> 83 - <code 84 - class="bg-gray-100 px-2 py-1 rounded">GET /xrpc/social.slices.records.list?collection=app.bsky.feed.post</code> 85 - <p class="text-gray-600 mt-1">List records for a collection</p> 86 - </div> 87 - <div> 88 - <code class="bg-gray-100 px-2 py-1 rounded">POST /xrpc/social.slices.collections.bulkSync</code> 89 - <p class="text-gray-600 mt-1">Bulk sync collections (JSON: {"collections": ["app.bsky.feed.post"]})</p> 90 - </div> 91 - </div> 92 - </div> 93 - </div> 94 - {% endblock %}
-49
api/templates/lexicon.html
··· 1 - {% extends "base.html" %} 2 - 3 - {% block content %} 4 - <div class="max-w-6xl mx-auto"> 5 - <h1 class="text-3xl font-bold text-gray-800 mb-8">Lexicon Definitions</h1> 6 - 7 - {% if lexicons %} 8 - <div class="bg-white rounded-lg shadow-md overflow-hidden"> 9 - <div class="px-6 py-4 bg-gray-50 border-b"> 10 - <h3 class="text-lg font-semibold">Found {{ lexicons|length }} lexicon definitions</h3> 11 - </div> 12 - <div class="divide-y divide-gray-200"> 13 - {% for lexicon in lexicons %} 14 - <div class="p-6"> 15 - <div class="flex justify-between items-start mb-4"> 16 - <h4 class="text-lg font-medium text-blue-600">{{ lexicon.nsid }}</h4> 17 - <span class="text-xs text-gray-500">Updated: {{ lexicon.updated_at }}</span> 18 - </div> 19 - 20 - <div class="mt-4"> 21 - <details class="cursor-pointer"> 22 - <summary class="font-medium text-gray-700 hover:text-gray-900">View Definitions</summary> 23 - <div class="mt-4"> 24 - {% if lexicon.pretty_definitions %} 25 - <pre class="bg-gray-100 p-3 rounded text-xs overflow-x-auto">{{ lexicon.pretty_definitions }}</pre> 26 - {% else %} 27 - <p class="text-gray-500 italic">No definitions found</p> 28 - {% endif %} 29 - </div> 30 - </details> 31 - </div> 32 - </div> 33 - {% endfor %} 34 - </div> 35 - </div> 36 - {% else %} 37 - <div class="bg-white rounded-lg shadow-md p-8 text-center"> 38 - <div class="text-gray-400 text-6xl mb-4">📚</div> 39 - <h3 class="text-xl font-semibold text-gray-800 mb-2">No lexicon definitions found</h3> 40 - <p class="text-gray-600 mb-4"> 41 - Upload lexicon files to see their definitions here. 42 - </p> 43 - <a href="/sync" class="bg-blue-500 hover:bg-blue-600 text-white px-4 py-2 rounded"> 44 - Upload Lexicons 45 - </a> 46 - </div> 47 - {% endif %} 48 - </div> 49 - {% endblock %}
-98
api/templates/records.html
··· 1 - {% extends "base.html" %} 2 - 3 - {% block content %} 4 - <div class="max-w-6xl mx-auto"> 5 - <h1 class="text-3xl font-bold text-gray-800 mb-8">Records</h1> 6 - 7 - <div class="bg-white rounded-lg shadow-md p-6 mb-6"> 8 - <div class="flex justify-between items-center mb-4"> 9 - <h2 class="text-xl font-semibold">Filter Records</h2> 10 - </div> 11 - <form class="grid grid-cols-1 md:grid-cols-3 gap-4" method="get" _="on submit 12 - if #author.value is empty 13 - remove @name from #author 14 - end"> 15 - <div> 16 - <label class="block text-sm font-medium text-gray-700 mb-2">Collection</label> 17 - <select name="collection" class="w-full border border-gray-300 rounded-md px-3 py-2"> 18 - <option value="">Select collection...</option> 19 - {% for available_collection in available_collections %} 20 - <option value="{{ available_collection[0] }}" {% if collection==available_collection[0] %}selected{% 21 - endif %}> 22 - {{ available_collection[0] }} ({{ available_collection[1] }} records) 23 - </option> 24 - {% endfor %} 25 - </select> 26 - </div> 27 - <div> 28 - <label class="block text-sm font-medium text-gray-700 mb-2">Author DID</label> 29 - <input type="text" id="author" name="author" placeholder="did:plc:..." 30 - class="w-full border border-gray-300 rounded-md px-3 py-2"> 31 - </div> 32 - <div class="flex items-end"> 33 - <button type="submit" class="bg-blue-500 hover:bg-blue-600 text-white px-4 py-2 rounded-md"> 34 - Filter 35 - </button> 36 - </div> 37 - </form> 38 - </div> 39 - 40 - {% if records %} 41 - <div class="bg-white rounded-lg shadow-md overflow-hidden"> 42 - <div class="px-6 py-4 bg-gray-50 border-b"> 43 - <h3 class="text-lg font-semibold">Found {{ records|length }} records</h3> 44 - </div> 45 - <div class="divide-y divide-gray-200"> 46 - {% for record in records %} 47 - <div class="p-6"> 48 - <div class="flex justify-between items-start mb-2"> 49 - <h4 class="text-sm font-medium text-blue-600">{{ record.uri }}</h4> 50 - <span class="text-xs text-gray-500">{{ record.indexed_at }}</span> 51 - </div> 52 - <div class="space-y-2 text-sm"> 53 - <div> 54 - <span class="font-medium">Collection:</span> 55 - <span class="text-gray-600">{{ record.collection }}</span> 56 - </div> 57 - <div> 58 - <span class="font-medium">Author:</span> 59 - <span class="text-gray-600">{{ record.did }}</span> 60 - </div> 61 - <div> 62 - <span class="font-medium">CID:</span> 63 - <span class="text-gray-600 font-mono text-xs">{{ record.cid }}</span> 64 - </div> 65 - </div> 66 - {% if record.value %} 67 - <div class="mt-4"> 68 - <details class="cursor-pointer"> 69 - <summary class="font-medium text-gray-700 hover:text-gray-900">View Record Data</summary> 70 - <pre 71 - class="mt-2 bg-gray-100 p-3 rounded text-xs overflow-x-auto">{{ record.pretty_value }}</pre> 72 - </details> 73 - </div> 74 - {% endif %} 75 - </div> 76 - {% endfor %} 77 - </div> 78 - </div> 79 - {% else %} 80 - <div class="bg-white rounded-lg shadow-md p-8 text-center"> 81 - <div class="text-gray-400 text-6xl mb-4">📝</div> 82 - <h3 class="text-xl font-semibold text-gray-800 mb-2">No records found</h3> 83 - <p class="text-gray-600 mb-4"> 84 - {% if collection %} 85 - No records found for collection "{{ collection }}". 86 - {% elif available_collections %} 87 - Select a collection from the dropdown above to view records. 88 - {% else %} 89 - No records have been synced yet. Start by syncing some AT Protocol records! 90 - {% endif %} 91 - </p> 92 - <a href="/sync" class="bg-blue-500 hover:bg-blue-600 text-white px-4 py-2 rounded"> 93 - Sync Some Records 94 - </a> 95 - </div> 96 - {% endif %} 97 - </div> 98 - {% endblock %}
-122
api/templates/sync.html
··· 1 - {% extends "base.html" %} 2 - 3 - {% block content %} 4 - <div class="max-w-4xl mx-auto"> 5 - <h1 class="text-3xl font-bold text-gray-800 mb-8">Sync Records</h1> 6 - 7 - <div class="max-w-2xl mx-auto"> 8 - <!-- Lexicon Upload Section --> 9 - <div class="bg-white rounded-lg shadow-md p-6 mb-6"> 10 - <h2 class="text-xl font-semibold text-gray-800 mb-4">📦 Upload Lexicon Files</h2> 11 - <p class="text-gray-600 mb-6">Upload a zip file containing lexicon JSON files to automatically populate collections for syncing.</p> 12 - 13 - <form hx-post="/upload-lexicons" 14 - hx-target="#lexicon-result" 15 - hx-indicator="#upload-button" 16 - enctype="multipart/form-data" 17 - class="space-y-4"> 18 - <div> 19 - <label for="lexicon-file" class="block text-sm font-medium text-gray-700 mb-2">Lexicon Zip File</label> 20 - <input type="file" 21 - id="lexicon-file" 22 - name="lexicon_file" 23 - accept=".zip" 24 - class="w-full border border-gray-300 rounded-md px-3 py-2 focus:ring-2 focus:ring-green-500 focus:border-green-500" 25 - required> 26 - <p class="text-xs text-gray-500 mt-1"> 27 - Upload a zip file containing lexicon JSON files. Record definitions will be extracted automatically. 28 - </p> 29 - </div> 30 - 31 - <button type="submit" 32 - id="upload-button" 33 - class="w-full bg-green-500 hover:bg-green-600 text-white px-6 py-3 rounded-md font-medium"> 34 - <span class="default-text">Parse Lexicons</span> 35 - <span class="htmx-indicator">🔄 Processing...</span> 36 - </button> 37 - </form> 38 - 39 - <div id="lexicon-result" class="mt-4"></div> 40 - </div> 41 - 42 - <!-- Bulk Sync Section --> 43 - <div class="bg-white rounded-lg shadow-md p-6"> 44 - <h2 class="text-xl font-semibold text-gray-800 mb-4">📚 Bulk Sync Collections</h2> 45 - <p class="text-gray-600 mb-6">Sync multiple records from AT Protocol collections. This will fetch records from across the network.</p> 46 - 47 - <form hx-post="/sync" 48 - hx-target="#sync-result" 49 - hx-indicator="#sync-button" 50 - class="space-y-6"> 51 - <div> 52 - <label for="collections" class="block text-sm font-medium text-gray-700 mb-2">Collections to Sync</label> 53 - <input type="text" 54 - id="collections" 55 - name="collections" 56 - placeholder="app.bsky.feed.post, app.bsky.actor.profile" 57 - class="w-full border border-gray-300 rounded-md px-3 py-2 focus:ring-2 focus:ring-blue-500 focus:border-blue-500" 58 - required> 59 - <p class="text-xs text-gray-500 mt-1"> 60 - Enter collection names separated by commas. Common collections: app.bsky.feed.post, app.bsky.actor.profile, app.bsky.feed.like 61 - </p> 62 - </div> 63 - 64 - <div> 65 - <label for="repos" class="block text-sm font-medium text-gray-700 mb-2">Specific DIDs (optional)</label> 66 - <textarea id="repos" 67 - name="repos" 68 - placeholder="did:plc:example1&#10;did:plc:example2" 69 - rows="4" 70 - class="w-full border border-gray-300 rounded-md px-3 py-2 focus:ring-2 focus:ring-blue-500 focus:border-blue-500"></textarea> 71 - <p class="text-xs text-gray-500 mt-1">Optional: Enter specific DIDs (one per line). Leave empty to discover and sync from all repositories that have the specified collections.</p> 72 - </div> 73 - 74 - <div class="bg-yellow-50 border border-yellow-200 rounded-lg p-4"> 75 - <div class="flex"> 76 - <div class="flex-shrink-0"> 77 - <span class="text-yellow-600">⚠️</span> 78 - </div> 79 - <div class="ml-3"> 80 - <h3 class="text-sm font-medium text-yellow-800">Note about bulk sync</h3> 81 - <div class="mt-2 text-sm text-yellow-700"> 82 - <p>Bulk sync can take several minutes and may fetch thousands of records. The operation runs in the foreground, so please be patient.</p> 83 - </div> 84 - </div> 85 - </div> 86 - </div> 87 - 88 - <button type="submit" 89 - id="sync-button" 90 - class="w-full bg-blue-500 hover:bg-blue-600 text-white px-6 py-3 rounded-md font-medium"> 91 - <span class="default-text">Start Bulk Sync</span> 92 - <span class="htmx-indicator">🔄 Syncing...</span> 93 - </button> 94 - </form> 95 - 96 - <div id="sync-result" class="mt-6"></div> 97 - </div> 98 - </div> 99 - 100 - <div class="mt-8 bg-blue-50 border border-blue-200 rounded-lg p-6"> 101 - <h3 class="text-lg font-semibold text-blue-800 mb-2">💡 Tips for Bulk Sync</h3> 102 - <ul class="space-y-2 text-blue-700"> 103 - <li class="flex items-start"> 104 - <span class="font-bold mr-2">•</span> 105 - <span>Start with popular collections like <code class="bg-blue-100 px-1 rounded">app.bsky.feed.post</code> for posts</span> 106 - </li> 107 - <li class="flex items-start"> 108 - <span class="font-bold mr-2">•</span> 109 - <span>Leave DIDs empty to discover all repositories automatically</span> 110 - </li> 111 - <li class="flex items-start"> 112 - <span class="font-bold mr-2">•</span> 113 - <span>Use the API endpoint for programmatic access: <code class="bg-blue-100 px-1 rounded">/xrpc/social.slices.collections.bulkSync</code></span> 114 - </li> 115 - <li class="flex items-start"> 116 - <span class="font-bold mr-2">•</span> 117 - <span>Sync operations may take time - progress is logged to the server console</span> 118 - </li> 119 - </ul> 120 - </div> 121 - </div> 122 - {% endblock %}
+52 -45
frontend/src/client.ts
··· 1 1 // Generated TypeScript client for AT Protocol records 2 - // Generated at: 2025-08-21 22:15:58 UTC 2 + // Generated at: 2025-08-22 22:17:55 UTC 3 3 // Lexicons: 2 4 4 5 5 export interface OAuthAuthorizeParams { ··· 73 73 getRecord(params: GetRecordParams): Promise<RecordResponse<T>>; 74 74 } 75 75 76 - export interface XyzSliceatLexiconRecord { 77 - /** When the lexicon was created */ 78 - createdAt: string; 79 - /** The lexicon schema definitions as JSON */ 80 - definitions: string; 76 + export interface SocialSlicesLexiconRecord { 81 77 /** Namespaced identifier for the lexicon */ 82 78 nsid: string; 83 - /** AT-URI reference to the slice this lexicon belongs to */ 84 - slice: string; 79 + /** The lexicon schema definitions as JSON */ 80 + definitions: string; 81 + /** When the lexicon was created */ 82 + createdAt: string; 85 83 /** When the lexicon was last updated */ 86 84 updatedAt?: string; 85 + /** AT-URI reference to the slice this lexicon belongs to */ 86 + slice: string; 87 87 } 88 88 89 - export interface XyzSliceatSliceRecord { 89 + export interface SocialSlicesSliceRecord { 90 + /** Name of the slice */ 91 + name: string; 90 92 /** When the slice was created */ 91 93 createdAt: string; 92 - /** Name of the slice */ 93 - name: string; 94 94 } 95 95 96 96 export class PKCEUtils { ··· 434 434 } 435 435 } 436 436 437 - class LexiconSliceatXyzClient extends BaseClient { 437 + class LexiconSlicesSocialClient extends BaseClient { 438 438 constructor( 439 439 baseUrl: string, 440 440 authBaseUrl: string, ··· 446 446 447 447 async listRecords( 448 448 params?: ListRecordsParams 449 - ): Promise<ListRecordsResponse<XyzSliceatLexiconRecord>> { 450 - return await this.makeRequest("xyz.sliceat.lexicon.list", "GET", params); 449 + ): Promise<ListRecordsResponse<SocialSlicesLexiconRecord>> { 450 + return await this.makeRequest("social.slices.lexicon.list", "GET", params); 451 451 } 452 452 453 453 async getRecord( 454 454 params: GetRecordParams 455 - ): Promise<RecordResponse<XyzSliceatLexiconRecord>> { 456 - return await this.makeRequest("xyz.sliceat.lexicon.get", "GET", params); 455 + ): Promise<RecordResponse<SocialSlicesLexiconRecord>> { 456 + return await this.makeRequest("social.slices.lexicon.get", "GET", params); 457 457 } 458 458 459 459 async createRecord( 460 - record: XyzSliceatLexiconRecord 460 + record: SocialSlicesLexiconRecord 461 461 ): Promise<{ uri: string; cid: string }> { 462 - const recordWithType = { $type: "xyz.sliceat.lexicon", ...record }; 462 + const recordWithType = { $type: "social.slices.lexicon", ...record }; 463 463 return await this.makeRequest( 464 - "xyz.sliceat.lexicon.create", 464 + "social.slices.lexicon.create", 465 465 "POST", 466 466 recordWithType 467 467 ); ··· 469 469 470 470 async updateRecord( 471 471 rkey: string, 472 - record: XyzSliceatLexiconRecord 472 + record: SocialSlicesLexiconRecord 473 473 ): Promise<{ uri: string; cid: string }> { 474 - const recordWithType = { $type: "xyz.sliceat.lexicon", ...record }; 475 - return await this.makeRequest("xyz.sliceat.lexicon.update", "POST", { 474 + const recordWithType = { $type: "social.slices.lexicon", ...record }; 475 + return await this.makeRequest("social.slices.lexicon.update", "POST", { 476 476 rkey, 477 477 record: recordWithType, 478 478 }); 479 479 } 480 480 481 481 async deleteRecord(rkey: string): Promise<void> { 482 - return await this.makeRequest("xyz.sliceat.lexicon.delete", "POST", { 482 + return await this.makeRequest("social.slices.lexicon.delete", "POST", { 483 483 rkey, 484 484 }); 485 485 } 486 486 } 487 487 488 - class SliceSliceatXyzClient extends BaseClient { 488 + class SliceSlicesSocialClient extends BaseClient { 489 489 constructor( 490 490 baseUrl: string, 491 491 authBaseUrl: string, ··· 497 497 498 498 async listRecords( 499 499 params?: ListRecordsParams 500 - ): Promise<ListRecordsResponse<XyzSliceatSliceRecord>> { 501 - return await this.makeRequest("xyz.sliceat.slice.list", "GET", params); 500 + ): Promise<ListRecordsResponse<SocialSlicesSliceRecord>> { 501 + return await this.makeRequest("social.slices.slice.list", "GET", params); 502 502 } 503 503 504 504 async getRecord( 505 505 params: GetRecordParams 506 - ): Promise<RecordResponse<XyzSliceatSliceRecord>> { 507 - return await this.makeRequest("xyz.sliceat.slice.get", "GET", params); 506 + ): Promise<RecordResponse<SocialSlicesSliceRecord>> { 507 + return await this.makeRequest("social.slices.slice.get", "GET", params); 508 508 } 509 509 510 510 async createRecord( 511 - record: XyzSliceatSliceRecord 511 + record: SocialSlicesSliceRecord 512 512 ): Promise<{ uri: string; cid: string }> { 513 - const recordWithType = { $type: "xyz.sliceat.slice", ...record }; 513 + const recordWithType = { $type: "social.slices.slice", ...record }; 514 514 return await this.makeRequest( 515 - "xyz.sliceat.slice.create", 515 + "social.slices.slice.create", 516 516 "POST", 517 517 recordWithType 518 518 ); ··· 520 520 521 521 async updateRecord( 522 522 rkey: string, 523 - record: XyzSliceatSliceRecord 523 + record: SocialSlicesSliceRecord 524 524 ): Promise<{ uri: string; cid: string }> { 525 - const recordWithType = { $type: "xyz.sliceat.slice", ...record }; 526 - return await this.makeRequest("xyz.sliceat.slice.update", "POST", { 525 + const recordWithType = { $type: "social.slices.slice", ...record }; 526 + return await this.makeRequest("social.slices.slice.update", "POST", { 527 527 rkey, 528 528 record: recordWithType, 529 529 }); 530 530 } 531 531 532 532 async deleteRecord(rkey: string): Promise<void> { 533 - return await this.makeRequest("xyz.sliceat.slice.delete", "POST", { rkey }); 533 + return await this.makeRequest("social.slices.slice.delete", "POST", { 534 + rkey, 535 + }); 534 536 } 535 537 } 536 538 537 - class SliceatXyzClient extends BaseClient { 538 - readonly lexicon: LexiconSliceatXyzClient; 539 - readonly slice: SliceSliceatXyzClient; 539 + class SlicesSocialClient extends BaseClient { 540 + readonly lexicon: LexiconSlicesSocialClient; 541 + readonly slice: SliceSlicesSocialClient; 540 542 541 543 constructor( 542 544 baseUrl: string, ··· 545 547 clientSecret: string 546 548 ) { 547 549 super(baseUrl, authBaseUrl, clientId, clientSecret); 548 - this.lexicon = new LexiconSliceatXyzClient( 550 + this.lexicon = new LexiconSlicesSocialClient( 549 551 baseUrl, 550 552 authBaseUrl, 551 553 clientId, 552 554 clientSecret 553 555 ); 554 - this.slice = new SliceSliceatXyzClient( 556 + this.slice = new SliceSlicesSocialClient( 555 557 baseUrl, 556 558 authBaseUrl, 557 559 clientId, ··· 560 562 } 561 563 } 562 564 563 - class XyzClient extends BaseClient { 564 - readonly sliceat: SliceatXyzClient; 565 + class SocialClient extends BaseClient { 566 + readonly slices: SlicesSocialClient; 565 567 566 568 constructor( 567 569 baseUrl: string, ··· 570 572 clientSecret: string 571 573 ) { 572 574 super(baseUrl, authBaseUrl, clientId, clientSecret); 573 - this.sliceat = new SliceatXyzClient( 575 + this.slices = new SlicesSocialClient( 574 576 baseUrl, 575 577 authBaseUrl, 576 578 clientId, ··· 580 582 } 581 583 582 584 export class AtProtoClient extends BaseClient { 583 - readonly xyz: XyzClient; 585 + readonly social: SocialClient; 584 586 readonly oauth: OAuthClient; 585 587 586 588 constructor( ··· 590 592 clientSecret: string 591 593 ) { 592 594 super(baseUrl, authBaseUrl, clientId, clientSecret); 593 - this.xyz = new XyzClient(baseUrl, authBaseUrl, clientId, clientSecret); 595 + this.social = new SocialClient( 596 + baseUrl, 597 + authBaseUrl, 598 + clientId, 599 + clientSecret 600 + ); 594 601 this.oauth = new OAuthClient(baseUrl, authBaseUrl, clientId, clientSecret); 595 602 } 596 603 }
+11 -14
frontend/src/pages/SliceLexiconPage.tsx
··· 26 26 <div className="max-w-4xl mx-auto"> 27 27 <div className="flex items-center justify-between mb-8"> 28 28 <div className="flex items-center"> 29 - <a 30 - href="/" 31 - className="text-blue-600 hover:text-blue-800 mr-4" 32 - > 29 + <a href="/" className="text-blue-600 hover:text-blue-800 mr-4"> 33 30 ← Back to Slices 34 31 </a> 35 - <h1 className="text-3xl font-bold text-gray-800"> 36 - {sliceName} 37 - </h1> 32 + <h1 className="text-3xl font-bold text-gray-800">{sliceName}</h1> 38 33 </div> 39 34 </div> 40 35 ··· 82 77 className="block w-full border border-gray-300 rounded-md px-3 py-2 font-mono text-sm" 83 78 placeholder={`{ 84 79 "lexicon": 1, 85 - "id": "xyz.sliceat.example", 80 + "id": "social.slices.example", 86 81 "description": "Example record type", 87 82 "defs": { 88 83 "main": { ··· 119 114 Add Lexicon 120 115 </button> 121 116 </form> 122 - 117 + 123 118 <div id="lexicon-result" className="mt-4"></div> 124 119 </div> 125 120 ··· 128 123 Upload Lexicon Files 129 124 </h2> 130 125 <p className="text-gray-600 mb-6"> 131 - Or upload lexicon schema files to define custom record types for this slice. 126 + Or upload lexicon schema files to define custom record types for 127 + this slice. 132 128 </p> 133 129 134 130 <form ··· 148 144 className="block w-full border border-gray-300 rounded-md px-3 py-2" 149 145 /> 150 146 <p className="text-sm text-gray-500 mt-1"> 151 - Upload a ZIP file containing lexicon definitions or a single JSON file 147 + Upload a ZIP file containing lexicon definitions or a single 148 + JSON file 152 149 </p> 153 150 </div> 154 151 ··· 167 164 Slice Lexicons 168 165 </h2> 169 166 </div> 170 - <div 171 - id="lexicon-list" 167 + <div 168 + id="lexicon-list" 172 169 className="p-6" 173 170 hx-get="/api/lexicons/list" 174 171 hx-trigger="load" ··· 194 191 </div> 195 192 </Layout> 196 193 ); 197 - } 194 + }
+14 -10
frontend/src/routes/pages.tsx
··· 10 10 import { SliceLexiconPage } from "../pages/SliceLexiconPage.tsx"; 11 11 import { SliceCodegenPage } from "../pages/SliceCodegenPage.tsx"; 12 12 import { SliceSettingsPage } from "../pages/SliceSettingsPage.tsx"; 13 + import { buildAtUri } from "../utils/at-uri.ts"; 13 14 14 15 async function handleIndexPage(req: Request): Promise<Response> { 15 16 const context = await withAuth(req); ··· 19 20 20 21 if (context.currentUser.isAuthenticated) { 21 22 try { 22 - const sliceRecords = await atprotoClient.xyz.sliceat.slice.listRecords(); 23 + const sliceRecords = 24 + await atprotoClient.social.slices.slice.listRecords(); 23 25 24 26 slices = sliceRecords.records.map((record) => { 25 27 // Extract slice ID from URI ··· 105 107 // Construct the full URI for this slice 106 108 const sliceUri = `at://${ 107 109 context.currentUser.sub || "unknown" 108 - }/xyz.sliceat.slice/${sliceId}`; 110 + }/social.slices.slice/${sliceId}`; 109 111 110 - const sliceRecord = await atprotoClient.xyz.sliceat.slice.getRecord({ 112 + const sliceRecord = await atprotoClient.social.slices.slice.getRecord({ 111 113 uri: sliceUri, 112 114 }); 113 115 ··· 115 117 sliceId, 116 118 sliceName: sliceRecord.value.name, 117 119 totalRecords: 1, // For now, just showing this slice 118 - collections: [{ name: "xyz.sliceat.slice", count: 1 }], 120 + collections: [{ name: "social.slices.slice", count: 1 }], 119 121 }; 120 - } catch (error) { 122 + } catch (_error) { 121 123 // Fall back to default data 122 124 } 123 125 } ··· 167 169 if (context.currentUser.isAuthenticated) { 168 170 try { 169 171 // Construct the full URI for this slice 170 - const sliceUri = `at://${ 171 - context.currentUser.sub || "unknown" 172 - }/xyz.sliceat.slice/${sliceId}`; 172 + const sliceUri = buildAtUri({ 173 + did: context.currentUser.sub ?? "unknown", 174 + collection: "social.slices.slice", 175 + rkey: sliceId, 176 + }); 173 177 174 - const sliceRecord = await atprotoClient.xyz.sliceat.slice.getRecord({ 178 + const sliceRecord = await atprotoClient.social.slices.slice.getRecord({ 175 179 uri: sliceUri, 176 180 }); 177 181 ··· 179 183 sliceId, 180 184 sliceName: sliceRecord.value.name, 181 185 totalRecords: 1, // For now, just showing this slice 182 - collections: [{ name: "xyz.sliceat.slice", count: 1 }], 186 + collections: [{ name: "social.slices.slice", count: 1 }], 183 187 }; 184 188 } catch (error) { 185 189 console.error("Failed to fetch slice:", error);
+18 -12
frontend/src/routes/slices.tsx
··· 42 42 43 43 // Create actual slice using AT Protocol 44 44 try { 45 - const result = await atprotoClient.xyz.sliceat.slice.createRecord({ 45 + const result = await atprotoClient.social.slices.slice.createRecord({ 46 46 name: name.trim(), 47 47 createdAt: new Date().toISOString(), 48 48 }); 49 49 50 - // Extract record key from URI (format: at://did:plc:example/xyz.sliceat.slice/rkey) 50 + // Extract record key from URI (format: at://did:plc:example/social.slices.slice/rkey) 51 51 const uriParts = result.uri.split("/"); 52 52 const sliceId = uriParts[uriParts.length - 1]; 53 53 ··· 108 108 } 109 109 110 110 // Construct the URI for this slice 111 - const sliceUri = `at://${context.currentUser.sub}/xyz.sliceat.slice/${sliceId}`; 111 + const sliceUri = `at://${context.currentUser.sub}/social.slices.slice/${sliceId}`; 112 112 113 113 // Get the current record first 114 - const currentRecord = await atprotoClient.xyz.sliceat.slice.getRecord({ 114 + const currentRecord = await atprotoClient.social.slices.slice.getRecord({ 115 115 uri: sliceUri, 116 116 }); 117 117 ··· 121 121 name: name.trim(), 122 122 }; 123 123 124 - await atprotoClient.xyz.sliceat.slice.updateRecord(sliceId, updatedRecord); 124 + await atprotoClient.social.slices.slice.updateRecord( 125 + sliceId, 126 + updatedRecord 127 + ); 125 128 126 129 const resultHtml = render( 127 130 <UpdateResult ··· 148 151 } 149 152 } 150 153 151 - async function handleDeleteSlice(req: Request, params?: URLPatternResult): Promise<Response> { 154 + async function handleDeleteSlice( 155 + req: Request, 156 + params?: URLPatternResult 157 + ): Promise<Response> { 152 158 const context = await withAuth(req); 153 159 const authResponse = requireAuth(context, req); 154 160 if (authResponse) return authResponse; ··· 160 166 161 167 try { 162 168 // Delete the slice record from AT Protocol 163 - await atprotoClient.xyz.sliceat.slice.deleteRecord(sliceId); 169 + await atprotoClient.social.slices.slice.deleteRecord(sliceId); 164 170 165 171 // Redirect to home page 166 172 return new Response("", { ··· 182 188 try { 183 189 // Fetch lexicons from AT Protocol 184 190 const lexiconRecords = 185 - await atprotoClient.xyz.sliceat.lexicon.listRecords(); 191 + await atprotoClient.social.slices.lexicon.listRecords(); 186 192 187 193 if (lexiconRecords.records.length === 0) { 188 194 const html = render(<EmptyLexiconState />); ··· 261 267 // Create the lexicon record 262 268 try { 263 269 // For now, we'll create a simple slice reference - this could be improved to reference a specific slice 264 - const sliceUri = `at://${context.currentUser.sub}/xyz.sliceat.slice/example`; 270 + const sliceUri = `at://${context.currentUser.sub}/social.slices.slice/example`; 265 271 266 272 const lexiconRecord = { 267 273 nsid: lexiconData.id, ··· 270 276 slice: sliceUri, 271 277 }; 272 278 273 - const result = await atprotoClient.xyz.sliceat.lexicon.createRecord( 279 + const result = await atprotoClient.social.slices.lexicon.createRecord( 274 280 lexiconRecord 275 281 ); 276 282 ··· 318 324 319 325 try { 320 326 // Delete the lexicon record from AT Protocol 321 - await atprotoClient.xyz.sliceat.lexicon.deleteRecord(rkey); 327 + await atprotoClient.social.slices.lexicon.deleteRecord(rkey); 322 328 323 329 // Check if there are any remaining lexicons 324 330 const remainingLexicons = 325 - await atprotoClient.xyz.sliceat.lexicon.listRecords(); 331 + await atprotoClient.social.slices.lexicon.listRecords(); 326 332 327 333 if (remainingLexicons.records.length === 0) { 328 334 // If no lexicons remain, return the empty state and target the parent list