Highly ambitious ATProtocol AppView service and sdks

add clearSliceRecords procedure to empty the index for a slice, add cascade delete funciton to clean up slice records on delete and on lexicon delete, add button to slice settings page

Changed files
+402 -29
api
frontend
src
features
slices
lexicon
templates
fragments
settings
lexicons
network
slices
packages
+6
api/src/api/xrpc_dynamic.rs
··· 778 778 779 779 // Also delete from local database (from all slices) 780 780 let uri = format!("at://{}/{}/{}", repo, collection, rkey); 781 + 782 + // Handle cascade deletion before deleting the record 783 + if let Err(e) = state.database.handle_cascade_deletion(&uri, &collection).await { 784 + tracing::warn!("Cascade deletion failed for {}: {}", uri, e); 785 + } 786 + 781 787 let _ = state.database.delete_record_by_uri(&uri, None).await; 782 788 783 789 Ok(Json(serde_json::json!({})))
+27
api/src/database/actors.rs
··· 224 224 .await?; 225 225 Ok(result.rows_affected()) 226 226 } 227 + 228 + /// Deletes all actors for a specific slice. 229 + /// 230 + /// This is a destructive operation that removes all tracked actors 231 + /// from the specified slice. Actors will be recreated when records 232 + /// are re-indexed during sync. 233 + /// 234 + /// # Arguments 235 + /// * `slice_uri` - AT-URI of the slice to clear 236 + /// 237 + /// # Returns 238 + /// Number of actors deleted 239 + pub async fn delete_all_actors_for_slice( 240 + &self, 241 + slice_uri: &str, 242 + ) -> Result<u64, DatabaseError> { 243 + let result = sqlx::query!( 244 + r#" 245 + DELETE FROM actor 246 + WHERE slice_uri = $1 247 + "#, 248 + slice_uri 249 + ) 250 + .execute(&self.pool) 251 + .await?; 252 + Ok(result.rows_affected()) 253 + } 227 254 } 228 255 229 256 /// Builds WHERE conditions specifically for actor queries.
+96
api/src/database/records.rs
··· 481 481 Ok(result.rows_affected()) 482 482 } 483 483 484 + /// Deletes all records for a specific slice. 485 + /// 486 + /// This is a destructive operation that removes all indexed records 487 + /// from the specified slice. Records can be recovered by re-syncing. 488 + /// 489 + /// # Arguments 490 + /// * `slice_uri` - AT-URI of the slice to clear 491 + /// 492 + /// # Returns 493 + /// Number of records deleted 494 + pub async fn delete_all_records_for_slice( 495 + &self, 496 + slice_uri: &str, 497 + ) -> Result<u64, DatabaseError> { 498 + let result = sqlx::query("DELETE FROM record WHERE slice_uri = $1") 499 + .bind(slice_uri) 500 + .execute(&self.pool) 501 + .await?; 502 + Ok(result.rows_affected()) 503 + } 504 + 505 + /// Deletes all records of a specific collection from a slice. 506 + /// 507 + /// Used when a lexicon is deleted to clean up all records of that type. 508 + /// 509 + /// # Arguments 510 + /// * `slice_uri` - AT-URI of the slice 511 + /// * `collection` - Collection name (NSID) to delete 512 + /// 513 + /// # Returns 514 + /// Number of records deleted 515 + pub async fn delete_records_by_collection( 516 + &self, 517 + slice_uri: &str, 518 + collection: &str, 519 + ) -> Result<u64, DatabaseError> { 520 + let result = sqlx::query("DELETE FROM record WHERE slice_uri = $1 AND collection = $2") 521 + .bind(slice_uri) 522 + .bind(collection) 523 + .execute(&self.pool) 524 + .await?; 525 + Ok(result.rows_affected()) 526 + } 527 + 528 + /// Handles cascade deletion based on record type. 529 + /// 530 + /// When certain records are deleted, related data should be cleaned up: 531 + /// - Lexicon deletion: removes all records of that collection type 532 + /// - Slice deletion: removes all records and actors for that slice 533 + /// 534 + /// # Arguments 535 + /// * `uri` - AT-URI of the deleted record 536 + /// * `collection` - Collection name (e.g., "network.slices.lexicon") 537 + pub async fn handle_cascade_deletion(&self, uri: &str, collection: &str) -> Result<(), DatabaseError> { 538 + match collection { 539 + "network.slices.lexicon" => { 540 + // Get the lexicon record to extract collection name and slice URI 541 + if let Ok(Some(lexicon_record)) = self.get_record(uri).await 542 + && let (Some(nsid), Some(slice_uri_from_record)) = ( 543 + lexicon_record.value.get("nsid").and_then(|v| v.as_str()), 544 + lexicon_record.value.get("slice").and_then(|v| v.as_str()) 545 + ) 546 + { 547 + // Delete all records of this collection type from the slice 548 + let deleted = self.delete_records_by_collection(slice_uri_from_record, nsid).await?; 549 + tracing::info!( 550 + "Cascade delete: removed {} records of collection {} from slice {}", 551 + deleted, nsid, slice_uri_from_record 552 + ); 553 + } 554 + } 555 + "network.slices.slice" => { 556 + // The URI itself is the slice URI 557 + let slice_uri = uri; 558 + 559 + // Delete all records for this slice 560 + let records_deleted = self.delete_all_records_for_slice(slice_uri).await?; 561 + tracing::info!( 562 + "Cascade delete: removed {} records from slice {}", 563 + records_deleted, slice_uri 564 + ); 565 + 566 + // Delete all actors for this slice 567 + let actors_deleted = super::client::Database::delete_all_actors_for_slice(self, slice_uri).await?; 568 + tracing::info!( 569 + "Cascade delete: removed {} actors from slice {}", 570 + actors_deleted, slice_uri 571 + ); 572 + } 573 + _ => { 574 + // No cascade deletion needed for other collections 575 + } 576 + } 577 + Ok(()) 578 + } 579 + 484 580 /// Inserts or updates a record atomically. 485 581 /// 486 582 /// # Returns
+5
api/src/jetstream.rs
··· 622 622 return Ok(()); 623 623 } 624 624 625 + // Handle cascade deletion before deleting the record 626 + if let Err(e) = self.database.handle_cascade_deletion(&uri, &commit.collection).await { 627 + warn!("Cascade deletion failed for {}: {}", uri, e); 628 + } 629 + 625 630 // Delete the record and log only for relevant slices 626 631 match self.database.delete_record_by_uri(&uri, None).await { 627 632 Ok(rows_affected) => {
+4
api/src/main.rs
··· 319 319 post(xrpc::network::slices::slice::sync_user_collections::handler), 320 320 ) 321 321 .route( 322 + "/xrpc/network.slices.slice.clearSliceRecords", 323 + post(xrpc::network::slices::slice::clear_slice_records::handler), 324 + ) 325 + .route( 322 326 "/xrpc/network.slices.slice.getJobStatus", 323 327 get(xrpc::network::slices::slice::get_job_status::handler), 324 328 )
+57
api/src/xrpc/network/slices/slice/clear_slice_records.rs
··· 1 + use crate::{auth, errors::AppError, AppState}; 2 + use axum::{extract::State, http::HeaderMap, response::Json}; 3 + use serde::{Deserialize, Serialize}; 4 + 5 + #[derive(Debug, Deserialize)] 6 + #[serde(rename_all = "camelCase")] 7 + pub struct Params { 8 + pub slice: String, 9 + } 10 + 11 + #[derive(Debug, Serialize)] 12 + #[serde(rename_all = "camelCase")] 13 + pub struct Output { 14 + pub message: String, 15 + } 16 + 17 + pub async fn handler( 18 + State(state): State<AppState>, 19 + headers: HeaderMap, 20 + Json(params): Json<Params>, 21 + ) -> Result<Json<Output>, AppError> { 22 + let token = auth::extract_bearer_token(&headers)?; 23 + let user_info = auth::verify_oauth_token_cached( 24 + &token, 25 + &state.config.auth_base_url, 26 + Some(state.auth_cache.clone()), 27 + ) 28 + .await?; 29 + 30 + let user_did = user_info.sub; 31 + let slice_uri = params.slice; 32 + 33 + if !slice_uri.starts_with(&format!("at://{}/", user_did)) { 34 + return Err(AppError::Forbidden( 35 + "You do not have permission to clear this slice".to_string(), 36 + )); 37 + } 38 + 39 + let records_deleted = state 40 + .database 41 + .delete_all_records_for_slice(&slice_uri) 42 + .await 43 + .map_err(|e| AppError::Internal(format!("Failed to delete records: {}", e)))?; 44 + 45 + let actors_deleted = state 46 + .database 47 + .delete_all_actors_for_slice(&slice_uri) 48 + .await 49 + .map_err(|e| AppError::Internal(format!("Failed to delete actors: {}", e)))?; 50 + 51 + Ok(Json(Output { 52 + message: format!( 53 + "Slice index cleared successfully. Deleted {} records and {} actors.", 54 + records_deleted, actors_deleted 55 + ), 56 + })) 57 + }
+1
api/src/xrpc/network/slices/slice/mod.rs
··· 1 + pub mod clear_slice_records; 1 2 pub mod create_oauth_client; 2 3 pub mod delete_oauth_client; 3 4 pub mod get_actors;
+18 -2
frontend/src/client.ts
··· 1 1 // Generated TypeScript client for AT Protocol records 2 - // Generated at: 2025-09-28 00:41:14 UTC 3 - // Lexicons: 40 2 + // Generated at: 2025-09-28 17:28:31 UTC 3 + // Lexicons: 41 4 4 5 5 /** 6 6 * @example Usage ··· 1117 1117 sliceUri: string; 1118 1118 /** When this actor was indexed */ 1119 1119 indexedAt: string; 1120 + } 1121 + 1122 + export interface NetworkSlicesSliceClearSliceRecordsInput { 1123 + slice: string; 1124 + } 1125 + 1126 + export interface NetworkSlicesSliceClearSliceRecordsOutput { 1127 + message: string; 1120 1128 } 1121 1129 1122 1130 export interface NetworkSlicesSliceDeleteOAuthClientInput { ··· 2252 2260 "POST", 2253 2261 input, 2254 2262 ); 2263 + } 2264 + 2265 + async clearSliceRecords( 2266 + input: NetworkSlicesSliceClearSliceRecordsInput, 2267 + ): Promise<NetworkSlicesSliceClearSliceRecordsOutput> { 2268 + return await this.client.makeRequest< 2269 + NetworkSlicesSliceClearSliceRecordsOutput 2270 + >("network.slices.slice.clearSliceRecords", "POST", input); 2255 2271 } 2256 2272 2257 2273 async deleteOAuthClient(
+1 -1
frontend/src/features/slices/lexicon/templates/fragments/LexiconsList.tsx
··· 78 78 hx-target="#lexicon-list" 79 79 hx-swap="outerHTML" 80 80 hx-include="input[name='lexicon_rkey']:checked" 81 - hx-confirm="Are you sure you want to delete the selected lexicons?" 81 + hx-confirm="Are you sure you want to delete the selected lexicons? This will also delete all records of those collection types from the index." 82 82 > 83 83 <span className="flex items-center">Delete Selected</span> 84 84 </Button>
+82 -9
frontend/src/features/slices/settings/handlers.tsx
··· 13 13 14 14 async function handleSliceSettingsPage( 15 15 req: Request, 16 - params?: URLPatternResult, 16 + params?: URLPatternResult 17 17 ): Promise<Response> { 18 18 const authContext = await withAuth(req); 19 19 const sliceParams = extractSliceParams(params); ··· 25 25 const context = await withSliceAccess( 26 26 authContext, 27 27 sliceParams.handle, 28 - sliceParams.sliceId, 28 + sliceParams.sliceId 29 29 ); 30 30 const accessError = requireSliceAccess(context); 31 31 if (accessError) return accessError; 32 32 33 33 const url = new URL(req.url); 34 34 const updated = url.searchParams.get("updated"); 35 + const cleared = url.searchParams.get("cleared"); 35 36 const error = url.searchParams.get("error"); 36 37 37 38 return renderHTML( ··· 39 40 slice={context.sliceContext!.slice!} 40 41 sliceId={sliceParams.sliceId} 41 42 updated={updated === "true"} 43 + cleared={cleared === "true"} 42 44 error={error} 43 45 currentUser={authContext.currentUser} 44 46 hasSliceAccess={context.sliceContext?.hasAccess} 45 - />, 47 + /> 46 48 ); 47 49 } 48 50 49 51 async function handleUpdateSliceSettings( 50 52 req: Request, 51 - params?: URLPatternResult, 53 + params?: URLPatternResult 52 54 ): Promise<Response> { 53 55 const context = await withAuth(req); 54 56 const sliceId = params?.pathname.groups.id; ··· 93 95 }); 94 96 95 97 return hxRedirect( 96 - `/profile/${context.currentUser.handle}/slice/${sliceId}/settings?updated=true`, 98 + `/profile/${context.currentUser.handle}/slice/${sliceId}/settings?updated=true` 97 99 ); 98 100 } catch (_error) { 99 101 return hxRedirect( 100 - `/profile/${context.currentUser.handle}/slice/${sliceId}/settings?error=update_failed`, 102 + `/profile/${context.currentUser.handle}/slice/${sliceId}/settings?error=update_failed` 103 + ); 104 + } 105 + } 106 + 107 + async function handleClearSliceRecords( 108 + req: Request, 109 + params?: URLPatternResult 110 + ): Promise<Response> { 111 + const context = await withAuth(req); 112 + const sliceId = params?.pathname.groups.id; 113 + 114 + if (!sliceId) { 115 + return new Response("Slice ID is required", { status: 400 }); 116 + } 117 + 118 + if (!context.currentUser.isAuthenticated) { 119 + return new Response("Unauthorized", { status: 401 }); 120 + } 121 + 122 + try { 123 + const sliceUri = buildSliceUri(context.currentUser.sub!, sliceId); 124 + const sessionClient = createSessionClient(context.currentUser.sessionId!); 125 + 126 + await sessionClient.network.slices.slice.clearSliceRecords({ 127 + slice: sliceUri, 128 + }); 129 + 130 + return hxRedirect( 131 + `/profile/${context.currentUser.handle}/slice/${sliceId}/settings?cleared=true` 132 + ); 133 + } catch (error) { 134 + console.log("Error clearing slice records:", error); 135 + return hxRedirect( 136 + `/profile/${context.currentUser.handle}/slice/${sliceId}/settings?error=clear_failed` 101 137 ); 102 138 } 103 139 } 104 140 105 141 async function handleDeleteSlice( 106 142 req: Request, 107 - params?: URLPatternResult, 143 + params?: URLPatternResult 108 144 ): Promise<Response> { 109 145 const context = await withAuth(req); 110 146 const sliceId = params?.pathname.groups.id; ··· 118 154 } 119 155 120 156 try { 121 - // Create user-scoped client for delete operation 157 + const sliceUri = buildSliceUri(context.currentUser.sub!, sliceId); 122 158 const sessionClient = createSessionClient(context.currentUser.sessionId!); 123 159 124 - // Delete the slice record from AT Protocol 160 + // 1. Delete all OAuth clients for this slice 161 + try { 162 + const oauthClients = 163 + await sessionClient.network.slices.slice.getOAuthClients({ 164 + slice: sliceUri, 165 + }); 166 + 167 + for (const client of oauthClients.clients) { 168 + await sessionClient.network.slices.slice.deleteOAuthClient({ 169 + clientId: client.clientId, 170 + }); 171 + } 172 + } catch (_error) { 173 + // Continue even if OAuth cleanup fails 174 + } 175 + 176 + // 2. Delete all lexicons for this slice 177 + try { 178 + const lexicons = await sessionClient.network.slices.lexicon.getRecords({ 179 + limit: 100, 180 + }); 181 + 182 + for (const lexicon of lexicons.records) { 183 + const rkey = lexicon.uri.split("/").pop(); 184 + if (rkey) { 185 + await sessionClient.network.slices.lexicon.deleteRecord(rkey); 186 + } 187 + } 188 + } catch (_error) { 189 + // Continue even if lexicon cleanup fails 190 + } 191 + 192 + // 3. Delete the slice record from AT Protocol (this will trigger backend cleanup) 125 193 await sessionClient.network.slices.slice.deleteRecord(sliceId); 126 194 127 195 return hxRedirect(`/profile/${context.currentUser.handle}`); ··· 142 210 method: "PUT", 143 211 pattern: new URLPattern({ pathname: "/api/slices/:id/settings" }), 144 212 handler: handleUpdateSliceSettings, 213 + }, 214 + { 215 + method: "POST", 216 + pattern: new URLPattern({ pathname: "/api/slices/:id/clear-records" }), 217 + handler: handleClearSliceRecords, 145 218 }, 146 219 { 147 220 method: "DELETE",
+51 -15
frontend/src/features/slices/settings/templates/SliceSettings.tsx
··· 11 11 slice: NetworkSlicesSliceDefsSliceView; 12 12 sliceId: string; 13 13 updated?: boolean; 14 + cleared?: boolean; 14 15 error?: string | null; 15 16 currentUser?: AuthenticatedUser; 16 17 hasSliceAccess?: boolean; ··· 20 21 slice, 21 22 sliceId, 22 23 updated = false, 24 + cleared = false, 23 25 error = null, 24 26 currentUser, 25 27 hasSliceAccess, ··· 41 43 /> 42 44 )} 43 45 46 + {/* Cleared Message */} 47 + {cleared && ( 48 + <FlashMessage 49 + type="success" 50 + message="All records and actors cleared successfully!" 51 + /> 52 + )} 53 + 44 54 {/* Error Message */} 45 55 {error && ( 46 56 <FlashMessage ··· 48 58 message={ 49 59 error === "update_failed" 50 60 ? "Failed to update slice settings. Please try again." 61 + : error === "clear_failed" 62 + ? "Failed to clear records. Please try again." 51 63 : "An error occurred." 52 64 } 53 65 /> ··· 112 124 > 113 125 Danger Zone 114 126 </Text> 115 - <Text as="p" variant="secondary" className="mb-4"> 116 - Permanently delete this slice and all associated data. This action 117 - cannot be undone. 118 - </Text> 119 - <Button 120 - type="button" 121 - hx-delete={`/api/slices/${sliceId}`} 122 - hx-confirm="Are you sure you want to delete this slice? This action cannot be undone." 123 - hx-target="body" 124 - hx-push-url="/" 125 - variant="danger" 126 - size="lg" 127 - > 128 - Delete Slice 129 - </Button> 127 + 128 + {/* Clear Records Section */} 129 + <div className="mb-6 pb-6 border-b border-gray-200 dark:border-gray-700"> 130 + <Text as="p" variant="secondary" className="mb-4"> 131 + Clear all indexed records and actors from this slice. Data can be 132 + re-indexed through sync. 133 + </Text> 134 + <Button 135 + type="button" 136 + hx-post={`/api/slices/${sliceId}/clear-records`} 137 + hx-confirm="Are you sure you want to clear all records and actors from this slice? This will delete all indexed data." 138 + hx-target="#clear-records-result" 139 + hx-swap="innerHTML" 140 + variant="danger" 141 + size="lg" 142 + > 143 + Clear All Records 144 + </Button> 145 + <div id="clear-records-result" className="mt-4"></div> 146 + </div> 147 + 148 + {/* Delete Slice Section */} 149 + <div> 150 + <Text as="p" variant="secondary" className="mb-4"> 151 + Permanently delete this slice and all associated data. This action 152 + cannot be undone. 153 + </Text> 154 + <Button 155 + type="button" 156 + hx-delete={`/api/slices/${sliceId}`} 157 + hx-confirm="Are you sure you want to delete this slice? This action cannot be undone." 158 + hx-target="body" 159 + hx-push-url="/" 160 + variant="danger" 161 + size="lg" 162 + > 163 + Delete Slice 164 + </Button> 165 + </div> 130 166 </Card> 131 167 </div> 132 168 </SlicePage>
+36
lexicons/network/slices/slice/clearSliceRecords.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "network.slices.slice.clearSliceRecords", 4 + "defs": { 5 + "main": { 6 + "type": "procedure", 7 + "description": "Clear all indexed records and actors from a slice. Data can be re-indexed through sync. Requires authentication.", 8 + "input": { 9 + "encoding": "application/json", 10 + "schema": { 11 + "type": "object", 12 + "required": ["slice"], 13 + "properties": { 14 + "slice": { 15 + "type": "string", 16 + "description": "AT-URI of the slice to clear" 17 + } 18 + } 19 + } 20 + }, 21 + "output": { 22 + "encoding": "application/json", 23 + "schema": { 24 + "type": "object", 25 + "required": ["message"], 26 + "properties": { 27 + "message": { 28 + "type": "string", 29 + "description": "Success message" 30 + } 31 + } 32 + } 33 + } 34 + } 35 + } 36 + }
+18 -2
packages/cli/src/generated_client.ts
··· 1 1 // Generated TypeScript client for AT Protocol records 2 - // Generated at: 2025-09-28 00:41:51 UTC 3 - // Lexicons: 40 2 + // Generated at: 2025-09-28 17:28:24 UTC 3 + // Lexicons: 41 4 4 5 5 /** 6 6 * @example Usage ··· 1117 1117 sliceUri: string; 1118 1118 /** When this actor was indexed */ 1119 1119 indexedAt: string; 1120 + } 1121 + 1122 + export interface NetworkSlicesSliceClearSliceRecordsInput { 1123 + slice: string; 1124 + } 1125 + 1126 + export interface NetworkSlicesSliceClearSliceRecordsOutput { 1127 + message: string; 1120 1128 } 1121 1129 1122 1130 export interface NetworkSlicesSliceDeleteOAuthClientInput { ··· 2252 2260 "POST", 2253 2261 input, 2254 2262 ); 2263 + } 2264 + 2265 + async clearSliceRecords( 2266 + input: NetworkSlicesSliceClearSliceRecordsInput, 2267 + ): Promise<NetworkSlicesSliceClearSliceRecordsOutput> { 2268 + return await this.client.makeRequest< 2269 + NetworkSlicesSliceClearSliceRecordsOutput 2270 + >("network.slices.slice.clearSliceRecords", "POST", input); 2255 2271 } 2256 2272 2257 2273 async deleteOAuthClient(