+6
api/src/api/xrpc_dynamic.rs
+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
+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
+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
+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
+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
+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
api/src/xrpc/network/slices/slice/mod.rs
+18
-2
frontend/src/client.ts
+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
+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
+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
+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
+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
+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(