···33 // BlobRef is an enum with Blob variant, which has a ref field (CidLink)
34 let blob_ref = &file_node.blob;
35 let cid_string = blob_ref.blob().r#ref.to_string();
36-37 // Store with full path (mirrors TypeScript implementation)
38 blob_map.insert(
39 full_path,
···43 EntryNode::Directory(subdir) => {
44 let sub_map = extract_blob_map_recursive(subdir, full_path);
45 blob_map.extend(sub_map);
000046 }
47 EntryNode::Unknown(_) => {
48 // Skip unknown node types
···33 // BlobRef is an enum with Blob variant, which has a ref field (CidLink)
34 let blob_ref = &file_node.blob;
35 let cid_string = blob_ref.blob().r#ref.to_string();
36+37 // Store with full path (mirrors TypeScript implementation)
38 blob_map.insert(
39 full_path,
···43 EntryNode::Directory(subdir) => {
44 let sub_map = extract_blob_map_recursive(subdir, full_path);
45 blob_map.extend(sub_map);
46+ }
47+ EntryNode::Subfs(_) => {
48+ // Subfs nodes don't contain blobs directly - they reference other records
49+ // Skip them in blob map extraction
50 }
51 EntryNode::Unknown(_) => {
52 // Skip unknown node types
+9
cli/src/lib.rs
···000000000
···1+// @generated by jacquard-lexicon. DO NOT EDIT.
2+//
3+// This file was automatically generated from Lexicon schemas.
4+// Any manual changes will be overwritten on the next regeneration.
5+6+pub mod builder_types;
7+8+#[cfg(feature = "place_wisp")]
9+pub mod place_wisp;
+195-12
cli/src/main.rs
···6mod download;
7mod pull;
8mod serve;
0910use clap::{Parser, Subcommand};
11use jacquard::CowStr;
···204 println!("Deploying site '{}'...", site_name);
205206 // Try to fetch existing manifest for incremental updates
207- let existing_blob_map: HashMap<String, (jacquard_common::types::blob::BlobRef<'static>, String)> = {
208 use jacquard_common::types::string::AtUri;
209-210 // Get the DID for this session
211 let session_info = agent.session_info().await;
212 if let Some((did, _)) = session_info {
···218 match response.into_output() {
219 Ok(record_output) => {
220 let existing_manifest = record_output.value;
221- let blob_map = blob_map::extract_blob_map(&existing_manifest.root);
222- println!("Found existing manifest with {} files, checking for changes...", blob_map.len());
223- blob_map
00000000000000000000224 }
225 Err(_) => {
226 println!("No existing manifest found, uploading all files...");
227- HashMap::new()
228 }
229 }
230 }
231 Err(_) => {
232 // Record doesn't exist yet - this is a new site
233 println!("No existing manifest found, uploading all files...");
234- HashMap::new()
235 }
236 }
237 } else {
238 println!("No existing manifest found (invalid URI), uploading all files...");
239- HashMap::new()
240 }
241 } else {
242 println!("No existing manifest found (could not get DID), uploading all files...");
243- HashMap::new()
244 }
245 };
246···248 let (root_dir, total_files, reused_count) = build_directory(agent, &path, &existing_blob_map, String::new()).await?;
249 let uploaded_count = total_files - reused_count;
250251- // Create the Fs record
0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000252 let fs_record = Fs::new()
253 .site(CowStr::from(site_name.clone()))
254- .root(root_dir)
255- .file_count(total_files as i64)
256 .created_at(Datetime::now())
257 .build();
258···270 println!("\n✓ Deployed site '{}': {}", site_name, output.uri);
271 println!(" Total files: {} ({} reused, {} uploaded)", total_files, reused_count, uploaded_count);
272 println!(" Available at: https://sites.wisp.place/{}/{}", did, site_name);
000000000000000000000000000273274 Ok(())
275}
···448 ))
449}
45000000000000000000000000000000000000000000000
···6mod download;
7mod pull;
8mod serve;
9+mod subfs_utils;
1011use clap::{Parser, Subcommand};
12use jacquard::CowStr;
···205 println!("Deploying site '{}'...", site_name);
206207 // Try to fetch existing manifest for incremental updates
208+ let (existing_blob_map, old_subfs_uris): (HashMap<String, (jacquard_common::types::blob::BlobRef<'static>, String)>, Vec<(String, String)>) = {
209 use jacquard_common::types::string::AtUri;
210+211 // Get the DID for this session
212 let session_info = agent.session_info().await;
213 if let Some((did, _)) = session_info {
···219 match response.into_output() {
220 Ok(record_output) => {
221 let existing_manifest = record_output.value;
222+ let mut blob_map = blob_map::extract_blob_map(&existing_manifest.root);
223+ println!("Found existing manifest with {} files in main record", blob_map.len());
224+225+ // Extract subfs URIs from main record
226+ let subfs_uris = subfs_utils::extract_subfs_uris(&existing_manifest.root, String::new());
227+228+ if !subfs_uris.is_empty() {
229+ println!("Found {} subfs records, fetching for blob reuse...", subfs_uris.len());
230+231+ // Merge blob maps from all subfs records
232+ match subfs_utils::merge_subfs_blob_maps(agent, subfs_uris.clone(), &mut blob_map).await {
233+ Ok(merged_count) => {
234+ println!("Total blob map: {} files (main + {} from subfs)", blob_map.len(), merged_count);
235+ }
236+ Err(e) => {
237+ eprintln!("⚠️ Failed to merge some subfs blob maps: {}", e);
238+ }
239+ }
240+241+ (blob_map, subfs_uris)
242+ } else {
243+ (blob_map, Vec::new())
244+ }
245 }
246 Err(_) => {
247 println!("No existing manifest found, uploading all files...");
248+ (HashMap::new(), Vec::new())
249 }
250 }
251 }
252 Err(_) => {
253 // Record doesn't exist yet - this is a new site
254 println!("No existing manifest found, uploading all files...");
255+ (HashMap::new(), Vec::new())
256 }
257 }
258 } else {
259 println!("No existing manifest found (invalid URI), uploading all files...");
260+ (HashMap::new(), Vec::new())
261 }
262 } else {
263 println!("No existing manifest found (could not get DID), uploading all files...");
264+ (HashMap::new(), Vec::new())
265 }
266 };
267···269 let (root_dir, total_files, reused_count) = build_directory(agent, &path, &existing_blob_map, String::new()).await?;
270 let uploaded_count = total_files - reused_count;
271272+ // Check if we need to split into subfs records
273+ const MAX_MANIFEST_SIZE: usize = 140 * 1024; // 140KB (PDS limit is 150KB)
274+ const FILE_COUNT_THRESHOLD: usize = 250; // Start splitting at this many files
275+ const TARGET_FILE_COUNT: usize = 200; // Keep main manifest under this
276+277+ let mut working_directory = root_dir;
278+ let mut current_file_count = total_files;
279+ let mut new_subfs_uris: Vec<(String, String)> = Vec::new();
280+281+ // Estimate initial manifest size
282+ let mut manifest_size = subfs_utils::estimate_directory_size(&working_directory);
283+284+ if total_files >= FILE_COUNT_THRESHOLD || manifest_size > MAX_MANIFEST_SIZE {
285+ println!("\n⚠️ Large site detected ({} files, {:.1}KB manifest), splitting into subfs records...",
286+ total_files, manifest_size as f64 / 1024.0);
287+288+ let mut attempts = 0;
289+ const MAX_SPLIT_ATTEMPTS: usize = 50;
290+291+ while (manifest_size > MAX_MANIFEST_SIZE || current_file_count > TARGET_FILE_COUNT) && attempts < MAX_SPLIT_ATTEMPTS {
292+ attempts += 1;
293+294+ // Find large directories to split
295+ let directories = subfs_utils::find_large_directories(&working_directory, String::new());
296+297+ if let Some(largest_dir) = directories.first() {
298+ println!(" Split #{}: {} ({} files, {:.1}KB)",
299+ attempts, largest_dir.path, largest_dir.file_count, largest_dir.size as f64 / 1024.0);
300+301+ // Create a subfs record for this directory
302+ use jacquard_common::types::string::Tid;
303+ let subfs_tid = Tid::now_0();
304+ let subfs_rkey = subfs_tid.to_string();
305+306+ let subfs_manifest = crate::place_wisp::subfs::SubfsRecord::new()
307+ .root(convert_fs_dir_to_subfs_dir(largest_dir.directory.clone()))
308+ .file_count(Some(largest_dir.file_count as i64))
309+ .created_at(Datetime::now())
310+ .build();
311+312+ // Upload subfs record
313+ let subfs_output = agent.put_record(
314+ RecordKey::from(Rkey::new(&subfs_rkey).into_diagnostic()?),
315+ subfs_manifest
316+ ).await.into_diagnostic()?;
317+318+ let subfs_uri = subfs_output.uri.to_string();
319+ println!(" ✅ Created subfs: {}", subfs_uri);
320+321+ // Replace directory with subfs node (flat: false to preserve structure)
322+ working_directory = subfs_utils::replace_directory_with_subfs(
323+ working_directory,
324+ &largest_dir.path,
325+ &subfs_uri,
326+ false // Preserve directory structure
327+ )?;
328+329+ new_subfs_uris.push((subfs_uri, largest_dir.path.clone()));
330+ current_file_count -= largest_dir.file_count;
331+332+ // Recalculate manifest size
333+ manifest_size = subfs_utils::estimate_directory_size(&working_directory);
334+ println!(" → Manifest now {:.1}KB with {} files ({} subfs total)",
335+ manifest_size as f64 / 1024.0, current_file_count, new_subfs_uris.len());
336+337+ if manifest_size <= MAX_MANIFEST_SIZE && current_file_count <= TARGET_FILE_COUNT {
338+ println!("✅ Manifest now fits within limits");
339+ break;
340+ }
341+ } else {
342+ println!(" No more subdirectories to split - stopping");
343+ break;
344+ }
345+ }
346+347+ if attempts >= MAX_SPLIT_ATTEMPTS {
348+ return Err(miette::miette!(
349+ "Exceeded maximum split attempts ({}). Manifest still too large: {:.1}KB with {} files",
350+ MAX_SPLIT_ATTEMPTS,
351+ manifest_size as f64 / 1024.0,
352+ current_file_count
353+ ));
354+ }
355+356+ println!("✅ Split complete: {} subfs records, {} files in main manifest, {:.1}KB",
357+ new_subfs_uris.len(), current_file_count, manifest_size as f64 / 1024.0);
358+ } else {
359+ println!("Manifest created ({} files, {:.1}KB) - no splitting needed",
360+ total_files, manifest_size as f64 / 1024.0);
361+ }
362+363+ // Create the final Fs record
364 let fs_record = Fs::new()
365 .site(CowStr::from(site_name.clone()))
366+ .root(working_directory)
367+ .file_count(current_file_count as i64)
368 .created_at(Datetime::now())
369 .build();
370···382 println!("\n✓ Deployed site '{}': {}", site_name, output.uri);
383 println!(" Total files: {} ({} reused, {} uploaded)", total_files, reused_count, uploaded_count);
384 println!(" Available at: https://sites.wisp.place/{}/{}", did, site_name);
385+386+ // Clean up old subfs records
387+ if !old_subfs_uris.is_empty() {
388+ println!("\nCleaning up {} old subfs records...", old_subfs_uris.len());
389+390+ let mut deleted_count = 0;
391+ let mut failed_count = 0;
392+393+ for (uri, _path) in old_subfs_uris {
394+ match subfs_utils::delete_subfs_record(agent, &uri).await {
395+ Ok(_) => {
396+ deleted_count += 1;
397+ println!(" 🗑️ Deleted old subfs: {}", uri);
398+ }
399+ Err(e) => {
400+ failed_count += 1;
401+ eprintln!(" ⚠️ Failed to delete {}: {}", uri, e);
402+ }
403+ }
404+ }
405+406+ if failed_count > 0 {
407+ eprintln!("⚠️ Cleanup completed with {} deleted, {} failed", deleted_count, failed_count);
408+ } else {
409+ println!("✅ Cleanup complete: {} old subfs records deleted", deleted_count);
410+ }
411+ }
412413 Ok(())
414}
···587 ))
588}
589590+/// Convert fs::Directory to subfs::Directory
591+/// They have the same structure, but different types
592+fn convert_fs_dir_to_subfs_dir(fs_dir: place_wisp::fs::Directory<'static>) -> place_wisp::subfs::Directory<'static> {
593+ use place_wisp::subfs::{Directory as SubfsDirectory, Entry as SubfsEntry, EntryNode as SubfsEntryNode, File as SubfsFile};
594+595+ let subfs_entries: Vec<SubfsEntry> = fs_dir.entries.into_iter().map(|entry| {
596+ let node = match entry.node {
597+ place_wisp::fs::EntryNode::File(file) => {
598+ SubfsEntryNode::File(Box::new(SubfsFile::new()
599+ .r#type(file.r#type)
600+ .blob(file.blob)
601+ .encoding(file.encoding)
602+ .mime_type(file.mime_type)
603+ .base64(file.base64)
604+ .build()))
605+ }
606+ place_wisp::fs::EntryNode::Directory(dir) => {
607+ SubfsEntryNode::Directory(Box::new(convert_fs_dir_to_subfs_dir(*dir)))
608+ }
609+ place_wisp::fs::EntryNode::Subfs(subfs) => {
610+ // Nested subfs in the directory we're converting
611+ // Note: subfs::Subfs doesn't have the 'flat' field - that's only in fs::Subfs
612+ SubfsEntryNode::Subfs(Box::new(place_wisp::subfs::Subfs::new()
613+ .r#type(subfs.r#type)
614+ .subject(subfs.subject)
615+ .build()))
616+ }
617+ place_wisp::fs::EntryNode::Unknown(unknown) => {
618+ SubfsEntryNode::Unknown(unknown)
619+ }
620+ };
621+622+ SubfsEntry::new()
623+ .name(entry.name)
624+ .node(node)
625+ .build()
626+ }).collect();
627+628+ SubfsDirectory::new()
629+ .r#type(fs_dir.r#type)
630+ .entries(subfs_entries)
631+ .build()
632+}
633+
+2-1
cli/src/place_wisp.rs
···3// This file was automatically generated from Lexicon schemas.
4// Any manual changes will be overwritten on the next regeneration.
56-pub mod fs;0
···3// This file was automatically generated from Lexicon schemas.
4// Any manual changes will be overwritten on the next regeneration.
56+pub mod fs;
7+pub mod subfs;