···2020tokio = { version = "1", features = ["full"] }
2121chrono = { version = "0.4", features = ["serde"] }
2222similar = "2.6"
2323+flate2 = "1.0"
23242425[dev-dependencies]
2526tempfile = "3"
+2-2
src/export.rs
···2626 .await?
2727 .ok_or_else(|| format!("site record not found: {}", rkey))?;
28282929- let site: SiteRecord = serde_json::from_value(record.value)
3030- .map_err(|e| format!("parse SiteRecord: {}", e))?;
2929+ let site: SiteRecord =
3030+ serde_json::from_value(record.value).map_err(|e| format!("parse SiteRecord: {}", e))?;
31313232 let mut files_exported = 0;
3333
+9-3
src/lib.rs
···1010pub mod pack;
1111pub mod pds_client;
1212pub mod save;
1313+pub mod sync;
1314pub mod types;
1415pub mod yrs_pds;
1516···1718pub use load::load;
1819pub use local_state::LocalState;
1920pub use merge::merge_sites;
2020-pub use pack::{create_pack, parse_pack, extract_entry, PackBlob, PackDataType, PackEntry};
2121+pub use pack::{
2222+ chunk_data, compress, create_compressed_pack, create_pack, decompress, extract_entry, is_gzip,
2323+ is_precompressed_extension, parse_pack, parse_pack_auto, reassemble_chunks, PackBlob,
2424+ PackDataType, PackEntry, CHUNK_SIZE,
2525+};
2126pub use pds_client::PdsClient;
2222-pub use save::save;
2323-pub use types::{COLLECTION, MANIFEST_KEY, FileKind};
2727+pub use save::{save, save_filtered};
2828+pub use sync::{sync_loop, SyncConfig, SyncCycleResult};
2929+pub use types::{FileKind, COLLECTION, MANIFEST_KEY};
+98-19
src/load.rs
···11//! Load a site from PDS into a local directory.
2233+use std::collections::HashMap;
34use std::path::Path;
4556use crate::pds_client::PdsClient;
···1011///
1112/// If a manifest exists, only loads files listed in the manifest.
1213/// Supports both text (Yrs CRDT) and binary files.
1414+/// Pack blobs are cached to avoid redundant downloads.
1315pub async fn load(
1416 client: &PdsClient,
1517 did: &str,
···2325 .await?
2426 .ok_or_else(|| format!("site record not found: {}", rkey))?;
25272626- let site: SiteRecord = serde_json::from_value(record.value)
2727- .map_err(|e| format!("parse SiteRecord: {}", e))?;
2828+ let site: SiteRecord =
2929+ serde_json::from_value(record.value).map_err(|e| format!("parse SiteRecord: {}", e))?;
28302931 // Determine which files to load
3032 let file_list = if let Some(manifest_entry) = site.files.get(MANIFEST_KEY) {
3131- // Manifest exists — only load listed files
3233 let manifest_doc = yrs_pds::file_entry_to_doc(manifest_entry, client, did).await?;
3334 let entries = yrs_pds::manifest_entries(&manifest_doc);
3435 if verbose {
···3637 }
3738 Some(entries)
3839 } else {
3939- // No manifest — load all files (legacy behavior)
4040 None
4141 };
42424343 let mut files_loaded = 0;
4444 let mut blobs_downloaded = 0;
45454646+ // Cache for pack blobs (keyed by CID) to avoid re-downloading
4747+ let mut pack_cache: HashMap<String, Vec<u8>> = HashMap::new();
4848+4649 for (rel_path, entry) in &site.files {
4747- // Skip the manifest entry itself
4850 if rel_path == MANIFEST_KEY {
4951 continue;
5052 }
51535252- // If manifest exists, only load files listed in it
5354 if let Some(ref manifest) = file_list {
5455 if !manifest.contains_key(rel_path) {
5556 if verbose {
···71727273 match entry.kind {
7374 FileKind::Binary => {
7474- // Binary file — download raw blob and write bytes
7575- let data = client
7676- .get_blob(did, entry.snapshot_blob.cid())
7777- .await?;
7878- blobs_downloaded += 1;
7575+ let data = get_blob_data_cached(
7676+ entry,
7777+ client,
7878+ did,
7979+ &mut pack_cache,
8080+ &mut blobs_downloaded,
8181+ )
8282+ .await?;
7983 std::fs::write(&output_path, &data)
8084 .map_err(|e| format!("write {:?}: {}", output_path, e))?;
8185 }
8286 FileKind::Text => {
8383- // Text file — reconstruct Yrs Doc and materialize
8484- let doc = yrs_pds::file_entry_to_doc(entry, client, did).await?;
8585- blobs_downloaded += 1;
8686- if entry.updates_blob.is_some() {
8787+ // If pack_ref exists, use cached pack blob extraction
8888+ if entry.pack_ref.is_some() {
8989+ let snapshot_data = get_blob_data_cached(
9090+ entry,
9191+ client,
9292+ did,
9393+ &mut pack_cache,
9494+ &mut blobs_downloaded,
9595+ )
9696+ .await?;
9797+ let doc = yrs_pds::doc_from_snapshot(&snapshot_data)?;
9898+ if let Some(ref updates_blob) = entry.updates_blob {
9999+ let updates_data = client.get_blob(did, updates_blob.cid()).await?;
100100+ blobs_downloaded += 1;
101101+ yrs_pds::apply_update(&doc, &updates_data)?;
102102+ }
103103+ let content = yrs_pds::materialize(&doc);
104104+ std::fs::write(&output_path, &content)
105105+ .map_err(|e| format!("write {:?}: {}", output_path, e))?;
106106+ } else {
107107+ // Direct blob download (no pack ref)
108108+ let doc = yrs_pds::file_entry_to_doc(entry, client, did).await?;
87109 blobs_downloaded += 1;
110110+ if entry.updates_blob.is_some() {
111111+ blobs_downloaded += 1;
112112+ }
113113+ let content = yrs_pds::materialize(&doc);
114114+ std::fs::write(&output_path, &content)
115115+ .map_err(|e| format!("write {:?}: {}", output_path, e))?;
88116 }
8989- let content = yrs_pds::materialize(&doc);
9090- std::fs::write(&output_path, &content)
9191- .map_err(|e| format!("write {:?}: {}", output_path, e))?;
92117 }
93118 }
94119···96121 }
9712298123 if verbose {
9999- eprintln!("pds-yrs: loaded {} file(s)", files_loaded);
124124+ eprintln!(
125125+ "pds-yrs: loaded {} file(s), {} blob(s) downloaded",
126126+ files_loaded, blobs_downloaded
127127+ );
100128 }
101129102130 Ok(LoadResult {
···104132 blobs_downloaded,
105133 })
106134}
135135+136136+/// Get blob data for a file entry, using pack cache when available.
137137+async fn get_blob_data_cached(
138138+ entry: &crate::types::FileEntry,
139139+ client: &PdsClient,
140140+ did: &str,
141141+ pack_cache: &mut HashMap<String, Vec<u8>>,
142142+ blobs_downloaded: &mut usize,
143143+) -> Result<Vec<u8>, String> {
144144+ if let Some(ref pack_ref) = entry.pack_ref {
145145+ let cid = pack_ref.blob.cid().to_string();
146146+147147+ // Fetch pack blob (or use cache), handling chunked packs
148148+ if !pack_cache.contains_key(&cid) {
149149+ let data = if let Some(ref chunks) = pack_ref.chunks {
150150+ // Reassemble chunked pack
151151+ let mut chunk_data = Vec::new();
152152+ for chunk_ref in chunks {
153153+ let chunk = client.get_blob(did, chunk_ref.cid()).await?;
154154+ *blobs_downloaded += 1;
155155+ chunk_data.push(chunk);
156156+ }
157157+ crate::pack::reassemble_chunks(&chunk_data)
158158+ } else {
159159+ let d = client.get_blob(did, &cid).await?;
160160+ *blobs_downloaded += 1;
161161+ d
162162+ };
163163+ pack_cache.insert(cid.clone(), data);
164164+ }
165165+166166+ let pack_data = pack_cache.get(&cid).unwrap();
167167+ let (_, blob_data) = crate::pack::parse_pack_auto(pack_data)?;
168168+169169+ let start = pack_ref.offset as usize;
170170+ let end = start + pack_ref.length as usize;
171171+ if end > blob_data.len() {
172172+ return Err(format!(
173173+ "pack_ref out of bounds: {}..{} in {} bytes",
174174+ start,
175175+ end,
176176+ blob_data.len()
177177+ ));
178178+ }
179179+ Ok(blob_data[start..end].to_vec())
180180+ } else {
181181+ let data = client.get_blob(did, entry.snapshot_blob.cid()).await?;
182182+ *blobs_downloaded += 1;
183183+ Ok(data)
184184+ }
185185+}
+11-16
src/local_state.rs
···4545 /// Save config.
4646 pub fn save_config(&self, config: &LocalConfig) -> Result<(), String> {
4747 let path = self.state_dir.join("_config.json");
4848- let json = serde_json::to_string_pretty(config)
4949- .map_err(|e| format!("serialize config: {}", e))?;
5050- std::fs::write(&path, json)
5151- .map_err(|e| format!("write config: {}", e))
4848+ let json =
4949+ serde_json::to_string_pretty(config).map_err(|e| format!("serialize config: {}", e))?;
5050+ std::fs::write(&path, json).map_err(|e| format!("write config: {}", e))
5251 }
53525453 /// Load config.
···5756 if !path.exists() {
5857 return Ok(None);
5958 }
6060- let json = std::fs::read_to_string(&path)
6161- .map_err(|e| format!("read config: {}", e))?;
6262- let config: LocalConfig = serde_json::from_str(&json)
6363- .map_err(|e| format!("parse config: {}", e))?;
5959+ let json = std::fs::read_to_string(&path).map_err(|e| format!("read config: {}", e))?;
6060+ let config: LocalConfig =
6161+ serde_json::from_str(&json).map_err(|e| format!("parse config: {}", e))?;
6462 Ok(Some(config))
6563 }
6664···9088 if !yrs_path.exists() {
9189 return Ok(None);
9290 }
9393- let data = std::fs::read(&yrs_path)
9494- .map_err(|e| format!("read doc state {}: {}", rel_path, e))?;
9191+ let data =
9292+ std::fs::read(&yrs_path).map_err(|e| format!("read doc state {}: {}", rel_path, e))?;
9593 let doc = yrs_pds::doc_from_snapshot(&data)?;
9694 Ok(Some(doc))
9795 }
···118116 if !yrs_path.exists() {
119117 return Ok(None);
120118 }
121121- let data = std::fs::read(&yrs_path)
122122- .map_err(|e| format!("read manifest state: {}", e))?;
119119+ let data = std::fs::read(&yrs_path).map_err(|e| format!("read manifest state: {}", e))?;
123120 let doc = yrs_pds::manifest_from_snapshot(&data)?;
124121 Ok(Some(doc))
125122 }
···153150 std::fs::create_dir_all(parent)
154151 .map_err(|e| format!("create dir for cid {}: {}", rel_path, e))?;
155152 }
156156- std::fs::write(&path, cid)
157157- .map_err(|e| format!("write snapshot cid {}: {}", rel_path, e))
153153+ std::fs::write(&path, cid).map_err(|e| format!("write snapshot cid {}: {}", rel_path, e))
158154 }
159155160156 /// Load the last-seen snapshot blob CID for a file.
···186182 dir: &Path,
187183 files: &mut Vec<String>,
188184 ) -> Result<(), String> {
189189- let entries = std::fs::read_dir(dir)
190190- .map_err(|e| format!("read dir {:?}: {}", dir, e))?;
185185+ let entries = std::fs::read_dir(dir).map_err(|e| format!("read dir {:?}: {}", dir, e))?;
191186 for entry in entries.flatten() {
192187 let path = entry.path();
193188 if path.is_dir() {