+3
api/.env.example
+3
api/.env.example
+11
api/src/main.rs
+11
api/src/main.rs
···
36
36
pub auth_base_url: String,
37
37
pub relay_endpoint: String,
38
38
pub system_slice_uri: String,
39
+
pub default_max_sync_repos: i32,
39
40
}
40
41
41
42
#[derive(Clone)]
···
86
87
"at://did:plc:bcgltzqazw5tb6k2g3ttenbj/network.slices.slice/3lymhd4jhrd2z".to_string()
87
88
});
88
89
90
+
let default_max_sync_repos = env::var("DEFAULT_MAX_SYNC_REPOS")
91
+
.unwrap_or_else(|_| "5000".to_string())
92
+
.parse::<i32>()
93
+
.unwrap_or(5000);
94
+
89
95
let config = Config {
90
96
auth_base_url,
91
97
relay_endpoint,
92
98
system_slice_uri,
99
+
default_max_sync_repos,
93
100
};
94
101
95
102
// Initialize global logger
···
377
384
.route(
378
385
"/xrpc/network.slices.slice.deleteOAuthClient",
379
386
post(xrpc::network::slices::slice::delete_oauth_client::handler),
387
+
)
388
+
.route(
389
+
"/xrpc/network.slices.slice.getSyncSummary",
390
+
get(xrpc::network::slices::slice::get_sync_summary::handler),
380
391
)
381
392
// Dynamic collection-specific XRPC endpoints (wildcard routes must come last)
382
393
.route(
+1
-1
api/src/sync.rs
+1
-1
api/src/sync.rs
···
667
667
/// Fetch all repositories that have records in a given collection.
668
668
///
669
669
/// Uses cursor-based pagination to fetch all repos from the relay.
670
-
async fn get_repos_for_collection(
670
+
pub async fn get_repos_for_collection(
671
671
&self,
672
672
collection: &str,
673
673
slice_uri: &str,
+208
api/src/xrpc/network/slices/slice/get_sync_summary.rs
+208
api/src/xrpc/network/slices/slice/get_sync_summary.rs
···
1
+
use crate::{AppState, auth, errors::AppError, sync::SyncService};
2
+
use axum::{extract::{Query, State}, http::HeaderMap, response::Json};
3
+
use serde::{Deserialize, Serialize};
4
+
use std::collections::HashMap;
5
+
6
+
#[derive(Debug, Deserialize)]
7
+
#[serde(rename_all = "camelCase")]
8
+
pub struct Params {
9
+
pub slice: String,
10
+
#[serde(default, deserialize_with = "deserialize_string_or_vec")]
11
+
pub collections: Option<Vec<String>>,
12
+
#[serde(default, deserialize_with = "deserialize_string_or_vec")]
13
+
pub external_collections: Option<Vec<String>>,
14
+
#[serde(default, deserialize_with = "deserialize_string_or_vec")]
15
+
pub repos: Option<Vec<String>>,
16
+
}
17
+
18
+
fn deserialize_string_or_vec<'de, D>(deserializer: D) -> Result<Option<Vec<String>>, D::Error>
19
+
where
20
+
D: serde::Deserializer<'de>,
21
+
{
22
+
use serde::de::{self, Visitor};
23
+
use std::fmt;
24
+
25
+
struct StringOrVec;
26
+
27
+
impl<'de> Visitor<'de> for StringOrVec {
28
+
type Value = Option<Vec<String>>;
29
+
30
+
fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result {
31
+
formatter.write_str("string or list of strings")
32
+
}
33
+
34
+
fn visit_str<E>(self, value: &str) -> Result<Self::Value, E>
35
+
where
36
+
E: de::Error,
37
+
{
38
+
Ok(Some(vec![value.to_string()]))
39
+
}
40
+
41
+
fn visit_seq<A>(self, mut seq: A) -> Result<Self::Value, A::Error>
42
+
where
43
+
A: de::SeqAccess<'de>,
44
+
{
45
+
let mut vec = Vec::new();
46
+
while let Some(item) = seq.next_element::<String>()? {
47
+
vec.push(item);
48
+
}
49
+
Ok(if vec.is_empty() { None } else { Some(vec) })
50
+
}
51
+
52
+
fn visit_none<E>(self) -> Result<Self::Value, E>
53
+
where
54
+
E: de::Error,
55
+
{
56
+
Ok(None)
57
+
}
58
+
59
+
fn visit_unit<E>(self) -> Result<Self::Value, E>
60
+
where
61
+
E: de::Error,
62
+
{
63
+
Ok(None)
64
+
}
65
+
}
66
+
67
+
deserializer.deserialize_any(StringOrVec)
68
+
}
69
+
70
+
#[derive(Debug, Serialize)]
71
+
#[serde(rename_all = "camelCase")]
72
+
pub struct Output {
73
+
pub total_repos: i64,
74
+
pub capped_repos: i64,
75
+
pub collections_summary: Vec<CollectionSummary>,
76
+
pub would_be_capped: bool,
77
+
pub applied_limit: i32,
78
+
}
79
+
80
+
#[derive(Debug, Serialize)]
81
+
#[serde(rename_all = "camelCase")]
82
+
pub struct CollectionSummary {
83
+
pub collection: String,
84
+
pub estimated_repos: i64,
85
+
pub is_external: bool,
86
+
}
87
+
88
+
pub async fn handler(
89
+
State(state): State<AppState>,
90
+
headers: HeaderMap,
91
+
Query(params): Query<Params>,
92
+
) -> Result<Json<Output>, AppError> {
93
+
tracing::info!("getSyncSummary called with params: {:?}", params);
94
+
95
+
let token = auth::extract_bearer_token(&headers)?;
96
+
let _user_info = auth::verify_oauth_token_cached(
97
+
&token,
98
+
&state.config.auth_base_url,
99
+
Some(state.auth_cache.clone()),
100
+
)
101
+
.await?;
102
+
103
+
let slice_uri = ¶ms.slice;
104
+
let primary_collections = params.collections.unwrap_or_default();
105
+
let external_collections = params.external_collections.unwrap_or_default();
106
+
let user_provided_repos = params.repos;
107
+
108
+
// Use the system default limit
109
+
let applied_limit = state.config.default_max_sync_repos;
110
+
111
+
// Get slice domain for categorizing collections
112
+
let slice_domain = state
113
+
.database
114
+
.get_slice_domain(slice_uri)
115
+
.await
116
+
.map_err(|e| AppError::Internal(format!("Failed to get slice domain: {}", e)))?
117
+
.ok_or_else(|| AppError::NotFound(format!("Slice not found: {}", slice_uri)))?;
118
+
119
+
// Create sync service for repo discovery
120
+
let sync_service = SyncService::with_cache(
121
+
state.database.clone(),
122
+
state.config.relay_endpoint.clone(),
123
+
state.auth_cache.clone(),
124
+
);
125
+
126
+
// Discover repos if not provided
127
+
let all_repos = if let Some(provided_repos) = user_provided_repos {
128
+
provided_repos
129
+
} else {
130
+
// Discover repos from collections
131
+
let mut discovered_repos = std::collections::HashSet::new();
132
+
133
+
// Get repos from primary collections
134
+
for collection in &primary_collections {
135
+
match sync_service.get_repos_for_collection(collection, slice_uri).await {
136
+
Ok(repos) => {
137
+
discovered_repos.extend(repos);
138
+
}
139
+
Err(e) => {
140
+
tracing::warn!("Failed to get repos for collection {}: {}", collection, e);
141
+
}
142
+
}
143
+
}
144
+
145
+
// Get repos from external collections
146
+
for collection in &external_collections {
147
+
match sync_service.get_repos_for_collection(collection, slice_uri).await {
148
+
Ok(repos) => {
149
+
discovered_repos.extend(repos);
150
+
}
151
+
Err(e) => {
152
+
tracing::warn!("Failed to get repos for collection {}: {}", collection, e);
153
+
}
154
+
}
155
+
}
156
+
157
+
discovered_repos.into_iter().collect()
158
+
};
159
+
160
+
let total_repos = all_repos.len() as i64;
161
+
let capped_repos = std::cmp::min(total_repos, applied_limit as i64);
162
+
let would_be_capped = total_repos > applied_limit as i64;
163
+
164
+
// Build collections summary
165
+
let mut collections_summary = Vec::new();
166
+
let mut collection_repo_counts: HashMap<String, i64> = HashMap::new();
167
+
168
+
// Count repos per collection (this is an approximation)
169
+
for collection in &primary_collections {
170
+
let is_external = !collection.starts_with(&slice_domain);
171
+
let estimated_repos = if let Ok(repos) = sync_service.get_repos_for_collection(collection, slice_uri).await {
172
+
repos.len() as i64
173
+
} else {
174
+
0
175
+
};
176
+
177
+
collection_repo_counts.insert(collection.clone(), estimated_repos);
178
+
collections_summary.push(CollectionSummary {
179
+
collection: collection.clone(),
180
+
estimated_repos,
181
+
is_external,
182
+
});
183
+
}
184
+
185
+
for collection in &external_collections {
186
+
let is_external = !collection.starts_with(&slice_domain);
187
+
let estimated_repos = if let Ok(repos) = sync_service.get_repos_for_collection(collection, slice_uri).await {
188
+
repos.len() as i64
189
+
} else {
190
+
0
191
+
};
192
+
193
+
collection_repo_counts.insert(collection.clone(), estimated_repos);
194
+
collections_summary.push(CollectionSummary {
195
+
collection: collection.clone(),
196
+
estimated_repos,
197
+
is_external,
198
+
});
199
+
}
200
+
201
+
Ok(Json(Output {
202
+
total_repos,
203
+
capped_repos,
204
+
collections_summary,
205
+
would_be_capped,
206
+
applied_limit,
207
+
}))
208
+
}
+1
api/src/xrpc/network/slices/slice/mod.rs
+1
api/src/xrpc/network/slices/slice/mod.rs
+36
-2
frontend/src/client.ts
+36
-2
frontend/src/client.ts
···
1
1
// Generated TypeScript client for AT Protocol records
2
-
// Generated at: 2025-09-28 21:47:20 UTC
3
-
// Lexicons: 41
2
+
// Generated at: 2025-09-29 01:19:06 UTC
3
+
// Lexicons: 42
4
4
5
5
/**
6
6
* @example Usage
···
1046
1046
connected: boolean;
1047
1047
}
1048
1048
1049
+
export interface NetworkSlicesSliceGetSyncSummaryParams {
1050
+
slice: string;
1051
+
collections?: string[];
1052
+
externalCollections?: string[];
1053
+
repos?: string[];
1054
+
maxRepos?: number;
1055
+
}
1056
+
1057
+
export interface NetworkSlicesSliceGetSyncSummaryOutput {
1058
+
totalRepos: number;
1059
+
cappedRepos: number;
1060
+
collectionsSummary: NetworkSlicesSliceGetSyncSummary["CollectionSummary"][];
1061
+
wouldBeCapped: boolean;
1062
+
appliedLimit: number;
1063
+
}
1064
+
1065
+
export interface NetworkSlicesSliceGetSyncSummaryCollectionSummary {
1066
+
collection: string;
1067
+
estimatedRepos: number;
1068
+
isExternal: boolean;
1069
+
}
1070
+
1049
1071
export interface NetworkSlicesSlice {
1050
1072
/** Name of the slice */
1051
1073
name: string;
···
1552
1574
1553
1575
export interface NetworkSlicesSliceGetJobLogs {
1554
1576
readonly LogEntry: NetworkSlicesSliceGetJobLogsLogEntry;
1577
+
}
1578
+
1579
+
export interface NetworkSlicesSliceGetSyncSummary {
1580
+
readonly CollectionSummary: NetworkSlicesSliceGetSyncSummaryCollectionSummary;
1555
1581
}
1556
1582
1557
1583
export interface NetworkSlicesSliceGetJobStatus {
···
2243
2269
return await this.client.makeRequest<
2244
2270
NetworkSlicesSliceGetJetstreamStatusOutput
2245
2271
>("network.slices.slice.getJetstreamStatus", "GET", {});
2272
+
}
2273
+
2274
+
async getSyncSummary(
2275
+
params?: NetworkSlicesSliceGetSyncSummaryParams,
2276
+
): Promise<NetworkSlicesSliceGetSyncSummaryOutput> {
2277
+
return await this.client.makeRequest<
2278
+
NetworkSlicesSliceGetSyncSummaryOutput
2279
+
>("network.slices.slice.getSyncSummary", "GET", params);
2246
2280
}
2247
2281
2248
2282
async getJobStatus(
+1
-1
frontend/src/features/slices/lexicon/templates/LexiconDetailPage.tsx
+1
-1
frontend/src/features/slices/lexicon/templates/LexiconDetailPage.tsx
+75
-1
frontend/src/features/slices/sync/handlers.tsx
+75
-1
frontend/src/features/slices/sync/handlers.tsx
···
15
15
import { SliceSyncPage } from "./templates/SliceSyncPage.tsx";
16
16
import { hxRedirect } from "../../../utils/htmx.ts";
17
17
import { SyncFormModal } from "./templates/fragments/SyncFormModal.tsx";
18
+
import { SyncSummaryModal } from "./templates/fragments/SyncSummaryModal.tsx";
18
19
import { SyncResult } from "./templates/fragments/SyncResult.tsx";
19
20
import { JobHistory } from "./templates/fragments/JobHistory.tsx";
20
21
···
35
36
try {
36
37
const formData = await req.formData();
37
38
const collections = formData.getAll("collections") as string[];
38
-
const externalCollections = formData.getAll("external_collections") as string[];
39
+
const externalCollections = formData.getAll(
40
+
"external_collections"
41
+
) as string[];
39
42
const reposText = (formData.get("repos") as string) || "";
40
43
41
44
const repos = reposText
···
249
252
}
250
253
}
251
254
255
+
async function handleSyncSummary(
256
+
req: Request,
257
+
params?: URLPatternResult
258
+
): Promise<Response> {
259
+
const context = await withAuth(req);
260
+
const authResponse = requireAuth(context);
261
+
if (authResponse) return authResponse;
262
+
263
+
const sliceId = params?.pathname.groups.id;
264
+
if (!sliceId) {
265
+
return new Response("Invalid slice ID", { status: 400 });
266
+
}
267
+
268
+
try {
269
+
const formData = await req.formData();
270
+
const collections = formData.getAll("collections") as string[];
271
+
const externalCollections = formData.getAll(
272
+
"external_collections"
273
+
) as string[];
274
+
const reposText = (formData.get("repos") as string) || "";
275
+
276
+
const repos = reposText
277
+
.split("\n")
278
+
.map((line) => line.trim())
279
+
.filter((line) => line.length > 0);
280
+
281
+
if (collections.length === 0 && externalCollections.length === 0) {
282
+
return renderHTML(
283
+
<SyncResult
284
+
success={false}
285
+
error="Please specify at least one collection (primary or external) to sync"
286
+
/>
287
+
);
288
+
}
289
+
290
+
const sliceClient = getSliceClient(context, sliceId);
291
+
const sliceUri = buildSliceUri(context.currentUser.sub!, sliceId);
292
+
293
+
// Call the getSyncSummary endpoint
294
+
const requestParams = {
295
+
slice: sliceUri,
296
+
collections: collections.length > 0 ? collections : undefined,
297
+
externalCollections:
298
+
externalCollections.length > 0 ? externalCollections : undefined,
299
+
repos: repos.length > 0 ? repos : undefined,
300
+
};
301
+
302
+
const summaryResponse =
303
+
await sliceClient.network.slices.slice.getSyncSummary(requestParams);
304
+
305
+
return renderHTML(
306
+
<SyncSummaryModal
307
+
sliceId={sliceId}
308
+
summary={summaryResponse}
309
+
collections={collections}
310
+
externalCollections={externalCollections}
311
+
repos={reposText}
312
+
/>
313
+
);
314
+
} catch (error) {
315
+
console.error("Failed to get sync summary:", error);
316
+
const errorMessage = error instanceof Error ? error.message : String(error);
317
+
return renderHTML(<SyncResult success={false} error={errorMessage} />);
318
+
}
319
+
}
320
+
252
321
export const syncRoutes: Route[] = [
253
322
{
254
323
method: "GET",
···
264
333
method: "POST",
265
334
pattern: new URLPattern({ pathname: "/api/slices/:id/sync" }),
266
335
handler: handleSliceSync,
336
+
},
337
+
{
338
+
method: "POST",
339
+
pattern: new URLPattern({ pathname: "/api/slices/:id/sync/summary" }),
340
+
handler: handleSyncSummary,
267
341
},
268
342
{
269
343
method: "GET",
+7
-9
frontend/src/features/slices/sync/templates/fragments/SyncFormModal.tsx
+7
-9
frontend/src/features/slices/sync/templates/fragments/SyncFormModal.tsx
···
19
19
title="Sync Collections"
20
20
description="Sync entire collections from AT Protocol network to this slice."
21
21
>
22
-
<form
23
-
hx-post={`/api/slices/${sliceId}/sync`}
24
-
hx-target="#sync-result"
25
-
hx-swap="innerHTML"
26
-
className="space-y-4"
27
-
>
22
+
<form className="space-y-4">
28
23
<div className="space-y-2">
29
24
<label className="block text-sm font-medium text-zinc-700 dark:text-zinc-300">
30
25
Primary Collections
···
97
92
</Button>
98
93
<Button
99
94
type="submit"
100
-
variant="success"
95
+
variant="primary"
101
96
className="flex items-center justify-center"
97
+
hx-post={`/api/slices/${sliceId}/sync/summary`}
98
+
hx-target="#modal-container"
99
+
hx-swap="innerHTML"
102
100
>
103
101
<i
104
102
data-lucide="loader-2"
···
107
105
_="on load js lucide.createIcons() end"
108
106
>
109
107
</i>
110
-
<span className="htmx-indicator">Syncing...</span>
111
-
<span className="default-text">Start Sync</span>
108
+
<span className="htmx-indicator">Loading...</span>
109
+
<span className="default-text">Preview Sync</span>
112
110
</Button>
113
111
</div>
114
112
</form>
+172
frontend/src/features/slices/sync/templates/fragments/SyncSummaryModal.tsx
+172
frontend/src/features/slices/sync/templates/fragments/SyncSummaryModal.tsx
···
1
+
import { Button } from "../../../../../shared/fragments/Button.tsx";
2
+
import { Modal } from "../../../../../shared/fragments/Modal.tsx";
3
+
import type { NetworkSlicesSliceGetSyncSummaryOutput } from "../../../../../client.ts";
4
+
5
+
interface SyncSummaryModalProps {
6
+
sliceId: string;
7
+
summary: NetworkSlicesSliceGetSyncSummaryOutput;
8
+
collections: string[];
9
+
externalCollections: string[];
10
+
repos?: string;
11
+
}
12
+
13
+
export function SyncSummaryModal({
14
+
sliceId,
15
+
summary,
16
+
collections,
17
+
externalCollections,
18
+
repos,
19
+
}: SyncSummaryModalProps) {
20
+
return (
21
+
<Modal
22
+
title="Sync Summary"
23
+
description="Review what will be synced before starting the operation."
24
+
>
25
+
<div className="space-y-6">
26
+
{/* Summary Stats */}
27
+
<div className="bg-zinc-50 dark:bg-zinc-800 rounded-lg p-4">
28
+
{summary.wouldBeCapped ? (
29
+
<div className="grid grid-cols-2 gap-4">
30
+
<div>
31
+
<p className="text-sm font-medium text-zinc-700 dark:text-zinc-300">
32
+
Total Repositories
33
+
</p>
34
+
<p className="text-2xl font-bold text-zinc-900 dark:text-zinc-100">
35
+
{summary.totalRepos.toLocaleString()}
36
+
</p>
37
+
</div>
38
+
<div>
39
+
<p className="text-sm font-medium text-zinc-700 dark:text-zinc-300">
40
+
Will Sync
41
+
</p>
42
+
<p className="text-2xl font-bold text-zinc-900 dark:text-zinc-100">
43
+
{summary.cappedRepos.toLocaleString()}
44
+
</p>
45
+
</div>
46
+
</div>
47
+
) : (
48
+
<div className="text-center">
49
+
<p className="text-sm font-medium text-zinc-700 dark:text-zinc-300">
50
+
Repositories to Sync
51
+
</p>
52
+
<p className="text-2xl font-bold text-zinc-900 dark:text-zinc-100">
53
+
{summary.totalRepos.toLocaleString()}
54
+
</p>
55
+
</div>
56
+
)}
57
+
58
+
{summary.wouldBeCapped && (
59
+
<div className="mt-3 p-3 bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800 rounded-md">
60
+
<div className="flex">
61
+
<i
62
+
data-lucide="alert-triangle"
63
+
className="h-5 w-5 text-yellow-400 mr-2 flex-shrink-0"
64
+
/* @ts-ignore - Hyperscript attribute */
65
+
_="on load js lucide.createIcons() end"
66
+
></i>
67
+
<div>
68
+
<p className="text-sm font-medium text-yellow-800 dark:text-yellow-200">
69
+
Sync Limited
70
+
</p>
71
+
<p className="text-sm text-yellow-700 dark:text-yellow-300">
72
+
This sync has been limited to{" "}
73
+
{summary.appliedLimit.toLocaleString()} repositories.
74
+
</p>
75
+
</div>
76
+
</div>
77
+
</div>
78
+
)}
79
+
</div>
80
+
81
+
{/* Collections Breakdown */}
82
+
<div>
83
+
<h3 className="text-sm font-medium text-zinc-700 dark:text-zinc-300 mb-3">
84
+
Collections Breakdown
85
+
</h3>
86
+
<div className="space-y-2">
87
+
{summary.collectionsSummary.map((collection) => (
88
+
<div
89
+
key={collection.collection}
90
+
className="flex items-center justify-between p-3 bg-white dark:bg-zinc-900 border border-zinc-200 dark:border-zinc-700 rounded-md"
91
+
>
92
+
<div className="flex items-center space-x-3">
93
+
<div
94
+
className={`w-2 h-2 rounded-full ${
95
+
collection.isExternal ? "bg-blue-500" : "bg-green-500"
96
+
}`}
97
+
></div>
98
+
<div>
99
+
<p className="text-sm font-medium text-zinc-900 dark:text-zinc-100">
100
+
{collection.collection}
101
+
</p>
102
+
<p className="text-xs text-zinc-500">
103
+
{collection.isExternal ? "External" : "Primary"}{" "}
104
+
Collection
105
+
</p>
106
+
</div>
107
+
</div>
108
+
<span className="text-sm font-medium text-zinc-600 dark:text-zinc-400">
109
+
{collection.estimatedRepos.toLocaleString()} repos
110
+
</span>
111
+
</div>
112
+
))}
113
+
</div>
114
+
</div>
115
+
116
+
{/* Actions */}
117
+
<form
118
+
hx-post={`/api/slices/${sliceId}/sync`}
119
+
hx-target="#sync-result"
120
+
hx-swap="innerHTML"
121
+
className="space-y-4"
122
+
>
123
+
{/* Hidden inputs to pass the sync parameters */}
124
+
{collections.map((collection) => (
125
+
<input
126
+
key={collection}
127
+
type="hidden"
128
+
name="collections"
129
+
value={collection}
130
+
/>
131
+
))}
132
+
{externalCollections.map((collection) => (
133
+
<input
134
+
key={collection}
135
+
type="hidden"
136
+
name="external_collections"
137
+
value={collection}
138
+
/>
139
+
))}
140
+
{repos && <input type="hidden" name="repos" value={repos} />}
141
+
142
+
<div id="sync-result" className="mt-4"></div>
143
+
144
+
<div className="flex justify-end gap-3">
145
+
<Button
146
+
type="button"
147
+
variant="secondary"
148
+
/* @ts-ignore - Hyperscript attribute */
149
+
_="on click set #modal-container's innerHTML to ''"
150
+
>
151
+
Cancel
152
+
</Button>
153
+
<Button
154
+
type="submit"
155
+
variant="success"
156
+
className="flex items-center justify-center"
157
+
>
158
+
<i
159
+
data-lucide="loader-2"
160
+
className="htmx-indicator animate-spin mr-2 h-4 w-4"
161
+
/* @ts-ignore - Hyperscript attribute */
162
+
_="on load js lucide.createIcons() end"
163
+
></i>
164
+
<span className="htmx-indicator">Starting...</span>
165
+
<span className="default-text">Start Sync</span>
166
+
</Button>
167
+
</div>
168
+
</form>
169
+
</div>
170
+
</Modal>
171
+
);
172
+
}
+94
lexicons/network/slices/slice/getSyncSummary.json
+94
lexicons/network/slices/slice/getSyncSummary.json
···
1
+
{
2
+
"lexicon": 1,
3
+
"id": "network.slices.slice.getSyncSummary",
4
+
"defs": {
5
+
"main": {
6
+
"type": "query",
7
+
"description": "Get a summary of what would be synced before running a sync operation",
8
+
"parameters": {
9
+
"type": "params",
10
+
"required": ["slice"],
11
+
"properties": {
12
+
"slice": {
13
+
"type": "string",
14
+
"description": "URI of the slice to sync"
15
+
},
16
+
"collections": {
17
+
"type": "array",
18
+
"items": {
19
+
"type": "string"
20
+
},
21
+
"description": "Primary collections to sync"
22
+
},
23
+
"externalCollections": {
24
+
"type": "array",
25
+
"items": {
26
+
"type": "string"
27
+
},
28
+
"description": "External collections to sync"
29
+
},
30
+
"repos": {
31
+
"type": "array",
32
+
"items": {
33
+
"type": "string"
34
+
},
35
+
"description": "Specific repositories to sync (DIDs)"
36
+
}
37
+
}
38
+
},
39
+
"output": {
40
+
"encoding": "application/json",
41
+
"schema": {
42
+
"type": "object",
43
+
"required": [
44
+
"totalRepos",
45
+
"cappedRepos",
46
+
"collectionsSummary",
47
+
"wouldBeCapped",
48
+
"appliedLimit"
49
+
],
50
+
"properties": {
51
+
"totalRepos": {
52
+
"type": "integer",
53
+
"description": "Total number of repositories that would be synced"
54
+
},
55
+
"cappedRepos": {
56
+
"type": "integer",
57
+
"description": "Number of repositories after applying limit"
58
+
},
59
+
"collectionsSummary": {
60
+
"type": "array",
61
+
"items": {
62
+
"type": "ref",
63
+
"ref": "#collectionSummary"
64
+
}
65
+
},
66
+
"wouldBeCapped": {
67
+
"type": "boolean",
68
+
"description": "Whether the sync would be limited by maxRepos"
69
+
},
70
+
"appliedLimit": {
71
+
"type": "integer",
72
+
"description": "The actual limit applied (user-specified or default)"
73
+
}
74
+
}
75
+
}
76
+
}
77
+
},
78
+
"collectionSummary": {
79
+
"type": "object",
80
+
"required": ["collection", "estimatedRepos", "isExternal"],
81
+
"properties": {
82
+
"collection": {
83
+
"type": "string"
84
+
},
85
+
"estimatedRepos": {
86
+
"type": "integer"
87
+
},
88
+
"isExternal": {
89
+
"type": "boolean"
90
+
}
91
+
}
92
+
}
93
+
}
94
+
}