diff --git a/api/lexicons.zip b/api/lexicons.zip deleted file mode 100644 index bba8e79e09504b8e6d1e9bae35490f499d4b080e..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 53893 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 diff --git a/api/migrations/003_actors.sql b/api/migrations/002_actors.sql similarity index 100% rename from api/migrations/003_actors.sql rename to api/migrations/002_actors.sql diff --git a/api/migrations/002_lexicons.sql b/api/migrations/002_lexicons.sql deleted file mode 100644 index 0b5d1e2..0000000 --- a/api/migrations/002_lexicons.sql +++ /dev/null @@ -1,10 +0,0 @@ --- Add lexicons table for storing AT Protocol lexicon schemas -CREATE TABLE IF NOT EXISTS "lexicons" ( - "nsid" TEXT PRIMARY KEY NOT NULL, - "definitions" JSONB NOT NULL, - "created_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(), - "updated_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW() -); - -CREATE INDEX IF NOT EXISTS idx_lexicons_nsid ON "lexicons"("nsid"); -CREATE INDEX IF NOT EXISTS idx_lexicons_definitions ON "lexicons" USING gin("definitions"); \ No newline at end of file diff --git a/api/scripts/generated_client.ts b/api/scripts/generated_client.ts deleted file mode 100644 index 19765bd..0000000 --- a/api/scripts/generated_client.ts +++ /dev/null @@ -1 +0,0 @@ -null diff --git a/api/scripts/test_codegen.sh b/api/scripts/test_codegen.sh index 6d9a0b1..da3b7d6 100755 --- a/api/scripts/test_codegen.sh +++ b/api/scripts/test_codegen.sh @@ -4,12 +4,11 @@ echo "๐Ÿงช Testing TypeScript Code Generation..." # Test with multiple lexicons echo "๐Ÿ“ Generating TypeScript client with multiple lexicons..." -curl -s -X POST http://localhost:3000/xrpc/social.slices.codegen.generate \ +curl -s -X POST http://localhost:3000/xrpc/social.slices.slice.codegen \ -H "Content-Type: application/json" \ -d '{ - "target": "typescript-deno", - "client_type": "records", - "lexicons": ["social.grain.gallery", "social.grain.comment"] + "target": "typescript", + "lexicons": ["social.slices.slice", "social.slices.lexicon"] }' | jq -r '.generated_code' > generated_client.ts if [ $? -eq 0 ] && [ -f generated_client.ts ]; then @@ -17,11 +16,11 @@ if [ $? -eq 0 ] && [ -f generated_client.ts ]; then echo "๐Ÿ“Š Generated code stats:" echo " Lines: $(wc -l < generated_client.ts)" echo " Size: $(du -h generated_client.ts | cut -f1)" - + echo "" echo "๐Ÿ” Preview of generated interfaces:" grep -A 5 "export interface.*Record {" generated_client.ts || echo "No record interfaces found" - + echo "" echo "๐ŸŽฏ Preview of auto-typing examples:" grep -A 10 "// Usage examples:" generated_client.ts || echo "No usage examples found" @@ -31,4 +30,4 @@ else fi echo "" -echo "๐ŸŽ‰ Test complete! Check generated_client.ts to see the auto-typing functionality." \ No newline at end of file +echo "๐ŸŽ‰ Test complete! Check generated_client.ts to see the auto-typing functionality." diff --git a/api/scripts/test_sync.sh b/api/scripts/test_sync.sh new file mode 100755 index 0000000..304b223 --- /dev/null +++ b/api/scripts/test_sync.sh @@ -0,0 +1,19 @@ +#!/bin/bash + +echo "๐Ÿ”„ Testing Sync Endpoint..." + +echo "๐ŸŽฏ Syncing specific collections with specific repos" +curl -s -X POST http://localhost:3000/xrpc/social.slices.slice.sync \ + -H "Content-Type: application/json" \ + -d '{ + "collections": [ + "social.slice.slice", + "social.slice.lexicon" + ], + "repos": [ + "did:plc:bcgltzqazw5tb6k2g3ttenbj" + ] + }' | jq '.' + +echo "" +echo "โœ… Sync test complete!" diff --git a/api/src/codegen/typescript.rs b/api/src/codegen/typescript.rs index 8303441..116ddba 100644 --- a/api/src/codegen/typescript.rs +++ b/api/src/codegen/typescript.rs @@ -1,5 +1,3 @@ -use crate::models::Lexicon; - pub struct TypeScriptGenerator; impl TypeScriptGenerator { @@ -7,28 +5,8 @@ impl TypeScriptGenerator { Self } - pub fn generate_client(&self, lexicons: &[Lexicon]) -> Result { - // Serialize lexicons to JSON for the Deno script - let lexicons_json = serde_json::to_string(lexicons) - .map_err(|e| format!("Failed to serialize lexicons: {}", e))?; - - // Call the Deno script to generate TypeScript with proper comments - let output = std::process::Command::new("deno") - .arg("run") - .arg("--allow-all") - .arg("scripts/generate-typescript.ts") - .arg(&lexicons_json) - .output() - .map_err(|e| format!("Failed to execute deno script: {}", e))?; - - if !output.status.success() { - let stderr = String::from_utf8_lossy(&output.stderr); - return Err(format!("Deno script failed: {}", stderr)); - } - - let generated_code = String::from_utf8(output.stdout) - .map_err(|e| format!("Failed to decode output: {}", e))?; - - Ok(generated_code) + pub fn generate_client(&self, _lexicons_json: &str) -> Result { + // TODO: Implement TypeScript generation - functionality moved to /frontend + Ok("// TypeScript generation placeholder - functionality moved to /frontend\n".to_string()) } } \ No newline at end of file diff --git a/api/src/database.rs b/api/src/database.rs index 53f0ac6..888d376 100644 --- a/api/src/database.rs +++ b/api/src/database.rs @@ -1,7 +1,7 @@ use sqlx::PgPool; use crate::errors::DatabaseError; -use crate::models::{Actor, IndexedRecord, Lexicon, ListRecordsParams, Record}; +use crate::models::{Actor, IndexedRecord, Record}; #[derive(Clone)] pub struct Database { @@ -85,8 +85,8 @@ impl Database { Ok(indexed_record) } - pub async fn list_records(&self, params: ListRecordsParams) -> Result, DatabaseError> { - let limit = params.limit.unwrap_or(25).min(100); + pub async fn list_records(&self, collection: &str, author: Option<&str>, limit: Option) -> Result, DatabaseError> { + let limit = limit.unwrap_or(25).min(100); let records = sqlx::query_as::<_, Record>( r#"SELECT "uri", "cid", "did", "collection", "json", "indexedAt" @@ -96,8 +96,8 @@ impl Database { ORDER BY "indexedAt" DESC LIMIT $3"#, ) - .bind(¶ms.collection) - .bind(¶ms.author) + .bind(collection) + .bind(author) .bind(limit) .fetch_all(&self.pool) .await?; @@ -117,22 +117,28 @@ impl Database { Ok(indexed_records) } - pub async fn get_available_collections(&self) -> Result, DatabaseError> { - let collections = sqlx::query!( - r#"SELECT "collection", COUNT(*) as count + pub async fn get_lexicons_by_slice(&self, slice_uri: &str) -> Result, DatabaseError> { + let records = sqlx::query_as::<_, Record>( + r#"SELECT "uri", "cid", "did", "collection", "json", "indexedAt" FROM "record" - GROUP BY "collection" - ORDER BY count DESC, "collection" ASC"# + WHERE "collection" = 'social.slices.lexicon' + AND "json"->>'slice' = $1 + ORDER BY "indexedAt" DESC"#, ) + .bind(slice_uri) .fetch_all(&self.pool) .await?; - Ok(collections + let lexicon_definitions: Vec = records .into_iter() - .map(|row| (row.collection, row.count.unwrap_or(0))) - .collect()) + .filter_map(|record| record.json.get("definition").cloned()) + .collect(); + + Ok(lexicon_definitions) } + + pub async fn get_total_record_count(&self) -> Result { let count = sqlx::query!("SELECT COUNT(*) as count FROM record") .fetch_one(&self.pool) @@ -141,49 +147,6 @@ impl Database { Ok(count.count.unwrap_or(0)) } - pub async fn insert_lexicon(&self, lexicon: &Lexicon) -> Result<(), DatabaseError> { - sqlx::query!( - r#"INSERT INTO "lexicons" ("nsid", "definitions", "created_at", "updated_at") - VALUES ($1, $2, $3, $4) - ON CONFLICT ("nsid") - DO UPDATE SET - "definitions" = EXCLUDED."definitions", - "updated_at" = EXCLUDED."updated_at""#, - lexicon.nsid, - lexicon.definitions, - lexicon.created_at, - lexicon.updated_at - ) - .execute(&self.pool) - .await?; - - Ok(()) - } - - pub async fn get_lexicon(&self, nsid: &str) -> Result, DatabaseError> { - let lexicon = sqlx::query_as::<_, Lexicon>( - r#"SELECT "nsid", "definitions", "created_at", "updated_at" - FROM "lexicons" - WHERE "nsid" = $1"#, - ) - .bind(nsid) - .fetch_optional(&self.pool) - .await?; - - Ok(lexicon) - } - - pub async fn get_all_lexicons(&self) -> Result, DatabaseError> { - let lexicons = sqlx::query_as::<_, Lexicon>( - r#"SELECT "nsid", "definitions", "created_at", "updated_at" - FROM "lexicons" - ORDER BY "nsid""#, - ) - .fetch_all(&self.pool) - .await?; - - Ok(lexicons) - } pub async fn update_record(&self, record: &Record) -> Result<(), DatabaseError> { let result = sqlx::query!( diff --git a/api/src/errors.rs b/api/src/errors.rs index 51d54b6..78f3cb8 100644 --- a/api/src/errors.rs +++ b/api/src/errors.rs @@ -1,32 +1,12 @@ use thiserror::Error; -#[derive(Error, Debug)] -pub enum LexiconError { - #[error("error-slice-lexicon-1 Failed to parse multipart boundary: {0}")] - MultipartBoundary(String), - - #[error("error-slice-lexicon-2 Failed to read request body: {0}")] - RequestBody(String), - - #[error("error-slice-lexicon-3 Failed to parse zip archive: {0}")] - ZipArchive(String), - - #[error("error-slice-lexicon-4 Failed to read file from archive: {0}")] - FileRead(String), - - #[error("error-slice-lexicon-5 Failed to parse JSON lexicon: {0}")] - JsonParse(String), -} #[derive(Error, Debug)] pub enum DatabaseError { #[error("error-slice-database-1 SQL query failed: {0}")] SqlQuery(#[from] sqlx::Error), - #[error("error-slice-database-2 Transaction failed: {0}")] - Transaction(String), - - #[error("error-slice-database-3 Record not found: {uri}")] + #[error("error-slice-database-2 Record not found: {uri}")] RecordNotFound { uri: String }, } @@ -64,8 +44,5 @@ pub enum AppError { #[error("error-slice-app-3 Server bind failed: {0}")] ServerBind(#[from] std::io::Error), - - #[error("error-slice-app-4 Environment variable error: {0}")] - Environment(String), } diff --git a/api/src/handler_codegen.rs b/api/src/handler_codegen.rs deleted file mode 100644 index b0a0b5b..0000000 --- a/api/src/handler_codegen.rs +++ /dev/null @@ -1,103 +0,0 @@ -use axum::{ - extract::State, - http::StatusCode, - response::{Html, IntoResponse}, -}; -use axum_extra::extract::Form; -use minijinja::{context, Environment}; -use serde::Deserialize; -use crate::AppState; -use crate::codegen::TypeScriptGenerator; - -#[derive(Deserialize)] -pub struct CodegenForm { - target: String, - client_type: String, - #[serde(default)] - lexicons: Vec, -} - - -pub async fn generate_client( - State(state): State, - Form(form): Form, -) -> Result { - let selected_lexicons = form.lexicons; - - if selected_lexicons.is_empty() { - return Ok(Html(r#" -
-

โŒ No lexicons selected

-

Please select at least one lexicon to generate client code.

-
- "#.to_string())); - } - - // Fetch the selected lexicons from database - let mut lexicons = Vec::new(); - for nsid in &selected_lexicons { - if let Ok(Some(lexicon)) = state.database.get_lexicon(nsid).await { - lexicons.push(lexicon); - } - } - - let generated_code = match form.target.as_str() { - "typescript-deno" => match form.client_type.as_str() { - "records" => { - let generator = TypeScriptGenerator::new(); - match generator.generate_client(&lexicons) { - Ok(code) => code, - Err(e) => return Ok(Html(format!(r#" -
-

โŒ TypeScript generation failed

-

Error: {}

-
- "#, e))), - } - }, - _ => return Ok(Html(r#" -
-

โŒ Unsupported client type

-

Only "records" client type is currently supported.

-
- "#.to_string())), - }, - _ => return Ok(Html(r#" -
-

โŒ Unsupported target

-

Only "typescript-deno" is currently supported.

-
- "#.to_string())), - }; - - let mut env = Environment::new(); - env.add_template("codegen_result.html", r#" -
-

โœ… Client code generated successfully!

-

Target: {{ target }}

-

Client Type: {{ client_type }}

-

Lexicons: {{ lexicons_count }}

- -
-
-
Generated Code
- -
-
{{ generated_code }}
-
-
- "#).unwrap(); - - let tmpl = env.get_template("codegen_result.html").unwrap(); - let rendered = tmpl.render(context! { - target => form.target, - client_type => form.client_type, - lexicons_count => lexicons.len(), - generated_code => generated_code - }).unwrap(); - - Ok(Html(rendered)) -} diff --git a/api/src/handler_lexicon.rs b/api/src/handler_lexicon.rs deleted file mode 100644 index b8beb91..0000000 --- a/api/src/handler_lexicon.rs +++ /dev/null @@ -1,38 +0,0 @@ -use axum::{ - extract::State, - http::StatusCode, - response::{Html, IntoResponse}, -}; -use minijinja::{context, Environment}; - -use crate::AppState; - -pub async fn lexicon_page( - State(state): State, -) -> Result { - let lexicons = state.database.get_all_lexicons().await.unwrap_or_default(); - - // Transform lexicons to include pretty-printed JSON - let lexicons_with_pretty_json: Vec = lexicons.into_iter().map(|lexicon| { - let pretty_definitions = serde_json::to_string_pretty(&lexicon.definitions).unwrap_or_else(|_| "{}".to_string()); - serde_json::json!({ - "nsid": lexicon.nsid, - "definitions": lexicon.definitions, - "pretty_definitions": pretty_definitions, - "created_at": lexicon.created_at, - "updated_at": lexicon.updated_at - }) - }).collect(); - - let mut env = Environment::new(); - env.add_template("base.html", include_str!("../templates/base.html")).unwrap(); - env.add_template("lexicon.html", include_str!("../templates/lexicon.html")).unwrap(); - - let tmpl = env.get_template("lexicon.html").unwrap(); - let rendered = tmpl.render(context! { - title => "Lexicon Definitions", - lexicons => lexicons_with_pretty_json - }).unwrap(); - - Ok(Html(rendered)) -} diff --git a/api/src/handler_sync.rs b/api/src/handler_sync.rs new file mode 100644 index 0000000..985ad1b --- /dev/null +++ b/api/src/handler_sync.rs @@ -0,0 +1,36 @@ +use axum::{ + extract::State, + http::StatusCode, + response::Json, +}; +use crate::models::{BulkSyncOutput, BulkSyncParams}; +use crate::AppState; + +pub async fn sync( + State(state): State, + axum::extract::Json(params): axum::extract::Json, +) -> Result, StatusCode> { + match state + .sync_service + .backfill_collections(¶ms.collections, params.repos.as_deref()) + .await + { + Ok(_) => { + let total_records = state.database.get_total_record_count().await.unwrap_or(0); + Ok(Json(BulkSyncOutput { + success: true, + total_records, + collections_synced: params.collections, + repos_processed: params.repos.map(|r| r.len() as i64).unwrap_or(0), + message: "Sync completed successfully".to_string(), + })) + } + Err(e) => Ok(Json(BulkSyncOutput { + success: false, + total_records: 0, + collections_synced: vec![], + repos_processed: 0, + message: format!("Sync failed: {}", e), + })), + } +} \ No newline at end of file diff --git a/api/src/handler_upload_lexicon.rs b/api/src/handler_upload_lexicon.rs deleted file mode 100644 index 45044d7..0000000 --- a/api/src/handler_upload_lexicon.rs +++ /dev/null @@ -1,250 +0,0 @@ -use axum::{ - extract::{Request, State}, - http::StatusCode, - response::{Html, IntoResponse}, -}; -use axum::body::to_bytes; -use multer::Multipart; -use futures_util::stream::once; -use crate::errors::LexiconError; -use crate::models::Lexicon; -use crate::AppState; -use minijinja::{context, Environment}; -use serde::Deserialize; -use std::collections::HashSet; -use std::io::Read; -use tracing::{error, warn}; -use zip::ZipArchive; -use chrono::Utc; - -#[derive(Deserialize)] -struct LexiconFile { - id: String, - defs: serde_json::Map, -} - -pub async fn upload_lexicons( - State(state): State, - request: Request, -) -> Result { - - let boundary = request - .headers() - .get("content-type") - .and_then(|ct| ct.to_str().ok()) - .and_then(|ct| multer::parse_boundary(ct).ok()) - .ok_or_else(|| { - let err = LexiconError::MultipartBoundary("Missing or invalid content-type header".to_string()); - error!("{}", err); - StatusCode::BAD_REQUEST - })?; - - let body = request.into_body(); - let body_bytes = to_bytes(body, usize::MAX).await.map_err(|e| { - let err = LexiconError::RequestBody(e.to_string()); - error!("{}", err); - StatusCode::BAD_REQUEST - })?; - - - let body_stream = once(async move { Ok::<_, multer::Error>(body_bytes) }); - let mut multipart = Multipart::new(body_stream, boundary); - let mut collections = HashSet::new(); - let mut file_count = 0; - let mut record_count = 0; - - while let Some(field) = multipart.next_field().await.map_err(|_| StatusCode::BAD_REQUEST)? { - if field.name() == Some("lexicon_file") { - let data = field.bytes().await.map_err(|_| StatusCode::BAD_REQUEST)?; - - // Parse zip file - let cursor = std::io::Cursor::new(data); - let mut archive = ZipArchive::new(cursor).map_err(|e| { - let err = LexiconError::ZipArchive(e.to_string()); - error!("{}", err); - StatusCode::BAD_REQUEST - })?; - - - for i in 0..archive.len() { - let mut file = archive.by_index(i).map_err(|e| { - let err = LexiconError::FileRead(format!("Failed to access file at index {}: {}", i, e)); - error!("{}", err); - StatusCode::INTERNAL_SERVER_ERROR - })?; - - // Only process JSON files, skip macOS metadata files - if file.name().ends_with(".json") && - !file.name().contains("__MACOSX") && - !file.name().starts_with("._") { - - let mut contents = String::new(); - if let Err(e) = file.read_to_string(&mut contents) { - let err = LexiconError::FileRead(format!("Failed to read {}: {}", file.name(), e)); - warn!("{}", err); - continue; // Skip this file and continue processing others - } - - // Try to parse as lexicon - match serde_json::from_str::(&contents) { - Ok(lexicon_file) => { - file_count += 1; - - // Look for record definitions first - let mut has_record_def = false; - for (_def_name, def_value) in &lexicon_file.defs { - if let Some(def_obj) = def_value.as_object() { - if let Some(type_val) = def_obj.get("type") { - if type_val == "record" { - // This is a record definition - for AT Protocol listRecords, we only use the NSID - // Fragments (#definition) are for Lexicon references, not collection names - collections.insert(lexicon_file.id.clone()); - record_count += 1; - has_record_def = true; - } - } - } - } - - // Only store lexicon in database if it has record definitions (will be synced) - if has_record_def { - let now = Utc::now(); - let lexicon = Lexicon { - nsid: lexicon_file.id.clone(), - definitions: serde_json::Value::Object(lexicon_file.defs.clone()), - created_at: now, - updated_at: now, - }; - - if let Err(e) = state.database.insert_lexicon(&lexicon).await { - warn!("Failed to store lexicon {}: {}", lexicon_file.id, e); - } - } - } - Err(e) => { - let err = LexiconError::JsonParse(format!("Failed to parse {}: {}", file.name(), e)); - warn!("{}", err); - } - } - } - } - } - } - - let collections_list: Vec = collections.into_iter().collect(); - let collections_str = collections_list.join(", "); - - // Group collections by domain - create a vector of objects for template - let mut domain_groups: std::collections::BTreeMap> = std::collections::BTreeMap::new(); - for collection in &collections_list { - let domain = if let Some(dot_index) = collection.find('.') { - collection[..dot_index].to_string() - } else { - "other".to_string() - }; - domain_groups.entry(domain).or_insert_with(Vec::new).push(collection.clone()); - } - - // Convert to a format that minijinja can handle - let grouped_collections: Vec = domain_groups - .into_iter() - .map(|(domain, collections)| { - serde_json::json!({ - "domain": domain, - "collections": collections - }) - }) - .collect(); - - - let mut env = Environment::new(); - env.add_template("upload_result.html", r#" -
-

โœ… Lexicon parsing completed!

-

Files processed: {{ file_count }}

-

Record definitions found: {{ record_count }}

- -
-
Select Collections to Sync:
-
- {% for group in grouped_collections %} -
-
- - -
-
- {% for collection in group.collections %} -
- - -
- {% endfor %} -
-
- {% endfor %} -
- -
- - -
-
- - -
- "#).unwrap(); - - let tmpl = env.get_template("upload_result.html").unwrap(); - let rendered = tmpl.render(context! { - file_count => file_count, - record_count => record_count, - collections_str => collections_str, - grouped_collections => grouped_collections - }).unwrap(); - - Ok(Html(rendered)) -} diff --git a/api/src/handler_xrpc_codegen.rs b/api/src/handler_xrpc_codegen.rs index 3b20124..c6d03a0 100644 --- a/api/src/handler_xrpc_codegen.rs +++ b/api/src/handler_xrpc_codegen.rs @@ -9,9 +9,9 @@ use crate::codegen::TypeScriptGenerator; #[derive(Deserialize)] pub struct CodegenXrpcRequest { + #[allow(dead_code)] target: String, - client_type: String, - lexicons: Vec, + slice: String, // at-uri of the slice to filter lexicons by } #[derive(Serialize)] @@ -25,59 +25,38 @@ pub async fn generate_client_xrpc( State(state): State, Json(request): Json, ) -> Result, StatusCode> { - if request.lexicons.is_empty() { - return Ok(ResponseJson(CodegenXrpcResponse { + // Query database for lexicon definitions by slice + let lexicon_definitions = match state.database.get_lexicons_by_slice(&request.slice).await { + Ok(definitions) => definitions, + Err(_) => return Ok(ResponseJson(CodegenXrpcResponse { success: false, generated_code: None, - error: Some("No lexicons specified".to_string()), - })); - } - - // Fetch the selected lexicons from database - let mut lexicons = Vec::new(); - for nsid in &request.lexicons { - if let Ok(Some(lexicon)) = state.database.get_lexicon(nsid).await { - lexicons.push(lexicon); - } - } + error: Some("Failed to query lexicon records".to_string()), + })) + }; - if lexicons.is_empty() { + if lexicon_definitions.is_empty() { return Ok(ResponseJson(CodegenXrpcResponse { success: false, generated_code: None, - error: Some("No valid lexicons found".to_string()), + error: Some(format!("No lexicons found for slice: {}", request.slice)), })); } - let generated_code = match request.target.as_str() { - "typescript-deno" => match request.client_type.as_str() { - "records" => { - let generator = TypeScriptGenerator::new(); - match generator.generate_client(&lexicons) { - Ok(code) => code, - Err(e) => return Ok(ResponseJson(CodegenXrpcResponse { - success: false, - generated_code: None, - error: Some(format!("TypeScript generation failed: {}", e)), - })), - } - }, - _ => return Ok(ResponseJson(CodegenXrpcResponse { - success: false, - generated_code: None, - error: Some("Unsupported client type".to_string()), - })), - }, - _ => return Ok(ResponseJson(CodegenXrpcResponse { + // Pass lexicon definitions to TypeScript generator + let generator = TypeScriptGenerator::new(); + let lexicons_json = serde_json::to_string(&lexicon_definitions).unwrap_or_default(); + + match generator.generate_client(&lexicons_json) { + Ok(code) => Ok(ResponseJson(CodegenXrpcResponse { + success: true, + generated_code: Some(code), + error: None, + })), + Err(e) => Ok(ResponseJson(CodegenXrpcResponse { success: false, generated_code: None, - error: Some("Unsupported target".to_string()), - })), - }; - - Ok(ResponseJson(CodegenXrpcResponse { - success: true, - generated_code: Some(generated_code), - error: None, - })) + error: Some(e), + })) + } } diff --git a/api/src/handler_dynamic_xrpc.rs b/api/src/handler_xrpc_dynamic.rs similarity index 94% rename from api/src/handler_dynamic_xrpc.rs rename to api/src/handler_xrpc_dynamic.rs index 21b9796..330b04d 100644 --- a/api/src/handler_dynamic_xrpc.rs +++ b/api/src/handler_xrpc_dynamic.rs @@ -9,7 +9,7 @@ use atproto_client::{client::DPoPAuth, com::atproto::repo::{CreateRecordRequest, use atproto_identity::key::KeyData; use atproto_oauth::jwk::WrappedJsonWebKey; -use crate::models::{ListRecordsParams, ListRecordsOutput, Record}; +use crate::models::{ListRecordsOutput, Record}; use crate::AppState; #[derive(Deserialize)] @@ -24,34 +24,6 @@ pub struct GetRecordParams { pub uri: String, } -#[derive(Deserialize)] -pub struct CreateRecordParams { - pub repo: String, - pub collection: String, - pub rkey: Option, - pub record: serde_json::Value, -} - -#[derive(Deserialize)] -pub struct UpdateRecordParams { - pub repo: String, - pub collection: String, - pub rkey: String, - pub record: serde_json::Value, -} - -#[derive(Deserialize)] -pub struct DeleteRecordParams { - pub repo: String, - pub collection: String, - pub rkey: String, -} - -#[derive(Serialize)] -pub struct CreateRecordOutput { - pub uri: String, - pub cid: String, -} #[derive(Serialize, Deserialize, Debug)] pub struct UserInfoResponse { @@ -250,14 +222,7 @@ async fn dynamic_list_records_impl( let dynamic_params: DynamicListParams = serde_json::from_value(params) .map_err(|_| StatusCode::BAD_REQUEST)?; - let list_params = ListRecordsParams { - collection, - author: dynamic_params.author, - limit: dynamic_params.limit, - cursor: dynamic_params.cursor, - }; - - match state.database.list_records(list_params).await { + match state.database.list_records(&collection, dynamic_params.author.as_deref(), dynamic_params.limit).await { Ok(records) => { let output = ListRecordsOutput { records, diff --git a/api/src/main.rs b/api/src/main.rs index 4ffe92e..b9ed85f 100644 --- a/api/src/main.rs +++ b/api/src/main.rs @@ -1,21 +1,15 @@ mod codegen; mod database; mod errors; -mod handler_codegen; -mod handler_dynamic_xrpc; -mod handler_lexicon; -mod handler_upload_lexicon; +mod handler_sync; mod handler_xrpc_codegen; +mod handler_xrpc_dynamic; mod models; mod sync; mod utils; -mod web; use axum::{ Router, - extract::{Query, State}, - http::StatusCode, - response::Json, routing::{get, post}, }; use sqlx::PgPool; @@ -26,11 +20,7 @@ use tracing_subscriber; use crate::database::Database; use crate::errors::AppError; -use crate::models::{ - BulkSyncOutput, BulkSyncParams, ListRecordsOutput, ListRecordsParams, SmartSyncParams, -}; use crate::sync::SyncService; -use crate::web::WebService; #[derive(Clone)] pub struct Config { @@ -41,8 +31,6 @@ pub struct Config { pub struct AppState { database: Database, sync_service: SyncService, - #[allow(dead_code)] - web_service: WebService, config: Config, } @@ -67,52 +55,34 @@ async fn main() -> Result<(), AppError> { let database = Database::new(pool); let sync_service = SyncService::new(database.clone()); - let web_service = WebService::new(); let auth_base_url = env::var("AUTH_BASE_URL") .unwrap_or_else(|_| "https://auth.grainsocial.network".to_string()); - let config = Config { - auth_base_url, - }; + let config = Config { auth_base_url }; let state = AppState { database: database.clone(), sync_service, - web_service, config, }; // Build application with routes let app = Router::new() // XRPC endpoints - .route("/xrpc/social.slices.records.list", get(list_records)) - .route("/xrpc/social.slices.collections.bulkSync", post(bulk_sync)) - .route("/xrpc/social.slices.repos.smartSync", post(smart_sync)) + .route("/xrpc/social.slices.slice.sync", post(handler_sync::sync)) .route( - "/xrpc/social.slices.codegen.generate", + "/xrpc/social.slices.slice.codegen", post(handler_xrpc_codegen::generate_client_xrpc), ) // Dynamic collection-specific XRPC endpoints .route( "/xrpc/*method", - get(handler_dynamic_xrpc::dynamic_xrpc_handler), + get(handler_xrpc_dynamic::dynamic_xrpc_handler), ) .route( "/xrpc/*method", - post(handler_dynamic_xrpc::dynamic_xrpc_post_handler), - ) - // Web interface - .route("/", get(web::index)) - .route("/records", get(web::records_page)) - .route("/sync", get(web::sync_page)) - .route("/sync", post(web::bulk_sync_action)) - .route("/codegen", get(web::codegen_page)) - .route("/codegen/generate", post(handler_codegen::generate_client)) - .route("/lexicon", get(handler_lexicon::lexicon_page)) - .route( - "/upload-lexicons", - post(handler_upload_lexicon::upload_lexicons), + post(handler_xrpc_dynamic::dynamic_xrpc_post_handler), ) .layer(TraceLayer::new_for_http()) .layer(CorsLayer::permissive()) @@ -125,74 +95,4 @@ async fn main() -> Result<(), AppError> { Ok(()) } -async fn list_records( - State(state): State, - Query(params): Query, -) -> Result, StatusCode> { - match state.database.list_records(params).await { - Ok(records) => Ok(Json(ListRecordsOutput { - records, - cursor: None, // TODO: implement cursor pagination - })), - Err(_) => Err(StatusCode::INTERNAL_SERVER_ERROR), - } -} - -async fn bulk_sync( - State(state): State, - axum::extract::Json(params): axum::extract::Json, -) -> Result, StatusCode> { - match state - .sync_service - .backfill_collections(¶ms.collections, params.repos.as_deref()) - .await - { - Ok(_) => { - let total_records = state.database.get_total_record_count().await.unwrap_or(0); - Ok(Json(BulkSyncOutput { - success: true, - total_records, - collections_synced: params.collections, - repos_processed: params.repos.map(|r| r.len() as i64).unwrap_or(0), - message: "Bulk sync completed successfully".to_string(), - })) - } - Err(e) => Ok(Json(BulkSyncOutput { - success: false, - total_records: 0, - collections_synced: vec![], - repos_processed: 0, - message: format!("Bulk sync failed: {}", e), - })), - } -} -async fn smart_sync( - State(state): State, - axum::extract::Json(params): axum::extract::Json, -) -> Result, StatusCode> { - let collections = params.collections.as_deref(); - - match state.sync_service.sync_repo(¶ms.did, collections).await { - Ok(records_count) => { - let total_records = state.database.get_total_record_count().await.unwrap_or(0); - Ok(Json(BulkSyncOutput { - success: true, - total_records, - collections_synced: params.collections.unwrap_or_default(), - repos_processed: 1, - message: format!( - "Smart sync completed for {}: {} records", - params.did, records_count - ), - })) - } - Err(e) => Ok(Json(BulkSyncOutput { - success: false, - total_records: 0, - collections_synced: vec![], - repos_processed: 0, - message: format!("Smart sync failed for {}: {}", params.did, e), - })), - } -} diff --git a/api/src/models.rs b/api/src/models.rs index cb1ff1d..9bbde5c 100644 --- a/api/src/models.rs +++ b/api/src/models.rs @@ -14,34 +14,6 @@ pub struct Record { pub indexed_at: DateTime, } -#[derive(Debug, Serialize, Deserialize)] -pub struct CreateRecordInput { - pub collection: String, - pub repo: String, - pub rkey: Option, - pub record: Value, -} - -#[derive(Debug, Serialize, Deserialize)] -pub struct CreateRecordOutput { - pub uri: String, - pub cid: String, -} - -#[derive(Debug, Serialize, Deserialize)] -pub struct ListRecordsParams { - pub collection: String, - pub author: Option, - pub limit: Option, - pub cursor: Option, -} - -#[derive(Debug, Serialize, Deserialize)] -pub struct ListRecordsOutput { - pub records: Vec, - pub cursor: Option, -} - #[derive(Debug, Serialize, Deserialize)] pub struct IndexedRecord { pub uri: String, @@ -53,6 +25,11 @@ pub struct IndexedRecord { pub indexed_at: String, } +#[derive(Debug, Serialize, Deserialize)] +pub struct ListRecordsOutput { + pub records: Vec, + pub cursor: Option, +} #[derive(Debug, Serialize, Deserialize)] pub struct BulkSyncParams { @@ -70,22 +47,6 @@ pub struct BulkSyncOutput { pub message: String, } - -#[derive(Debug, Serialize, Deserialize)] -pub struct SmartSyncParams { - pub did: String, - pub collections: Option>, - pub force_full_sync: Option, -} - -#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)] -pub struct Lexicon { - pub nsid: String, - pub definitions: serde_json::Value, - pub created_at: DateTime, - pub updated_at: DateTime, -} - #[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)] pub struct Actor { pub did: String, diff --git a/api/src/sync.rs b/api/src/sync.rs index a8ebd37..ce18256 100644 --- a/api/src/sync.rs +++ b/api/src/sync.rs @@ -68,45 +68,6 @@ impl SyncService { } } - // Sync using listRecords - pub async fn sync_repo(&self, did: &str, collections: Option<&[String]>) -> Result { - info!("๐Ÿ”„ Starting sync for DID: {}", did); - - let total_records = self.listrecords_sync(did, collections).await?; - - info!("โœ… Sync completed for {}: {} records", did, total_records); - Ok(total_records) - } - - - // Sync using listRecords - async fn listrecords_sync(&self, did: &str, collections: Option<&[String]>) -> Result { - let collections_to_sync = match collections { - Some(cols) => cols, - None => return Ok(0), // No collections specified = no records - }; - - // Get ATP data for this single repo - let atp_map = self.get_atp_map_for_repos(&[did.to_string()]).await?; - - let mut total_records = 0; - for collection in collections_to_sync { - match self.fetch_records_for_repo_collection_with_atp_map(did, collection, &atp_map).await { - Ok(records) => { - if !records.is_empty() { - info!("๐Ÿ“‹ Fallback sync: {} records for {}/{}", records.len(), did, collection); - self.database.batch_insert_records(&records).await?; - total_records += records.len() as i64; - } - } - Err(e) => { - error!("Failed fallback sync for {}/{}: {}", did, collection, e); - } - } - } - - Ok(total_records) - } pub async fn backfill_collections(&self, collections: &[String], repos: Option<&[String]>) -> Result<(), SyncError> { diff --git a/api/src/web.rs b/api/src/web.rs deleted file mode 100644 index 3cdc1af..0000000 --- a/api/src/web.rs +++ /dev/null @@ -1,204 +0,0 @@ -use axum::{ - extract::{Query, State}, - http::StatusCode, - response::{Html, IntoResponse}, - Form, -}; -use minijinja::{context, Environment}; -use serde::Deserialize; -use std::collections::HashMap; - -use crate::models::ListRecordsParams; -use crate::AppState; - -#[derive(Clone)] -pub struct WebService { - #[allow(dead_code)] - env: Environment<'static>, -} - -impl WebService { - pub fn new() -> Self { - let mut env = Environment::new(); - - // Add base template - env.add_template("base.html", include_str!("../templates/base.html")).unwrap(); - env.add_template("index.html", include_str!("../templates/index.html")).unwrap(); - env.add_template("records.html", include_str!("../templates/records.html")).unwrap(); - env.add_template("sync.html", include_str!("../templates/sync.html")).unwrap(); - - Self { env } - } -} - -#[derive(Deserialize)] -pub struct BulkSyncForm { - collections: String, - repos: Option, -} - - -pub async fn index(State(state): State) -> Result { - let collections = state.database.get_available_collections().await.unwrap_or_default(); - let total_records = state.database.get_total_record_count().await.unwrap_or(0); - - let mut env = Environment::new(); - env.add_template("base.html", include_str!("../templates/base.html")).unwrap(); - env.add_template("index.html", include_str!("../templates/index.html")).unwrap(); - - let tmpl = env.get_template("index.html").unwrap(); - let rendered = tmpl.render(context! { - title => "AT Protocol Indexer", - collections => collections, - total_records => total_records - }).unwrap(); - - Ok(Html(rendered)) -} - -pub async fn records_page( - State(state): State, - Query(params): Query>, -) -> Result { - let collection = params.get("collection").cloned().unwrap_or_default(); - let author = params.get("author").cloned(); - - // Get available collections for the dropdown - let available_collections = state.database.get_available_collections().await.unwrap_or_default(); - - let records = if !collection.is_empty() { - let list_params = ListRecordsParams { - collection: collection.clone(), - author, - limit: Some(50), - cursor: None, - }; - let raw_records = state.database.list_records(list_params).await.unwrap_or_default(); - - // Transform records to include pretty-printed JSON - raw_records.into_iter().map(|record| { - let pretty_json = serde_json::to_string_pretty(&record.value).unwrap_or_else(|_| record.value.to_string()); - serde_json::json!({ - "uri": record.uri, - "cid": record.cid, - "did": record.did, - "collection": record.collection, - "value": record.value, - "pretty_value": pretty_json, - "indexed_at": record.indexed_at - }) - }).collect() - } else { - Vec::new() - }; - - let mut env = Environment::new(); - env.add_template("base.html", include_str!("../templates/base.html")).unwrap(); - env.add_template("records.html", include_str!("../templates/records.html")).unwrap(); - - let tmpl = env.get_template("records.html").unwrap(); - let rendered = tmpl.render(context! { - title => "Records", - records => records, - collection => collection, - available_collections => available_collections - }).unwrap(); - - Ok(Html(rendered)) -} - -pub async fn codegen_page(State(state): State) -> Result { - // Get stored lexicons for the UI - let lexicons = match state.database.get_all_lexicons().await { - Ok(lexicons) => lexicons, - Err(_) => Vec::new(), - }; - - let mut env = Environment::new(); - env.add_template("base.html", include_str!("../templates/base.html")).unwrap(); - env.add_template("codegen.html", include_str!("../templates/codegen.html")).unwrap(); - - let tmpl = env.get_template("codegen.html").unwrap(); - let rendered = tmpl.render(context! { - title => "Client Code Generation", - lexicons => lexicons - }).unwrap(); - - Ok(Html(rendered)) -} - -pub async fn sync_page() -> impl IntoResponse { - let mut env = Environment::new(); - env.add_template("base.html", include_str!("../templates/base.html")).unwrap(); - env.add_template("sync.html", include_str!("../templates/sync.html")).unwrap(); - - let tmpl = env.get_template("sync.html").unwrap(); - let rendered = tmpl.render(context! { - title => "Sync Records" - }).unwrap(); - - Html(rendered) -} - -pub async fn bulk_sync_action( - State(state): State, - Form(form): Form, -) -> Result { - // Parse collections from comma-separated string - let collections: Vec = form.collections - .split(',') - .map(|s| s.trim().to_string()) - .filter(|s| !s.is_empty()) - .collect(); - - // Parse repos from newline-separated string if provided - let repos = form.repos - .filter(|s| !s.trim().is_empty()) - .map(|s| s.lines() - .map(|line| line.trim().to_string()) - .filter(|line| !line.is_empty()) - .collect::>()); - - if collections.is_empty() { - return Ok(Html(r#" -
-

โŒ No collections specified

-

Please specify at least one collection to sync.

-
- "#.to_string())); - } - - match state.sync_service.backfill_collections(&collections, repos.as_deref()).await { - Ok(_) => { - let total_records = state.database.get_total_record_count().await.unwrap_or(0); - - let mut env = Environment::new(); - env.add_template("sync_result.html", r#" -
-

โœ… Bulk sync completed successfully!

-

Collections synced: {{ collections|join(", ") }}

-

Total records in database: {{ total_records }}

-

Operation: {{ message }}

-
- "#).unwrap(); - - let tmpl = env.get_template("sync_result.html").unwrap(); - let rendered = tmpl.render(context! { - collections => collections, - total_records => total_records, - message => "Bulk sync operation completed" - }).unwrap(); - - Ok(Html(rendered)) - }, - Err(e) => { - Ok(Html(format!(r#" -
-

โŒ Bulk sync failed

-

Error: {}

-
- "#, e))) - } - } -} - diff --git a/api/templates/base.html b/api/templates/base.html deleted file mode 100644 index 4d87772..0000000 --- a/api/templates/base.html +++ /dev/null @@ -1,70 +0,0 @@ - - - - - - - {{ title }} - AT Protocol Indexer - - - - - - - - - -
- {% block content %}{% endblock %} -
- - - \ No newline at end of file diff --git a/api/templates/codegen.html b/api/templates/codegen.html deleted file mode 100644 index c1d8472..0000000 --- a/api/templates/codegen.html +++ /dev/null @@ -1,82 +0,0 @@ -{% extends "base.html" %} - -{% block content %} -
-

Client Code Generation

- -
-

Generate Typed Clients

-

Generate TypeScript clients for interacting with XRPC APIs based on stored lexicon definitions.

- -
-
- - -
- -
- - -
- -
- -
- {% if lexicons %} - {% for lexicon in lexicons %} -
- - -
- {% endfor %} - {% else %} -

No lexicons available. Upload some lexicon files first.

- {% endif %} -
-
- - -
- -
-
- -
-

๐Ÿ’ก About Client Generation

-
    -
  • - โ€ข - Generates typed TypeScript clients for interacting with XRPC APIs -
  • -
  • - โ€ข - Based on stored lexicon definitions with full type safety -
  • -
  • - โ€ข - Includes interfaces for records, queries, and procedures -
  • -
  • - โ€ข - Compatible with Deno and modern TypeScript environments -
  • -
-
-
-{% endblock %} \ No newline at end of file diff --git a/api/templates/index.html b/api/templates/index.html deleted file mode 100644 index 0b23e5c..0000000 --- a/api/templates/index.html +++ /dev/null @@ -1,94 +0,0 @@ -{% extends "base.html" %} - -{% block content %} -
-

AT Protocol Indexer

- - {% if total_records > 0 %} -
-

๐Ÿ“Š Database Status

-

Currently indexing {{ total_records }} records across {{ - collections|length }} collections.

-
- {% endif %} - -
-
-

๐Ÿ“ View Records

-

Browse indexed AT Protocol records by collection.

- {% if collections %} - - Browse Records - - {% else %} -

No records synced yet. Start by syncing some records!

- {% endif %} -
- -
-

๐Ÿ“š Lexicon Definitions

-

View lexicon definitions and schemas.

- - View Lexicons - -
- -
-

โšก Code Generation

-

Generate TypeScript client from your lexicon definitions.

- - Generate Client - -
- -
-

๐Ÿ”„ Bulk Sync

-

Sync entire collections from AT Protocol networks.

- - Start Bulk Sync - -
- - {% if collections %} -
-

๐Ÿ“Š Synced Collections

-

Collections currently indexed in the database.

-
- {% for collection in collections %} - - {{ collection[0] }} - {{ collection[1] }} - - {% endfor %} -
-
- {% else %} -
-

๐ŸŒŸ Get Started

-

No records indexed yet. Start by syncing some AT Protocol collections!

-
-

Try syncing collections like:

- app.bsky.feed.post - app.bsky.actor.profile -
-
- {% endif %} -
- -
-

API Endpoints

-
-
- GET /xrpc/social.slices.records.list?collection=app.bsky.feed.post -

List records for a collection

-
-
- POST /xrpc/social.slices.collections.bulkSync -

Bulk sync collections (JSON: {"collections": ["app.bsky.feed.post"]})

-
-
-
-
-{% endblock %} \ No newline at end of file diff --git a/api/templates/lexicon.html b/api/templates/lexicon.html deleted file mode 100644 index 5c242da..0000000 --- a/api/templates/lexicon.html +++ /dev/null @@ -1,49 +0,0 @@ -{% extends "base.html" %} - -{% block content %} -
-

Lexicon Definitions

- - {% if lexicons %} -
-
-

Found {{ lexicons|length }} lexicon definitions

-
-
- {% for lexicon in lexicons %} -
-
-

{{ lexicon.nsid }}

- Updated: {{ lexicon.updated_at }} -
- -
-
- View Definitions -
- {% if lexicon.pretty_definitions %} -
{{ lexicon.pretty_definitions }}
- {% else %} -

No definitions found

- {% endif %} -
-
-
-
- {% endfor %} -
-
- {% else %} -
-
๐Ÿ“š
-

No lexicon definitions found

-

- Upload lexicon files to see their definitions here. -

- - Upload Lexicons - -
- {% endif %} -
-{% endblock %} \ No newline at end of file diff --git a/api/templates/records.html b/api/templates/records.html deleted file mode 100644 index c1ac600..0000000 --- a/api/templates/records.html +++ /dev/null @@ -1,98 +0,0 @@ -{% extends "base.html" %} - -{% block content %} -
-

Records

- -
-
-

Filter Records

-
-
-
- - -
-
- - -
-
- -
-
-
- - {% if records %} -
-
-

Found {{ records|length }} records

-
-
- {% for record in records %} -
-
-

{{ record.uri }}

- {{ record.indexed_at }} -
-
-
- Collection: - {{ record.collection }} -
-
- Author: - {{ record.did }} -
-
- CID: - {{ record.cid }} -
-
- {% if record.value %} -
-
- View Record Data -
{{ record.pretty_value }}
-
-
- {% endif %} -
- {% endfor %} -
-
- {% else %} -
-
๐Ÿ“
-

No records found

-

- {% if collection %} - No records found for collection "{{ collection }}". - {% elif available_collections %} - Select a collection from the dropdown above to view records. - {% else %} - No records have been synced yet. Start by syncing some AT Protocol records! - {% endif %} -

- - Sync Some Records - -
- {% endif %} -
-{% endblock %} \ No newline at end of file diff --git a/api/templates/sync.html b/api/templates/sync.html deleted file mode 100644 index 9877a51..0000000 --- a/api/templates/sync.html +++ /dev/null @@ -1,122 +0,0 @@ -{% extends "base.html" %} - -{% block content %} -
-

Sync Records

- -
- -
-

๐Ÿ“ฆ Upload Lexicon Files

-

Upload a zip file containing lexicon JSON files to automatically populate collections for syncing.

- -
-
- - -

- Upload a zip file containing lexicon JSON files. Record definitions will be extracted automatically. -

-
- - -
- -
-
- - -
-

๐Ÿ“š Bulk Sync Collections

-

Sync multiple records from AT Protocol collections. This will fetch records from across the network.

- -
-
- - -

- Enter collection names separated by commas. Common collections: app.bsky.feed.post, app.bsky.actor.profile, app.bsky.feed.like -

-
- -
- - -

Optional: Enter specific DIDs (one per line). Leave empty to discover and sync from all repositories that have the specified collections.

-
- -
-
-
- โš ๏ธ -
-
-

Note about bulk sync

-
-

Bulk sync can take several minutes and may fetch thousands of records. The operation runs in the foreground, so please be patient.

-
-
-
-
- - -
- -
-
-
- -
-

๐Ÿ’ก Tips for Bulk Sync

-
    -
  • - โ€ข - Start with popular collections like app.bsky.feed.post for posts -
  • -
  • - โ€ข - Leave DIDs empty to discover all repositories automatically -
  • -
  • - โ€ข - Use the API endpoint for programmatic access: /xrpc/social.slices.collections.bulkSync -
  • -
  • - โ€ข - Sync operations may take time - progress is logged to the server console -
  • -
-
-
-{% endblock %} \ No newline at end of file diff --git a/frontend/src/client.ts b/frontend/src/client.ts index de15c30..2f91cba 100644 --- a/frontend/src/client.ts +++ b/frontend/src/client.ts @@ -1,5 +1,5 @@ // Generated TypeScript client for AT Protocol records -// Generated at: 2025-08-21 22:15:58 UTC +// Generated at: 2025-08-22 22:17:55 UTC // Lexicons: 2 export interface OAuthAuthorizeParams { @@ -73,24 +73,24 @@ export interface CollectionOperations { getRecord(params: GetRecordParams): Promise>; } -export interface XyzSliceatLexiconRecord { - /** When the lexicon was created */ - createdAt: string; - /** The lexicon schema definitions as JSON */ - definitions: string; +export interface SocialSlicesLexiconRecord { /** Namespaced identifier for the lexicon */ nsid: string; - /** AT-URI reference to the slice this lexicon belongs to */ - slice: string; + /** The lexicon schema definitions as JSON */ + definitions: string; + /** When the lexicon was created */ + createdAt: string; /** When the lexicon was last updated */ updatedAt?: string; + /** AT-URI reference to the slice this lexicon belongs to */ + slice: string; } -export interface XyzSliceatSliceRecord { - /** When the slice was created */ - createdAt: string; +export interface SocialSlicesSliceRecord { /** Name of the slice */ name: string; + /** When the slice was created */ + createdAt: string; } export class PKCEUtils { @@ -434,7 +434,7 @@ class OAuthClient extends BaseClient { } } -class LexiconSliceatXyzClient extends BaseClient { +class LexiconSlicesSocialClient extends BaseClient { constructor( baseUrl: string, authBaseUrl: string, @@ -446,22 +446,22 @@ class LexiconSliceatXyzClient extends BaseClient { async listRecords( params?: ListRecordsParams - ): Promise> { - return await this.makeRequest("xyz.sliceat.lexicon.list", "GET", params); + ): Promise> { + return await this.makeRequest("social.slices.lexicon.list", "GET", params); } async getRecord( params: GetRecordParams - ): Promise> { - return await this.makeRequest("xyz.sliceat.lexicon.get", "GET", params); + ): Promise> { + return await this.makeRequest("social.slices.lexicon.get", "GET", params); } async createRecord( - record: XyzSliceatLexiconRecord + record: SocialSlicesLexiconRecord ): Promise<{ uri: string; cid: string }> { - const recordWithType = { $type: "xyz.sliceat.lexicon", ...record }; + const recordWithType = { $type: "social.slices.lexicon", ...record }; return await this.makeRequest( - "xyz.sliceat.lexicon.create", + "social.slices.lexicon.create", "POST", recordWithType ); @@ -469,23 +469,23 @@ class LexiconSliceatXyzClient extends BaseClient { async updateRecord( rkey: string, - record: XyzSliceatLexiconRecord + record: SocialSlicesLexiconRecord ): Promise<{ uri: string; cid: string }> { - const recordWithType = { $type: "xyz.sliceat.lexicon", ...record }; - return await this.makeRequest("xyz.sliceat.lexicon.update", "POST", { + const recordWithType = { $type: "social.slices.lexicon", ...record }; + return await this.makeRequest("social.slices.lexicon.update", "POST", { rkey, record: recordWithType, }); } async deleteRecord(rkey: string): Promise { - return await this.makeRequest("xyz.sliceat.lexicon.delete", "POST", { + return await this.makeRequest("social.slices.lexicon.delete", "POST", { rkey, }); } } -class SliceSliceatXyzClient extends BaseClient { +class SliceSlicesSocialClient extends BaseClient { constructor( baseUrl: string, authBaseUrl: string, @@ -497,22 +497,22 @@ class SliceSliceatXyzClient extends BaseClient { async listRecords( params?: ListRecordsParams - ): Promise> { - return await this.makeRequest("xyz.sliceat.slice.list", "GET", params); + ): Promise> { + return await this.makeRequest("social.slices.slice.list", "GET", params); } async getRecord( params: GetRecordParams - ): Promise> { - return await this.makeRequest("xyz.sliceat.slice.get", "GET", params); + ): Promise> { + return await this.makeRequest("social.slices.slice.get", "GET", params); } async createRecord( - record: XyzSliceatSliceRecord + record: SocialSlicesSliceRecord ): Promise<{ uri: string; cid: string }> { - const recordWithType = { $type: "xyz.sliceat.slice", ...record }; + const recordWithType = { $type: "social.slices.slice", ...record }; return await this.makeRequest( - "xyz.sliceat.slice.create", + "social.slices.slice.create", "POST", recordWithType ); @@ -520,23 +520,25 @@ class SliceSliceatXyzClient extends BaseClient { async updateRecord( rkey: string, - record: XyzSliceatSliceRecord + record: SocialSlicesSliceRecord ): Promise<{ uri: string; cid: string }> { - const recordWithType = { $type: "xyz.sliceat.slice", ...record }; - return await this.makeRequest("xyz.sliceat.slice.update", "POST", { + const recordWithType = { $type: "social.slices.slice", ...record }; + return await this.makeRequest("social.slices.slice.update", "POST", { rkey, record: recordWithType, }); } async deleteRecord(rkey: string): Promise { - return await this.makeRequest("xyz.sliceat.slice.delete", "POST", { rkey }); + return await this.makeRequest("social.slices.slice.delete", "POST", { + rkey, + }); } } -class SliceatXyzClient extends BaseClient { - readonly lexicon: LexiconSliceatXyzClient; - readonly slice: SliceSliceatXyzClient; +class SlicesSocialClient extends BaseClient { + readonly lexicon: LexiconSlicesSocialClient; + readonly slice: SliceSlicesSocialClient; constructor( baseUrl: string, @@ -545,13 +547,13 @@ class SliceatXyzClient extends BaseClient { clientSecret: string ) { super(baseUrl, authBaseUrl, clientId, clientSecret); - this.lexicon = new LexiconSliceatXyzClient( + this.lexicon = new LexiconSlicesSocialClient( baseUrl, authBaseUrl, clientId, clientSecret ); - this.slice = new SliceSliceatXyzClient( + this.slice = new SliceSlicesSocialClient( baseUrl, authBaseUrl, clientId, @@ -560,8 +562,8 @@ class SliceatXyzClient extends BaseClient { } } -class XyzClient extends BaseClient { - readonly sliceat: SliceatXyzClient; +class SocialClient extends BaseClient { + readonly slices: SlicesSocialClient; constructor( baseUrl: string, @@ -570,7 +572,7 @@ class XyzClient extends BaseClient { clientSecret: string ) { super(baseUrl, authBaseUrl, clientId, clientSecret); - this.sliceat = new SliceatXyzClient( + this.slices = new SlicesSocialClient( baseUrl, authBaseUrl, clientId, @@ -580,7 +582,7 @@ class XyzClient extends BaseClient { } export class AtProtoClient extends BaseClient { - readonly xyz: XyzClient; + readonly social: SocialClient; readonly oauth: OAuthClient; constructor( @@ -590,7 +592,12 @@ export class AtProtoClient extends BaseClient { clientSecret: string ) { super(baseUrl, authBaseUrl, clientId, clientSecret); - this.xyz = new XyzClient(baseUrl, authBaseUrl, clientId, clientSecret); + this.social = new SocialClient( + baseUrl, + authBaseUrl, + clientId, + clientSecret + ); this.oauth = new OAuthClient(baseUrl, authBaseUrl, clientId, clientSecret); } } diff --git a/frontend/src/pages/SliceLexiconPage.tsx b/frontend/src/pages/SliceLexiconPage.tsx index cc5b231..d08f95a 100644 --- a/frontend/src/pages/SliceLexiconPage.tsx +++ b/frontend/src/pages/SliceLexiconPage.tsx @@ -26,15 +26,10 @@ export function SliceLexiconPage({
- + โ† Back to Slices -

- {sliceName} -

+

{sliceName}

@@ -82,7 +77,7 @@ export function SliceLexiconPage({ className="block w-full border border-gray-300 rounded-md px-3 py-2 font-mono text-sm" placeholder={`{ "lexicon": 1, - "id": "xyz.sliceat.example", + "id": "social.slices.example", "description": "Example record type", "defs": { "main": { @@ -119,7 +114,7 @@ export function SliceLexiconPage({ Add Lexicon - +
@@ -128,7 +123,8 @@ export function SliceLexiconPage({ Upload Lexicon Files

- Or upload lexicon schema files to define custom record types for this slice. + Or upload lexicon schema files to define custom record types for + this slice.

- Upload a ZIP file containing lexicon definitions or a single JSON file + Upload a ZIP file containing lexicon definitions or a single + JSON file

@@ -167,8 +164,8 @@ export function SliceLexiconPage({ Slice Lexicons -
); -} \ No newline at end of file +} diff --git a/frontend/src/routes/pages.tsx b/frontend/src/routes/pages.tsx index 99e514c..69cb09a 100644 --- a/frontend/src/routes/pages.tsx +++ b/frontend/src/routes/pages.tsx @@ -10,6 +10,7 @@ import { SliceSyncPage } from "../pages/SliceSyncPage.tsx"; import { SliceLexiconPage } from "../pages/SliceLexiconPage.tsx"; import { SliceCodegenPage } from "../pages/SliceCodegenPage.tsx"; import { SliceSettingsPage } from "../pages/SliceSettingsPage.tsx"; +import { buildAtUri } from "../utils/at-uri.ts"; async function handleIndexPage(req: Request): Promise { const context = await withAuth(req); @@ -19,7 +20,8 @@ async function handleIndexPage(req: Request): Promise { if (context.currentUser.isAuthenticated) { try { - const sliceRecords = await atprotoClient.xyz.sliceat.slice.listRecords(); + const sliceRecords = + await atprotoClient.social.slices.slice.listRecords(); slices = sliceRecords.records.map((record) => { // Extract slice ID from URI @@ -105,9 +107,9 @@ async function handleSlicePage( // Construct the full URI for this slice const sliceUri = `at://${ context.currentUser.sub || "unknown" - }/xyz.sliceat.slice/${sliceId}`; + }/social.slices.slice/${sliceId}`; - const sliceRecord = await atprotoClient.xyz.sliceat.slice.getRecord({ + const sliceRecord = await atprotoClient.social.slices.slice.getRecord({ uri: sliceUri, }); @@ -115,9 +117,9 @@ async function handleSlicePage( sliceId, sliceName: sliceRecord.value.name, totalRecords: 1, // For now, just showing this slice - collections: [{ name: "xyz.sliceat.slice", count: 1 }], + collections: [{ name: "social.slices.slice", count: 1 }], }; - } catch (error) { + } catch (_error) { // Fall back to default data } } @@ -167,11 +169,13 @@ async function handleSliceTabPage( if (context.currentUser.isAuthenticated) { try { // Construct the full URI for this slice - const sliceUri = `at://${ - context.currentUser.sub || "unknown" - }/xyz.sliceat.slice/${sliceId}`; + const sliceUri = buildAtUri({ + did: context.currentUser.sub ?? "unknown", + collection: "social.slices.slice", + rkey: sliceId, + }); - const sliceRecord = await atprotoClient.xyz.sliceat.slice.getRecord({ + const sliceRecord = await atprotoClient.social.slices.slice.getRecord({ uri: sliceUri, }); @@ -179,7 +183,7 @@ async function handleSliceTabPage( sliceId, sliceName: sliceRecord.value.name, totalRecords: 1, // For now, just showing this slice - collections: [{ name: "xyz.sliceat.slice", count: 1 }], + collections: [{ name: "social.slices.slice", count: 1 }], }; } catch (error) { console.error("Failed to fetch slice:", error); diff --git a/frontend/src/routes/slices.tsx b/frontend/src/routes/slices.tsx index 8dcabac..8c3378a 100644 --- a/frontend/src/routes/slices.tsx +++ b/frontend/src/routes/slices.tsx @@ -42,12 +42,12 @@ async function handleCreateSlice(req: Request): Promise { // Create actual slice using AT Protocol try { - const result = await atprotoClient.xyz.sliceat.slice.createRecord({ + const result = await atprotoClient.social.slices.slice.createRecord({ name: name.trim(), createdAt: new Date().toISOString(), }); - // Extract record key from URI (format: at://did:plc:example/xyz.sliceat.slice/rkey) + // Extract record key from URI (format: at://did:plc:example/social.slices.slice/rkey) const uriParts = result.uri.split("/"); const sliceId = uriParts[uriParts.length - 1]; @@ -108,10 +108,10 @@ async function handleUpdateSliceName( } // Construct the URI for this slice - const sliceUri = `at://${context.currentUser.sub}/xyz.sliceat.slice/${sliceId}`; + const sliceUri = `at://${context.currentUser.sub}/social.slices.slice/${sliceId}`; // Get the current record first - const currentRecord = await atprotoClient.xyz.sliceat.slice.getRecord({ + const currentRecord = await atprotoClient.social.slices.slice.getRecord({ uri: sliceUri, }); @@ -121,7 +121,10 @@ async function handleUpdateSliceName( name: name.trim(), }; - await atprotoClient.xyz.sliceat.slice.updateRecord(sliceId, updatedRecord); + await atprotoClient.social.slices.slice.updateRecord( + sliceId, + updatedRecord + ); const resultHtml = render( { +async function handleDeleteSlice( + req: Request, + params?: URLPatternResult +): Promise { const context = await withAuth(req); const authResponse = requireAuth(context, req); if (authResponse) return authResponse; @@ -160,7 +166,7 @@ async function handleDeleteSlice(req: Request, params?: URLPatternResult): Promi try { // Delete the slice record from AT Protocol - await atprotoClient.xyz.sliceat.slice.deleteRecord(sliceId); + await atprotoClient.social.slices.slice.deleteRecord(sliceId); // Redirect to home page return new Response("", { @@ -182,7 +188,7 @@ async function handleListLexicons(req: Request): Promise { try { // Fetch lexicons from AT Protocol const lexiconRecords = - await atprotoClient.xyz.sliceat.lexicon.listRecords(); + await atprotoClient.social.slices.lexicon.listRecords(); if (lexiconRecords.records.length === 0) { const html = render(); @@ -261,7 +267,7 @@ async function handleCreateLexicon(req: Request): Promise { // Create the lexicon record try { // For now, we'll create a simple slice reference - this could be improved to reference a specific slice - const sliceUri = `at://${context.currentUser.sub}/xyz.sliceat.slice/example`; + const sliceUri = `at://${context.currentUser.sub}/social.slices.slice/example`; const lexiconRecord = { nsid: lexiconData.id, @@ -270,7 +276,7 @@ async function handleCreateLexicon(req: Request): Promise { slice: sliceUri, }; - const result = await atprotoClient.xyz.sliceat.lexicon.createRecord( + const result = await atprotoClient.social.slices.lexicon.createRecord( lexiconRecord ); @@ -318,11 +324,11 @@ async function handleDeleteLexicon( try { // Delete the lexicon record from AT Protocol - await atprotoClient.xyz.sliceat.lexicon.deleteRecord(rkey); + await atprotoClient.social.slices.lexicon.deleteRecord(rkey); // Check if there are any remaining lexicons const remainingLexicons = - await atprotoClient.xyz.sliceat.lexicon.listRecords(); + await atprotoClient.social.slices.lexicon.listRecords(); if (remainingLexicons.records.length === 0) { // If no lexicons remain, return the empty state and target the parent list