···11+ # Plan: Move FileIndex from Opaque JSON Blob to Yrs YMap
22+33+ ## Context
44+55+ Currently the `FileIndex` (`HashMap<String, FileEntry>`) is
66+ serialized as a single opaque JSON blob and stored in a pack.
77+ This means:
88+ - No CRDT semantics on the index itself
99+ - The entire index must be replaced on every save
1010+ - No per-file-entry merge capability
1111+1212+ ## Goal
1313+1414+ Store the FileIndex as a Yrs YMap (like the manifest), where
1515+ each key is a file path and each value is the JSON-serialized
1616+ FileEntry. This gives per-file last-writer-wins CRDT semantics.
1717+1818+ ## New Index Structure
1919+2020+ - Yrs Doc with a Map named `"index"`
2121+ - Keys: relative file paths (e.g. `"docs/readme.md"`)
2222+ - Values: JSON-serialized `FileEntry` strings
2323+2424+ ## File Changes
2525+2626+ ### 1. `src/yrs_pds.rs`
2727+ Add index YMap helpers (mirroring manifest pattern):
2828+ - `new_index_doc() -> Doc` — creates Doc with empty `"index"`
2929+ Map
3030+ - `index_set(doc: &Doc, path: &str, entry: &FileEntry)` —
3131+ inserts JSON-serialized entry
3232+ - `index_remove(doc: &Doc, path: &str)` — removes entry
3333+ - `index_entries(doc: &Doc) -> FileIndex` — reads all entries,
3434+ deserializes JSON values
3535+ - `index_from_snapshot(data: &[u8]) -> Result<Doc>` — restore
3636+ index Doc from snapshot
3737+3838+ Update `load_file_index`:
3939+ - Download snapshot data from `repo.index` PackRef
4040+ - Create Doc via `index_from_snapshot`
4141+ - Return `index_entries(&doc)`
4242+4343+ Add `load_file_index_doc` for save.rs (returns the Doc for
4444+ incremental updates).
4545+4646+ ### 2. `src/save.rs`
4747+ - Load existing index as a Yrs Doc (not just HashMap) via
4848+ `load_file_index_doc`
4949+ - After building `file_entries`, update the index Doc:
5050+ - `index_set()` for each entry in file_entries
5151+ - `index_remove()` for deleted files
5252+ - Encode index Doc as snapshot → store in pack as `__index__`
5353+ - Rest of flow unchanged (PackRef, blob refs, YrsRepo
5454+ construction)
5555+5656+ ### 3. `src/load.rs`
5757+ - No change needed — `load_file_index` still returns
5858+ `FileIndex` (HashMap)
5959+6060+ ### 4. `src/merge.rs`
6161+ - No change needed — each repo's index is loaded independently
6262+ - Future: could CRDT-merge index Docs directly
6363+6464+ ### 5. `src/export.rs`
6565+ - No change needed — uses `load_file_index` which returns
6666+ HashMap
6767+6868+ ### 6. `src/types.rs`
6969+ - No structural changes — `YrsRepo.index` remains `PackRef`
7070+ - `FileIndex` type alias unchanged
7171+7272+ ## Verification
7373+7474+ 1. `cargo build` — clean compile
7575+ 2. `cargo test` — all tests pass
7676+ 3. New test: `index_ymap_round_trip` — insert entries, encode
7777+ snapshot, restore, verify entries match
7878+ 4. New test: `index_ymap_crdt_merge` — concurrent adds from two
7979+ Docs merge correctly
8080+ 5. Run benchmarks to confirm no regression
+5-5
src/export.rs
···12121313/// Export a repo from PDS to plain text files.
1414///
1515-/// Reconstructs content from Yrs snapshots (for text files) or
1616-/// downloads raw blobs (for binary files).
1515+/// Reconstructs content from BaseYrsUpdates (for text files) or
1616+/// downloads raw data (for binary files).
1717pub async fn export(
1818 client: &PdsClient,
1919 did: &str,
···5858 .map_err(|e| format!("write {:?}: {}", output_path, e))?;
5959 }
6060 FileKind::Binary => {
6161- let pack_ref = entry.pack_ref.as_ref()
6262- .ok_or_else(|| format!("missing pack_ref for binary file: {}", rel_path))?;
6363- let data = yrs_pds::get_pack_ref_data(pack_ref, client, did).await?;
6161+ let item_ref = entry.base.as_ref()
6262+ .ok_or_else(|| format!("missing base for binary file: {}", rel_path))?;
6363+ let data = yrs_pds::fetch_pack_item(item_ref, client, did).await?;
6464 std::fs::write(&output_path, &data)
6565 .map_err(|e| format!("write {:?}: {}", output_path, e))?;
6666 }
···75757676 match entry.kind {
7777 FileKind::Binary => {
7878- let data = get_blob_data_cached(
7878+ let data = fetch_file_data_cached(
7979 entry,
8080 client,
8181 did,
···8787 .map_err(|e| format!("write {:?}: {}", output_path, e))?;
8888 }
8989 FileKind::Text => {
9090- let snapshot_data = get_blob_data_cached(
9090+ let base_data = fetch_file_data_cached(
9191 entry,
9292 client,
9393 did,
···9595 &mut blobs_downloaded,
9696 )
9797 .await?;
9898- let doc = yrs_pds::doc_from_snapshot(&snapshot_data)?;
9999- // Apply incremental updates from pack refs
9898+ let doc = yrs_pds::doc_from_base_update(&base_data)?;
9999+ // Apply incremental updates from pack item refs
100100 for update_ref in &entry.updates {
101101- let update_data = get_pack_ref_data_cached(
101101+ let update_data = fetch_pack_item_cached(
102102 update_ref,
103103 client,
104104 did,
···130130 })
131131}
132132133133-/// Extract data from a PackRef, using pack cache when available.
134134-async fn get_pack_ref_data_cached(
135135- pack_ref: &crate::types::PackRef,
133133+/// Extract data from a PackItemRef, using pack cache when available.
134134+async fn fetch_pack_item_cached(
135135+ item_ref: &crate::types::PackItemRef,
136136 client: &PdsClient,
137137 did: &str,
138138 pack_cache: &mut HashMap<String, Vec<u8>>,
139139 blobs_downloaded: &mut usize,
140140) -> Result<Vec<u8>, String> {
141141- let cid = pack_ref.blob.cid().to_string();
141141+ let cid = item_ref.blob.cid().to_string();
142142143143 if !pack_cache.contains_key(&cid) {
144144- let data = if let Some(ref chunks) = pack_ref.chunks {
144144+ let data = if let Some(ref chunks) = item_ref.chunks {
145145 let mut chunk_data = Vec::new();
146146 for chunk_ref in chunks {
147147 let chunk = client.get_blob(did, chunk_ref.cid()).await?;
···157157 pack_cache.insert(cid.clone(), data);
158158 }
159159160160- let pack_data = pack_cache.get(&cid).unwrap();
161161- let (_, blob_data) = crate::pack::parse_pack_auto(pack_data)?;
160160+ let raw_pack = pack_cache.get(&cid).unwrap();
161161+ let (_, pack_data) = crate::pack::parse_pack_auto(raw_pack)?;
162162163163- let start = pack_ref.offset as usize;
164164- let end = start + pack_ref.length as usize;
165165- if end > blob_data.len() {
163163+ let start = item_ref.offset as usize;
164164+ let end = start + item_ref.length as usize;
165165+ if end > pack_data.len() {
166166 return Err(format!(
167167- "pack_ref out of bounds: {}..{} in {} bytes",
168168- start, end, blob_data.len()
167167+ "pack item ref out of bounds: {}..{} in {} bytes",
168168+ start, end, pack_data.len()
169169 ));
170170 }
171171- Ok(blob_data[start..end].to_vec())
171171+ Ok(pack_data[start..end].to_vec())
172172}
173173174174-/// Get blob data for a file entry, using pack cache when available.
175175-async fn get_blob_data_cached(
174174+/// Get data for a file entry's base update, using pack cache when available.
175175+async fn fetch_file_data_cached(
176176 entry: &crate::types::FileEntry,
177177 client: &PdsClient,
178178 did: &str,
179179 pack_cache: &mut HashMap<String, Vec<u8>>,
180180 blobs_downloaded: &mut usize,
181181) -> Result<Vec<u8>, String> {
182182- let pack_ref = entry.pack_ref.as_ref()
183183- .ok_or("missing pack_ref on FileEntry")?;
184184- let cid = pack_ref.blob.cid().to_string();
182182+ let item_ref = entry.base.as_ref()
183183+ .ok_or("missing base on FileEntry")?;
184184+ let cid = item_ref.blob.cid().to_string();
185185186186 // Fetch pack blob (or use cache), handling chunked packs
187187 if !pack_cache.contains_key(&cid) {
188188- let data = if let Some(ref chunks) = pack_ref.chunks {
188188+ let data = if let Some(ref chunks) = item_ref.chunks {
189189 // Reassemble chunked pack
190190 let mut chunk_data = Vec::new();
191191 for chunk_ref in chunks {
···202202 pack_cache.insert(cid.clone(), data);
203203 }
204204205205- let pack_data = pack_cache.get(&cid).unwrap();
206206- let (_, blob_data) = crate::pack::parse_pack_auto(pack_data)?;
205205+ let raw_pack = pack_cache.get(&cid).unwrap();
206206+ let (_, pack_data) = crate::pack::parse_pack_auto(raw_pack)?;
207207208208- let start = pack_ref.offset as usize;
209209- let end = start + pack_ref.length as usize;
210210- if end > blob_data.len() {
208208+ let start = item_ref.offset as usize;
209209+ let end = start + item_ref.length as usize;
210210+ if end > pack_data.len() {
211211 return Err(format!(
212212- "pack_ref out of bounds: {}..{} in {} bytes",
212212+ "pack item ref out of bounds: {}..{} in {} bytes",
213213 start,
214214 end,
215215- blob_data.len()
215215+ pack_data.len()
216216 ));
217217 }
218218- Ok(blob_data[start..end].to_vec())
218218+ Ok(pack_data[start..end].to_vec())
219219}
+3-3
src/local_state.rs
···9191 std::fs::create_dir_all(parent)
9292 .map_err(|e| format!("create dir for {}: {}", rel_path, e))?;
9393 }
9494- let snapshot = yrs_pds::encode_snapshot(doc);
9494+ let snapshot = yrs_pds::encode_base_update(doc);
9595 std::fs::write(&yrs_path, &snapshot)
9696 .map_err(|e| format!("write doc state {}: {}", rel_path, e))?;
9797···112112 }
113113 let data =
114114 std::fs::read(&yrs_path).map_err(|e| format!("read doc state {}: {}", rel_path, e))?;
115115- let doc = yrs_pds::doc_from_snapshot(&data)?;
115115+ let doc = yrs_pds::doc_from_base_update(&data)?;
116116 Ok(Some(doc))
117117 }
118118···139139 return Ok(None);
140140 }
141141 let data = std::fs::read(&yrs_path).map_err(|e| format!("read manifest state: {}", e))?;
142142- let doc = yrs_pds::manifest_from_snapshot(&data)?;
142142+ let doc = yrs_pds::manifest_from_base_update(&data)?;
143143 Ok(Some(doc))
144144 }
145145
+30-30
src/merge.rs
···8899use crate::pack;
1010use crate::pds_client::PdsClient;
1111-use crate::types::{FileEntry, FileIndex, FileKind, PackRef, YrsRepo, COLLECTION, MANIFEST_KEY};
1111+use crate::types::{FileEntry, FileIndex, FileKind, PackItemRef, YrsRepo, COLLECTION, MANIFEST_KEY};
1212use crate::yrs_pds;
13131414/// Merge all repos for a project.
···101101 }
102102103103 // Pack cache: keyed by CID, avoids redundant blob downloads.
104104- // All files in a repo typically share 1-2 pack blobs, so this reduces
105105- // O(N × sites) blob downloads to O(pack_blobs × sites).
104104+ // All files in a repo typically share 1-2 packs, so this reduces
105105+ // O(N × sites) blob downloads to O(packs × sites).
106106 let mut pack_cache: HashMap<String, Vec<u8>> = HashMap::new();
107107108108 // CRDT-merge manifests
···267267 if repo_indices.len() == 1 {
268268 // Only one repo has this binary file — just download it
269269 let entry = &repos[repo_indices[0]].2[rel_path];
270270- let data = client.get_blob(did, entry.snapshot_blob.cid()).await?;
270270+ let data = client.get_blob(did, entry.base_blob.cid()).await?;
271271 let output_path = output_dir.join(rel_path);
272272 std::fs::write(&output_path, &data)
273273 .map_err(|e| format!("write {:?}: {}", output_path, e))?;
···279279 for &idx in repo_indices {
280280 let entry = &repos[idx].2[rel_path];
281281 cid_repo
282282- .entry(entry.snapshot_blob.cid().to_string())
282282+ .entry(entry.base_blob.cid().to_string())
283283 .or_default()
284284 .push(idx);
285285 }
···287287 if cid_repo.len() == 1 {
288288 // All repos have the same CID — no conflict
289289 let entry = &repos[repo_indices[0]].2[rel_path];
290290- let data = client.get_blob(did, entry.snapshot_blob.cid()).await?;
290290+ let data = client.get_blob(did, entry.base_blob.cid()).await?;
291291 let output_path = output_dir.join(rel_path);
292292 std::fs::write(&output_path, &data)
293293 .map_err(|e| format!("write {:?}: {}", output_path, e))?;
···325325 did: &str,
326326 pack_cache: &mut HashMap<String, Vec<u8>>,
327327) -> Result<Doc, String> {
328328- let snapshot_data = get_blob_data_cached(entry, client, did, pack_cache).await?;
329329- let doc = yrs_pds::doc_from_snapshot(&snapshot_data)?;
328328+ let base_data = fetch_file_data_cached(entry, client, did, pack_cache).await?;
329329+ let doc = yrs_pds::doc_from_base_update(&base_data)?;
330330331331 for update_ref in &entry.updates {
332332 let update_data =
333333- get_pack_ref_data_cached(update_ref, client, did, pack_cache).await?;
333333+ fetch_pack_item_cached(update_ref, client, did, pack_cache).await?;
334334 yrs_pds::apply_update(&doc, &update_data)?;
335335 }
336336337337 Ok(doc)
338338}
339339340340-/// Get blob data for a file entry's snapshot, using pack cache.
341341-async fn get_blob_data_cached(
340340+/// Get data for a file entry's base update, using pack cache.
341341+async fn fetch_file_data_cached(
342342 entry: &FileEntry,
343343 client: &PdsClient,
344344 did: &str,
345345 pack_cache: &mut HashMap<String, Vec<u8>>,
346346) -> Result<Vec<u8>, String> {
347347- let pack_ref = entry.pack_ref.as_ref()
348348- .ok_or("missing pack_ref on FileEntry")?;
349349- get_pack_ref_data_cached(pack_ref, client, did, pack_cache).await
347347+ let item_ref = entry.base.as_ref()
348348+ .ok_or("missing base on FileEntry")?;
349349+ fetch_pack_item_cached(item_ref, client, did, pack_cache).await
350350}
351351352352-/// Extract data from a PackRef, using pack cache to avoid redundant downloads.
353353-async fn get_pack_ref_data_cached(
354354- pack_ref: &PackRef,
352352+/// Extract data from a PackItemRef, using pack cache to avoid redundant downloads.
353353+async fn fetch_pack_item_cached(
354354+ item_ref: &PackItemRef,
355355 client: &PdsClient,
356356 did: &str,
357357 pack_cache: &mut HashMap<String, Vec<u8>>,
358358) -> Result<Vec<u8>, String> {
359359- let cid = pack_ref.blob.cid().to_string();
359359+ let cid = item_ref.blob.cid().to_string();
360360361361 if !pack_cache.contains_key(&cid) {
362362- let data = if let Some(ref chunks) = pack_ref.chunks {
362362+ let data = if let Some(ref chunks) = item_ref.chunks {
363363 let mut chunk_data = Vec::new();
364364 for chunk_ref in chunks {
365365 chunk_data.push(client.get_blob(did, chunk_ref.cid()).await?);
···371371 pack_cache.insert(cid.clone(), data);
372372 }
373373374374- let pack_data = pack_cache.get(&cid).unwrap();
375375- let (_, blob_data) = pack::parse_pack_auto(pack_data)?;
374374+ let raw_pack = pack_cache.get(&cid).unwrap();
375375+ let (_, pack_data) = pack::parse_pack_auto(raw_pack)?;
376376377377- let start = pack_ref.offset as usize;
378378- let end = start + pack_ref.length as usize;
379379- if end > blob_data.len() {
377377+ let start = item_ref.offset as usize;
378378+ let end = start + item_ref.length as usize;
379379+ if end > pack_data.len() {
380380 return Err(format!(
381381- "pack_ref out of bounds: {}..{} in {} bytes",
382382- start, end, blob_data.len()
381381+ "pack item ref out of bounds: {}..{} in {} bytes",
382382+ start, end, pack_data.len()
383383 ));
384384 }
385385- Ok(blob_data[start..end].to_vec())
385385+ Ok(pack_data[start..end].to_vec())
386386}
387387388388/// Generate a conflict filename: stem.repo_name.ext
···487487488488 // Simulate two repos editing the same text file
489489 let base_doc = crate::yrs_pds::doc_from_text("Hello world");
490490- let base_snapshot = crate::yrs_pds::encode_snapshot(&base_doc);
490490+ let base_snapshot = crate::yrs_pds::encode_base_update(&base_doc);
491491492492 // Repo A: adds " from Alice" at end
493493- let doc_a = crate::yrs_pds::doc_from_snapshot(&base_snapshot).unwrap();
493493+ let doc_a = crate::yrs_pds::doc_from_base_update(&base_snapshot).unwrap();
494494 {
495495 let text = doc_a.get_or_insert_text("content");
496496 let mut txn = doc_a.transact_mut();
···498498 }
499499500500 // Repo B: adds "Dear " at beginning
501501- let doc_b = crate::yrs_pds::doc_from_snapshot(&base_snapshot).unwrap();
501501+ let doc_b = crate::yrs_pds::doc_from_base_update(&base_snapshot).unwrap();
502502 {
503503 let text = doc_b.get_or_insert_text("content");
504504 let mut txn = doc_b.transact_mut();
+108-109
src/pack.rs
···11-//! Pack blob format: bundle multiple file blobs into a single upload.
11+//! Pack format: bundle multiple items into a single PDS blob upload.
22//!
33//! Format:
44//! ```text
55//! [4 bytes: index length (u32 LE)]
66-//! [index: JSON array of PackEntry]
77-//! [blob data: concatenated file data]
66+//! [index: JSON array of PackItem]
77+//! [pack data: concatenated item data]
88//! ```
991010use serde::{Deserialize, Serialize};
11111212-/// An entry in the pack index.
1212+/// An item in the pack index.
1313#[derive(Debug, Clone, Serialize, Deserialize)]
1414-pub struct PackEntry {
1414+pub struct PackItem {
1515 /// Relative file path.
1616 pub path: String,
1717- /// Byte offset within the blob data section.
1717+ /// Byte offset within the pack data section.
1818 pub offset: u64,
1919 /// Length of data.
2020 pub length: u64,
···2222 pub data_type: PackDataType,
2323}
24242525-/// Type of data in a pack entry.
2525+/// Type of data in a pack item.
2626#[derive(Debug, Clone, Serialize, Deserialize)]
2727-#[serde(rename_all = "lowercase")]
2827pub enum PackDataType {
2929- /// Full Yrs snapshot (encode_state_as_update_v1).
3030- Snapshot,
3131- /// Incremental Yrs update (encode_diff_v1).
3232- Update,
2828+ /// BaseYrsUpdate — full Yrs state encoded against empty StateVector.
2929+ BaseYrsUpdate,
3030+ /// YrsUpdate — incremental diff encoded against a non-empty StateVector.
3131+ YrsUpdate,
3332 /// Raw binary file data.
3433 Binary,
3534}
36353737-/// A pack blob ready for upload.
3838-pub struct PackBlob {
3939- /// The complete pack data (index + blob data).
3636+/// A pack ready for upload as a PDS blob.
3737+pub struct Pack {
3838+ /// The complete pack data (index + item data).
4039 pub data: Vec<u8>,
4141- /// The index entries (for building PackRefs after upload).
4242- pub entries: Vec<PackEntry>,
4040+ /// The index items (for building PackItemRefs after upload).
4141+ pub items: Vec<PackItem>,
4342}
44434545-/// Build a pack blob from a set of named data blobs.
4646-pub fn create_pack(items: &[(&str, &[u8], PackDataType)]) -> PackBlob {
4747- // Build index entries
4848- let mut entries = Vec::new();
4444+/// Build a pack from a set of named data items.
4545+pub fn create_pack(items: &[(&str, &[u8], PackDataType)]) -> Pack {
4646+ // Build index items
4747+ let mut pack_items = Vec::new();
4948 let mut offset: u64 = 0;
5049 for &(path, data, ref data_type) in items {
5151- entries.push(PackEntry {
5050+ pack_items.push(PackItem {
5251 path: path.to_string(),
5352 offset,
5453 length: data.len() as u64,
···5857 }
59586059 // Serialize index
6161- let index_json = serde_json::to_vec(&entries).unwrap_or_default();
6060+ let index_json = serde_json::to_vec(&pack_items).unwrap_or_default();
6261 let index_len = index_json.len() as u32;
63626463 // Build pack data
···6968 pack_data.extend_from_slice(data);
7069 }
71707272- PackBlob {
7171+ Pack {
7372 data: pack_data,
7474- entries,
7373+ items: pack_items,
7574 }
7675}
77767878-/// Parse a pack blob, returning the index and a reference to the data section.
7979-pub fn parse_pack(data: &[u8]) -> Result<(Vec<PackEntry>, &[u8]), String> {
7777+/// Parse a pack, returning the index and a reference to the data section.
7878+pub fn parse_pack(data: &[u8]) -> Result<(Vec<PackItem>, &[u8]), String> {
8079 if data.len() < 4 {
8180 return Err("pack too small".to_string());
8281 }
···9392 ));
9493 }
95949696- let entries: Vec<PackEntry> = serde_json::from_slice(&data[index_start..index_end])
9595+ let items: Vec<PackItem> = serde_json::from_slice(&data[index_start..index_end])
9796 .map_err(|e| format!("parse pack index: {}", e))?;
98979999- let blob_data = &data[index_end..];
100100- Ok((entries, blob_data))
9898+ let pack_data = &data[index_end..];
9999+ Ok((items, pack_data))
101100}
102101103103-/// Extract a single entry's data from the blob data section.
104104-pub fn extract_entry<'a>(entry: &PackEntry, blob_data: &'a [u8]) -> Result<&'a [u8], String> {
105105- let start = entry.offset as usize;
106106- let end = start + entry.length as usize;
107107- if end > blob_data.len() {
102102+/// Extract a single item's data from the pack data section.
103103+pub fn extract_item<'a>(item: &PackItem, pack_data: &'a [u8]) -> Result<&'a [u8], String> {
104104+ let start = item.offset as usize;
105105+ let end = start + item.length as usize;
106106+ if end > pack_data.len() {
108107 return Err(format!(
109109- "pack entry {} out of bounds: {}..{} in {} bytes",
110110- entry.path,
108108+ "pack item {} out of bounds: {}..{} in {} bytes",
109109+ item.path,
111110 start,
112111 end,
113113- blob_data.len()
112112+ pack_data.len()
114113 ));
115114 }
116116- Ok(&blob_data[start..end])
115115+ Ok(&pack_data[start..end])
117116}
118117119118/// Compress data with gzip.
···194193 )
195194}
196195197197-/// Create a compressed pack blob. Compresses the entire pack with gzip.
198198-pub fn create_compressed_pack(items: &[(&str, &[u8], PackDataType)]) -> PackBlob {
196196+/// Create a compressed pack. Compresses the entire pack with gzip.
197197+pub fn create_compressed_pack(items: &[(&str, &[u8], PackDataType)]) -> Pack {
199198 let pack = create_pack(items);
200199 let compressed = compress(&pack.data);
201200202201 // Only use compression if it actually saves space
203202 if compressed.len() < pack.data.len() {
204204- PackBlob {
203203+ Pack {
205204 data: compressed,
206206- entries: pack.entries,
205205+ items: pack.items,
207206 }
208207 } else {
209208 pack
210209 }
211210}
212211213213-/// Parse a pack blob, auto-detecting gzip compression.
214214-pub fn parse_pack_auto(data: &[u8]) -> Result<(Vec<PackEntry>, Vec<u8>), String> {
212212+/// Parse a pack, auto-detecting gzip compression.
213213+pub fn parse_pack_auto(data: &[u8]) -> Result<(Vec<PackItem>, Vec<u8>), String> {
215214 let decompressed;
216215 let actual_data = if is_gzip(data) {
217216 decompressed = decompress(data)?;
···220219 data
221220 };
222221223223- let (entries, blob_data) = parse_pack(actual_data)?;
224224- Ok((entries, blob_data.to_vec()))
222222+ let (items, pack_data) = parse_pack(actual_data)?;
223223+ Ok((items, pack_data.to_vec()))
225224}
226225227226#[cfg(test)]
···233232 let items: Vec<(&str, &[u8], PackDataType)> = vec![
234233 (
235234 "docs/index.md",
236236- b"snapshot data for index",
237237- PackDataType::Snapshot,
235235+ b"base update data for index",
236236+ PackDataType::BaseYrsUpdate,
238237 ),
239238 (
240239 "docs/about.md",
241241- b"snapshot data for about",
242242- PackDataType::Snapshot,
240240+ b"base update data for about",
241241+ PackDataType::BaseYrsUpdate,
243242 ),
244243 ("images/logo.png", b"raw png bytes", PackDataType::Binary),
245244 ];
246245247246 let pack = create_pack(&items);
248247 assert!(!pack.data.is_empty());
249249- assert_eq!(pack.entries.len(), 3);
248248+ assert_eq!(pack.items.len(), 3);
250249251250 // Parse it back
252252- let (entries, blob_data) = parse_pack(&pack.data).unwrap();
253253- assert_eq!(entries.len(), 3);
251251+ let (parsed_items, pack_data) = parse_pack(&pack.data).unwrap();
252252+ assert_eq!(parsed_items.len(), 3);
254253255255- // Extract each entry
256256- let index_data = extract_entry(&entries[0], blob_data).unwrap();
257257- assert_eq!(index_data, b"snapshot data for index");
254254+ // Extract each item
255255+ let index_data = extract_item(&parsed_items[0], pack_data).unwrap();
256256+ assert_eq!(index_data, b"base update data for index");
258257259259- let about_data = extract_entry(&entries[1], blob_data).unwrap();
260260- assert_eq!(about_data, b"snapshot data for about");
258258+ let about_data = extract_item(&parsed_items[1], pack_data).unwrap();
259259+ assert_eq!(about_data, b"base update data for about");
261260262262- let logo_data = extract_entry(&entries[2], blob_data).unwrap();
261261+ let logo_data = extract_item(&parsed_items[2], pack_data).unwrap();
263262 assert_eq!(logo_data, b"raw png bytes");
264263 }
265264···267266 fn pack_empty() {
268267 let items: Vec<(&str, &[u8], PackDataType)> = vec![];
269268 let pack = create_pack(&items);
270270- let (entries, _) = parse_pack(&pack.data).unwrap();
271271- assert_eq!(entries.len(), 0);
269269+ let (parsed_items, _) = parse_pack(&pack.data).unwrap();
270270+ assert_eq!(parsed_items.len(), 0);
272271 }
273272274273 #[test]
275275- fn pack_single_large_entry() {
274274+ fn pack_single_large_item() {
276275 let big_data = vec![42u8; 100_000];
277276 let items: Vec<(&str, &[u8], PackDataType)> =
278277 vec![("big.bin", &big_data, PackDataType::Binary)];
279278 let pack = create_pack(&items);
280280- let (entries, blob_data) = parse_pack(&pack.data).unwrap();
281281- let extracted = extract_entry(&entries[0], blob_data).unwrap();
279279+ let (parsed_items, pack_data) = parse_pack(&pack.data).unwrap();
280280+ let extracted = extract_item(&parsed_items[0], pack_data).unwrap();
282281 assert_eq!(extracted.len(), 100_000);
283282 assert_eq!(extracted[0], 42);
284283 }
···302301 // Use repetitive data so compression actually helps
303302 let text = "Hello world! ".repeat(100);
304303 let items: Vec<(&str, &[u8], PackDataType)> =
305305- vec![("file.md", text.as_bytes(), PackDataType::Snapshot)];
304304+ vec![("file.md", text.as_bytes(), PackDataType::BaseYrsUpdate)];
306305 let pack = create_compressed_pack(&items);
307306 // Compressed should be smaller for repetitive data
308307 let uncompressed = create_pack(&items);
309308 assert!(pack.data.len() < uncompressed.data.len());
310309311310 // Parse with auto-detection
312312- let (entries, blob_data) = parse_pack_auto(&pack.data).unwrap();
313313- assert_eq!(entries.len(), 1);
314314- let extracted = extract_entry(&entries[0], &blob_data).unwrap();
311311+ let (parsed_items, pack_data) = parse_pack_auto(&pack.data).unwrap();
312312+ assert_eq!(parsed_items.len(), 1);
313313+ let extracted = extract_item(&parsed_items[0], &pack_data).unwrap();
315314 assert_eq!(extracted, text.as_bytes());
316315 }
317316318317 #[test]
319318 fn uncompressed_pack_auto_parse() {
320319 let items: Vec<(&str, &[u8], PackDataType)> =
321321- vec![("file.md", b"short", PackDataType::Snapshot)];
320320+ vec![("file.md", b"short", PackDataType::BaseYrsUpdate)];
322321 let pack = create_pack(&items);
323322 // Should work fine with parse_pack_auto even without compression
324324- let (entries, blob_data) = parse_pack_auto(&pack.data).unwrap();
325325- assert_eq!(entries.len(), 1);
326326- let extracted = extract_entry(&entries[0], &blob_data).unwrap();
323323+ let (parsed_items, pack_data) = parse_pack_auto(&pack.data).unwrap();
324324+ assert_eq!(parsed_items.len(), 1);
325325+ let extracted = extract_item(&parsed_items[0], &pack_data).unwrap();
327326 assert_eq!(extracted, b"short");
328327 }
329328···356355357356 #[test]
358357 fn pack_mixed_data_types() {
359359- // pack containing snapshot, update, and binary entries
358358+ // pack containing base update, incremental update, and binary items
360359 let items: Vec<(&str, &[u8], PackDataType)> = vec![
361361- ("index.md", b"yrs snapshot bytes", PackDataType::Snapshot),
362362- ("index.md.update", b"yrs update diff", PackDataType::Update),
360360+ ("index.md", b"yrs base update bytes", PackDataType::BaseYrsUpdate),
361361+ ("index.md.update", b"yrs incremental update", PackDataType::YrsUpdate),
363362 ("logo.png", b"\x89PNG raw data", PackDataType::Binary),
364363 ];
365364 let pack = create_pack(&items);
366366- let (entries, blob_data) = parse_pack(&pack.data).unwrap();
365365+ let (parsed_items, pack_data) = parse_pack(&pack.data).unwrap();
367366368368- assert_eq!(entries.len(), 3);
369369- assert!(matches!(entries[0].data_type, PackDataType::Snapshot));
370370- assert!(matches!(entries[1].data_type, PackDataType::Update));
371371- assert!(matches!(entries[2].data_type, PackDataType::Binary));
367367+ assert_eq!(parsed_items.len(), 3);
368368+ assert!(matches!(parsed_items[0].data_type, PackDataType::BaseYrsUpdate));
369369+ assert!(matches!(parsed_items[1].data_type, PackDataType::YrsUpdate));
370370+ assert!(matches!(parsed_items[2].data_type, PackDataType::Binary));
372371373372 assert_eq!(
374374- extract_entry(&entries[0], blob_data).unwrap(),
375375- b"yrs snapshot bytes"
373373+ extract_item(&parsed_items[0], pack_data).unwrap(),
374374+ b"yrs base update bytes"
376375 );
377376 assert_eq!(
378378- extract_entry(&entries[1], blob_data).unwrap(),
379379- b"yrs update diff"
377377+ extract_item(&parsed_items[1], pack_data).unwrap(),
378378+ b"yrs incremental update"
380379 );
381380 assert_eq!(
382382- extract_entry(&entries[2], blob_data).unwrap(),
381381+ extract_item(&parsed_items[2], pack_data).unwrap(),
383382 b"\x89PNG raw data"
384383 );
385384 }
···403402 let uncompressed = create_pack(&items);
404403 // For tiny data, create_compressed_pack should fall back to uncompressed
405404 // (or use compressed if smaller — either way, parse_pack_auto handles both)
406406- let (entries, blob_data) = parse_pack_auto(&compressed.data).unwrap();
407407- assert_eq!(entries.len(), 1);
408408- assert_eq!(extract_entry(&entries[0], &blob_data).unwrap(), b"x");
405405+ let (parsed_items, pack_data) = parse_pack_auto(&compressed.data).unwrap();
406406+ assert_eq!(parsed_items.len(), 1);
407407+ assert_eq!(extract_item(&parsed_items[0], &pack_data).unwrap(), b"x");
409408 // Just verify uncompressed also works
410410- let (entries2, blob_data2) = parse_pack_auto(&uncompressed.data).unwrap();
411411- assert_eq!(extract_entry(&entries2[0], &blob_data2).unwrap(), b"x");
409409+ let (parsed_items2, pack_data2) = parse_pack_auto(&uncompressed.data).unwrap();
410410+ assert_eq!(extract_item(&parsed_items2[0], &pack_data2).unwrap(), b"x");
412411 }
413412414413 #[test]
···442441 }
443442444443 #[test]
445445- fn pack_ref_extraction_exact_offsets() {
444444+ fn pack_item_extraction_exact_offsets() {
446445 // verify offset/length slicing produces correct data
447446 let items: Vec<(&str, &[u8], PackDataType)> = vec![
448448- ("a.txt", b"AAAA", PackDataType::Snapshot),
449449- ("b.txt", b"BBBBBB", PackDataType::Snapshot),
450450- ("c.txt", b"CC", PackDataType::Snapshot),
447447+ ("a.txt", b"AAAA", PackDataType::BaseYrsUpdate),
448448+ ("b.txt", b"BBBBBB", PackDataType::BaseYrsUpdate),
449449+ ("c.txt", b"CC", PackDataType::BaseYrsUpdate),
451450 ];
452451 let pack = create_pack(&items);
453453- let (entries, blob_data) = parse_pack(&pack.data).unwrap();
452452+ let (parsed_items, pack_data) = parse_pack(&pack.data).unwrap();
454453455454 // check offsets are correct
456456- assert_eq!(entries[0].offset, 0);
457457- assert_eq!(entries[0].length, 4);
458458- assert_eq!(entries[1].offset, 4);
459459- assert_eq!(entries[1].length, 6);
460460- assert_eq!(entries[2].offset, 10);
461461- assert_eq!(entries[2].length, 2);
455455+ assert_eq!(parsed_items[0].offset, 0);
456456+ assert_eq!(parsed_items[0].length, 4);
457457+ assert_eq!(parsed_items[1].offset, 4);
458458+ assert_eq!(parsed_items[1].length, 6);
459459+ assert_eq!(parsed_items[2].offset, 10);
460460+ assert_eq!(parsed_items[2].length, 2);
462461463462 // manual slice verification
464464- let start = entries[1].offset as usize;
465465- let end = start + entries[1].length as usize;
466466- assert_eq!(&blob_data[start..end], b"BBBBBB");
463463+ let start = parsed_items[1].offset as usize;
464464+ let end = start + parsed_items[1].length as usize;
465465+ assert_eq!(&pack_data[start..end], b"BBBBBB");
467466 }
468467469468 #[test]
470470- fn extract_entry_out_of_bounds() {
471471- let entry = PackEntry {
469469+ fn extract_item_out_of_bounds() {
470470+ let item = PackItem {
472471 path: "bad.bin".to_string(),
473472 offset: 100,
474473 length: 50,
475474 data_type: PackDataType::Binary,
476475 };
477477- let blob_data = b"short";
478478- assert!(extract_entry(&entry, blob_data).is_err());
476476+ let pack_data = b"short";
477477+ assert!(extract_item(&item, pack_data).is_err());
479478 }
480479}
+98-98
src/save.rs
···66use crate::pack::{self, PackDataType};
77use crate::pds_client::PdsClient;
88use crate::types::{
99- collect_blob_refs, BlobRef, Collaborator, FileEntry, FileIndex, FileKind, PackRef, SaveResult,
99+ collect_blob_refs, BlobRef, Collaborator, FileEntry, FileIndex, FileKind, PackItemRef, SaveResult,
1010 YrsRepo, COLLECTION, MANIFEST_KEY,
1111};
1212use crate::yrs_pds;
13131414/// Compaction threshold: when any file's updates_count reaches this,
1515-/// the entire repo is compacted (all files get fresh snapshots in one pack).
1515+/// the entire repo is compacted (all files get fresh BaseYrsUpdates in one pack).
1616const COMPACTION_THRESHOLD: u32 = 10;
17171818-/// Pending blob to be packed into a single upload.
1919-struct PendingBlob {
1818+/// Pending item to be packed into a single upload.
1919+struct PendingItem {
2020 path: String,
2121 data: Vec<u8>,
2222 data_type: PackDataType,
2323}
24242525-/// Whether a pending blob is an incremental update (appended to updates list)
2626-/// or a snapshot (replaces pack_ref).
2525+/// Whether a pending item is a base update (replaces base)
2626+/// or an incremental update (appended to updates list).
2727#[derive(Clone, PartialEq)]
2828enum PendingKind {
2929- /// Full snapshot — will become the new pack_ref.
3030- Snapshot,
3131- /// Incremental update — will be appended to updates list.
3232- Update,
2929+ /// BaseYrsUpdate — will become the new base.
3030+ Base,
3131+ /// Incremental YrsUpdate — will be appended to updates list.
3232+ Incremental,
3333}
34343535/// Save a directory to PDS.
3636///
3737/// Maintains a CRDT manifest (Yrs Map) tracking all files. Supports both
3838/// text files (Yrs CRDT merge) and binary files (raw blob storage).
3939-/// All blobs are bundled into a single pack blob upload.
3939+/// All items are bundled into a single pack uploaded as a PDS blob.
4040pub async fn save(
4141 dir: &Path,
4242 client: &PdsClient,
···113113 };
114114115115 let mut file_entries: HashMap<String, FileEntry> = HashMap::new();
116116- let mut pending_blobs: Vec<PendingBlob> = Vec::new();
117117- // Track whether each pending blob is a snapshot or incremental update
116116+ let mut pending_items: Vec<PendingItem> = Vec::new();
117117+ // Track whether each pending item is a base update or incremental update
118118 let mut pending_kinds: HashMap<String, PendingKind> = HashMap::new();
119119 let mut files_uploaded = 0;
120120 let mut files_skipped = 0;
···124124 let local_paths: std::collections::HashSet<String> =
125125 local_files.iter().map(|(p, _, _)| p.clone()).collect();
126126127127- // First pass: determine what needs uploading, collect blob data
127127+ // First pass: determine what needs uploading, collect data
128128 for (rel_path, file_data, kind) in &local_files {
129129 match kind {
130130 FileKind::Text => {
···134134 // Not valid UTF-8 — treat as binary
135135 let hash = hex_hash(file_data);
136136 yrs_pds::manifest_insert(&manifest_doc, rel_path, &FileKind::Binary);
137137- pending_blobs.push(PendingBlob {
137137+ pending_items.push(PendingItem {
138138 path: rel_path.clone(),
139139 data: file_data.clone(),
140140 data_type: PackDataType::Binary,
141141 });
142142- pending_kinds.insert(rel_path.clone(), PendingKind::Snapshot);
142142+ pending_kinds.insert(rel_path.clone(), PendingKind::Base);
143143 file_entries.insert(rel_path.clone(), placeholder_binary_entry(&hash));
144144 files_uploaded += 1;
145145 continue;
···172172 let diff = yrs_pds::encode_diff(&doc, &old_sv_bytes)?;
173173 let sv = yrs_pds::encode_state_vector(&doc);
174174 let materialized = yrs_pds::materialize(&doc);
175175- pending_blobs.push(PendingBlob {
175175+ pending_items.push(PendingItem {
176176 path: rel_path.clone(),
177177 data: diff,
178178- data_type: PackDataType::Update,
178178+ data_type: PackDataType::YrsUpdate,
179179 });
180180- pending_kinds.insert(rel_path.clone(), PendingKind::Update);
181181- // Keep existing snapshot/pack_ref, will append update
180180+ pending_kinds.insert(rel_path.clone(), PendingKind::Incremental);
181181+ // Keep existing base_blob/base, will append update
182182 file_entries.insert(
183183 rel_path.clone(),
184184 FileEntry {
185185 content_hash: hex_hash(materialized.as_bytes()),
186186- snapshot_blob: existing_entry.snapshot_blob.clone(),
186186+ base_blob: existing_entry.base_blob.clone(),
187187 state_vector: yrs_pds::base64_encode(&sv),
188188 updates: existing_entry.updates.clone(),
189189 updates_count: existing_entry.updates_count + 1,
190190- snapshot_at: existing_entry.snapshot_at.clone(),
190190+ base_at: existing_entry.base_at.clone(),
191191 kind: FileKind::Text,
192192- pack_ref: existing_entry.pack_ref.clone(),
192192+ base: existing_entry.base.clone(),
193193 conflict_source: None,
194194 },
195195 );
···207207 }
208208209209 if verbose {
210210- eprintln!("pds-yrs: full snapshot {}", rel_path);
210210+ eprintln!("pds-yrs: full base update {}", rel_path);
211211 }
212212 } else {
213213 yrs_pds::manifest_insert(&manifest_doc, rel_path, &FileKind::Text);
···216216 yrs_pds::manifest_insert(&manifest_doc, rel_path, &FileKind::Text);
217217 }
218218219219- // Full snapshot
219219+ // Full base update
220220 let doc = yrs_pds::doc_from_text(content);
221221- let snapshot = yrs_pds::encode_snapshot(&doc);
221221+ let base_update = yrs_pds::encode_base_update(&doc);
222222 let sv = yrs_pds::encode_state_vector(&doc);
223223- pending_blobs.push(PendingBlob {
223223+ pending_items.push(PendingItem {
224224 path: rel_path.clone(),
225225- data: snapshot,
226226- data_type: PackDataType::Snapshot,
225225+ data: base_update,
226226+ data_type: PackDataType::BaseYrsUpdate,
227227 });
228228- pending_kinds.insert(rel_path.clone(), PendingKind::Snapshot);
228228+ pending_kinds.insert(rel_path.clone(), PendingKind::Base);
229229 file_entries.insert(
230230 rel_path.clone(),
231231 FileEntry {
232232 content_hash: hex_hash(content.as_bytes()),
233233- snapshot_blob: placeholder_blob_ref(),
233233+ base_blob: placeholder_blob_ref(),
234234 state_vector: yrs_pds::base64_encode(&sv),
235235 updates: vec![],
236236 updates_count: 0,
237237- snapshot_at: chrono::Utc::now().to_rfc3339(),
237237+ base_at: chrono::Utc::now().to_rfc3339(),
238238 kind: FileKind::Text,
239239- pack_ref: None,
239239+ base: None,
240240 conflict_source: None,
241241 },
242242 );
···268268 }
269269270270 let hash = hex_hash(file_data);
271271- pending_blobs.push(PendingBlob {
271271+ pending_items.push(PendingItem {
272272 path: rel_path.clone(),
273273 data: file_data.clone(),
274274 data_type: PackDataType::Binary,
275275 });
276276- pending_kinds.insert(rel_path.clone(), PendingKind::Snapshot);
276276+ pending_kinds.insert(rel_path.clone(), PendingKind::Base);
277277 file_entries.insert(rel_path.clone(), placeholder_binary_entry(&hash));
278278 files_uploaded += 1;
279279 if verbose {
···283283 }
284284 }
285285286286- // Compaction: if any file crossed the threshold, re-snapshot ALL files
286286+ // Compaction: if any file crossed the threshold, re-create BaseYrsUpdates for ALL files
287287 if needs_compaction {
288288 if verbose {
289289- eprintln!("pds-yrs: compaction triggered — re-snapshotting all files");
289289+ eprintln!("pds-yrs: compaction triggered — re-creating base updates for all files");
290290 }
291291- pending_blobs.clear();
291291+ pending_items.clear();
292292 pending_kinds.clear();
293293 file_entries.clear();
294294 files_uploaded = 0;
···301301 Ok(s) => s,
302302 Err(_) => {
303303 let hash = hex_hash(file_data);
304304- pending_blobs.push(PendingBlob {
304304+ pending_items.push(PendingItem {
305305 path: rel_path.clone(),
306306 data: file_data.clone(),
307307 data_type: PackDataType::Binary,
308308 });
309309- pending_kinds.insert(rel_path.clone(), PendingKind::Snapshot);
309309+ pending_kinds.insert(rel_path.clone(), PendingKind::Base);
310310 file_entries.insert(rel_path.clone(), placeholder_binary_entry(&hash));
311311 files_uploaded += 1;
312312 continue;
···326326 } else {
327327 yrs_pds::doc_from_text(content)
328328 };
329329- let snapshot = yrs_pds::encode_snapshot(&doc);
329329+ let base_update = yrs_pds::encode_base_update(&doc);
330330 let sv = yrs_pds::encode_state_vector(&doc);
331331 let materialized = yrs_pds::materialize(&doc);
332332- pending_blobs.push(PendingBlob {
332332+ pending_items.push(PendingItem {
333333 path: rel_path.clone(),
334334- data: snapshot,
335335- data_type: PackDataType::Snapshot,
334334+ data: base_update,
335335+ data_type: PackDataType::BaseYrsUpdate,
336336 });
337337- pending_kinds.insert(rel_path.clone(), PendingKind::Snapshot);
337337+ pending_kinds.insert(rel_path.clone(), PendingKind::Base);
338338 file_entries.insert(
339339 rel_path.clone(),
340340 FileEntry {
341341 content_hash: hex_hash(materialized.as_bytes()),
342342- snapshot_blob: placeholder_blob_ref(),
342342+ base_blob: placeholder_blob_ref(),
343343 state_vector: yrs_pds::base64_encode(&sv),
344344 updates: vec![],
345345 updates_count: 0,
346346- snapshot_at: chrono::Utc::now().to_rfc3339(),
346346+ base_at: chrono::Utc::now().to_rfc3339(),
347347 kind: FileKind::Text,
348348- pack_ref: None,
348348+ base: None,
349349 conflict_source: None,
350350 },
351351 );
···353353 }
354354 FileKind::Binary => {
355355 let hash = hex_hash(file_data);
356356- pending_blobs.push(PendingBlob {
356356+ pending_items.push(PendingItem {
357357 path: rel_path.clone(),
358358 data: file_data.clone(),
359359 data_type: PackDataType::Binary,
360360 });
361361- pending_kinds.insert(rel_path.clone(), PendingKind::Snapshot);
361361+ pending_kinds.insert(rel_path.clone(), PendingKind::Base);
362362 file_entries.insert(rel_path.clone(), placeholder_binary_entry(&hash));
363363 files_uploaded += 1;
364364 }
···377377 }
378378 }
379379380380- // Add manifest snapshot to pending blobs
381381- let manifest_snapshot = yrs_pds::encode_snapshot(&manifest_doc);
380380+ // Add manifest base update to pending items
381381+ let manifest_base = yrs_pds::encode_base_update(&manifest_doc);
382382 let manifest_sv = yrs_pds::encode_state_vector(&manifest_doc);
383383- pending_blobs.push(PendingBlob {
383383+ pending_items.push(PendingItem {
384384 path: MANIFEST_KEY.to_string(),
385385- data: manifest_snapshot,
386386- data_type: PackDataType::Snapshot,
385385+ data: manifest_base,
386386+ data_type: PackDataType::BaseYrsUpdate,
387387 });
388388- pending_kinds.insert(MANIFEST_KEY.to_string(), PendingKind::Snapshot);
388388+ pending_kinds.insert(MANIFEST_KEY.to_string(), PendingKind::Base);
389389390390- // Upload all blobs as a single pack
390390+ // Upload all items as a single pack (becomes a PDS blob)
391391 let total_bytes;
392392- if pending_blobs.is_empty() {
392392+ if pending_items.is_empty() {
393393 total_bytes = 0;
394394 let manifest_entry = yrs_pds::doc_to_file_entry(&manifest_doc, client, did).await?;
395395 file_entries.insert(MANIFEST_KEY.to_string(), manifest_entry);
396396 } else {
397397- // Build pack blob
398398- let items: Vec<(&str, &[u8], PackDataType)> = pending_blobs
397397+ // Build pack
398398+ let items: Vec<(&str, &[u8], PackDataType)> = pending_items
399399 .iter()
400400- .map(|pb| (pb.path.as_str(), pb.data.as_slice(), pb.data_type.clone()))
400400+ .map(|pi| (pi.path.as_str(), pi.data.as_slice(), pi.data_type.clone()))
401401 .collect();
402402- let pack_blob = pack::create_compressed_pack(&items);
403403- let is_compressed = pack::is_gzip(&pack_blob.data);
404404- total_bytes = pack_blob.data.len() as u64;
402402+ let pack = pack::create_compressed_pack(&items);
403403+ let is_compressed = pack::is_gzip(&pack.data);
404404+ total_bytes = pack.data.len() as u64;
405405406406- // Upload pack blob — chunk if larger than ATProto limit
407407- let (blob_ref, chunk_refs) = if pack_blob.data.len() > pack::CHUNK_SIZE {
408408- let chunks = pack::chunk_data(&pack_blob.data);
406406+ // Upload pack as PDS blob — chunk if larger than ATProto limit
407407+ let (blob_ref, chunk_refs) = if pack.data.len() > pack::CHUNK_SIZE {
408408+ let chunks = pack::chunk_data(&pack.data);
409409 let mut refs = Vec::new();
410410 for (i, chunk) in chunks.iter().enumerate() {
411411 let r = client.upload_blob(chunk.clone()).await?;
···422422 let primary = refs[0].clone();
423423 (primary, Some(refs))
424424 } else {
425425- let r = client.upload_blob(pack_blob.data).await?;
425425+ let r = client.upload_blob(pack.data).await?;
426426 (r, None)
427427 };
428428429429 if verbose {
430430 eprintln!(
431431- "pds-yrs: uploaded pack blob ({} bytes, {} entries{})",
431431+ "pds-yrs: uploaded pack ({} bytes, {} items{})",
432432 total_bytes,
433433- pack_blob.entries.len(),
433433+ pack.items.len(),
434434 if chunk_refs.is_some() {
435435 ", chunked"
436436 } else {
···439439 );
440440 }
441441442442- // Update file entries with pack refs
443443- for entry in &pack_blob.entries {
444444- let pack_ref = PackRef {
442442+ // Update file entries with pack item refs
443443+ for item in &pack.items {
444444+ let item_ref = PackItemRef {
445445 blob: blob_ref.clone(),
446446- offset: entry.offset,
447447- length: entry.length,
446446+ offset: item.offset,
447447+ length: item.length,
448448 compressed: is_compressed,
449449 chunks: chunk_refs.clone(),
450450 };
451451452452- if entry.path == MANIFEST_KEY {
452452+ if item.path == MANIFEST_KEY {
453453 file_entries.insert(
454454 MANIFEST_KEY.to_string(),
455455 FileEntry {
456456 content_hash: String::new(),
457457- snapshot_blob: blob_ref.clone(),
457457+ base_blob: blob_ref.clone(),
458458 state_vector: yrs_pds::base64_encode(&manifest_sv),
459459 updates: vec![],
460460 updates_count: 0,
461461- snapshot_at: chrono::Utc::now().to_rfc3339(),
461461+ base_at: chrono::Utc::now().to_rfc3339(),
462462 kind: FileKind::Text,
463463- pack_ref: Some(pack_ref),
463463+ base: Some(item_ref),
464464 conflict_source: None,
465465 },
466466 );
467467- } else if let Some(fe) = file_entries.get_mut(&entry.path) {
468468- let kind = pending_kinds.get(&entry.path).cloned().unwrap_or(PendingKind::Snapshot);
467467+ } else if let Some(fe) = file_entries.get_mut(&item.path) {
468468+ let kind = pending_kinds.get(&item.path).cloned().unwrap_or(PendingKind::Base);
469469 match kind {
470470- PendingKind::Snapshot => {
471471- // Full snapshot — replace pack_ref, clear updates
472472- fe.snapshot_blob = blob_ref.clone();
473473- fe.pack_ref = Some(pack_ref);
470470+ PendingKind::Base => {
471471+ // BaseYrsUpdate — replace base, clear updates
472472+ fe.base_blob = blob_ref.clone();
473473+ fe.base = Some(item_ref);
474474 fe.updates.clear();
475475 }
476476- PendingKind::Update => {
476476+ PendingKind::Incremental => {
477477 // Incremental update — append to updates list
478478- fe.updates.push(pack_ref);
478478+ fe.updates.push(item_ref);
479479 }
480480 }
481481 }
···486486 let index_json = serde_json::to_vec(&file_entries)
487487 .map_err(|e| format!("serialize FileIndex: {}", e))?;
488488 let index_items: Vec<(&str, &[u8], PackDataType)> =
489489- vec![("__index__", &index_json, PackDataType::Snapshot)];
489489+ vec![("__index__", &index_json, PackDataType::BaseYrsUpdate)];
490490 let index_pack = pack::create_compressed_pack(&index_items);
491491 let index_is_compressed = pack::is_gzip(&index_pack.data);
492492 let index_blob_ref = client.upload_blob(index_pack.data).await?;
493493- let index_entry = &index_pack.entries[0];
494494- let index_pack_ref = PackRef {
493493+ let index_item = &index_pack.items[0];
494494+ let index_ref = PackItemRef {
495495 blob: index_blob_ref.clone(),
496496- offset: index_entry.offset,
497497- length: index_entry.length,
496496+ offset: index_item.offset,
497497+ length: index_item.length,
498498 compressed: index_is_compressed,
499499 chunks: None,
500500 };
···521521 let now = chrono::Utc::now().to_rfc3339();
522522 let record = YrsRepo {
523523 name: project_name.to_string(),
524524- index: index_pack_ref,
524524+ index: index_ref,
525525 blobs: all_blobs,
526526 updated_at: now,
527527 collaborators,
···541541 })
542542}
543543544544-/// Placeholder BlobRef — will be replaced with pack ref after upload.
544544+/// Placeholder BlobRef — will be replaced with pack item ref after upload.
545545fn placeholder_blob_ref() -> BlobRef {
546546 BlobRef::new(
547547 "pending".to_string(),
···554554fn placeholder_binary_entry(hash: &str) -> FileEntry {
555555 FileEntry {
556556 content_hash: hash.to_string(),
557557- snapshot_blob: placeholder_blob_ref(),
557557+ base_blob: placeholder_blob_ref(),
558558 state_vector: String::new(),
559559 updates: vec![],
560560 updates_count: 0,
561561- snapshot_at: chrono::Utc::now().to_rfc3339(),
561561+ base_at: chrono::Utc::now().to_rfc3339(),
562562 kind: FileKind::Binary,
563563- pack_ref: None,
563563+ base: None,
564564 conflict_source: None,
565565 }
566566}
···895895 let entry = placeholder_binary_entry("deadbeef");
896896 assert_eq!(entry.kind, FileKind::Binary);
897897 assert_eq!(entry.content_hash, "deadbeef");
898898- assert!(entry.pack_ref.is_none());
898898+ assert!(entry.base.is_none());
899899 assert!(entry.conflict_source.is_none());
900900 assert_eq!(entry.updates_count, 0);
901901 }
+53-53
src/types.rs
···3030}
31313232/// The file index — maps relative paths to FileEntry metadata.
3333-/// Stored as a blob (not inline in the record) to avoid record size limits.
3333+/// Stored as a PDS blob (not inline in the record) to avoid record size limits.
3434pub type FileIndex = HashMap<String, FileEntry>;
35353636/// A repo stored on PDS with Yrs CRDT state per file.
···4545#[derive(Debug, Clone, Serialize, Deserialize)]
4646pub struct YrsRepo {
4747 pub name: String,
4848- /// Pointer to the index blob within the pack (contains serialized FileIndex).
4949- pub index: PackRef,
4848+ /// Pointer to the index within the PDS blob containing the pack (contains serialized FileIndex).
4949+ pub index: PackItemRef,
5050 /// All blob CIDs referenced by this repo — prevents PDS garbage collection.
5151 pub blobs: Vec<BlobRef>,
5252 #[serde(rename = "updatedAt")]
···6363 /// For text: hash of UTF-8 content. For binary: hash of raw bytes.
6464 #[serde(rename = "contentHash")]
6565 pub content_hash: String,
6666- /// Full Yrs state blob reference (encode_state_as_update_v1).
6767- #[serde(rename = "snapshotBlob")]
6868- pub snapshot_blob: BlobRef,
6666+ /// Blob reference for GC — CID of the PDS blob containing the BaseYrsUpdate.
6767+ #[serde(rename = "baseBlob")]
6868+ pub base_blob: BlobRef,
6969 /// State vector bytes, base64-encoded for inline storage.
7070 #[serde(rename = "stateVector")]
7171 pub state_vector: String,
7272- /// Incremental update packs since snapshot (ordered, each points into a pack blob).
7272+ /// Incremental YrsUpdates since the base (ordered, each points into a pack).
7373 #[serde(default, skip_serializing_if = "Vec::is_empty")]
7474- pub updates: Vec<PackRef>,
7575- /// Number of incremental updates applied since last snapshot.
7474+ pub updates: Vec<PackItemRef>,
7575+ /// Number of incremental updates applied since last base update.
7676 #[serde(rename = "updatesCount", default)]
7777 pub updates_count: u32,
7878- /// When the snapshot was taken.
7979- #[serde(rename = "snapshotAt")]
8080- pub snapshot_at: String,
7878+ /// When the BaseYrsUpdate was created.
7979+ #[serde(rename = "baseAt")]
8080+ pub base_at: String,
8181 /// File kind (text or binary).
8282 pub kind: FileKind,
8383- /// Pack blob reference — points to data within a pack blob.
8484- #[serde(rename = "packRef")]
8585- pub pack_ref: Option<PackRef>,
8383+ /// PackItemRef to BaseYrsUpdate data within a pack.
8484+ #[serde(rename = "base")]
8585+ pub base: Option<PackItemRef>,
8686 /// For binary conflict files, the original path before conflict split.
8787 #[serde(rename = "conflictSource", skip_serializing_if = "Option::is_none")]
8888 pub conflict_source: Option<String>,
8989}
909091919292-/// Reference to data within a pack blob.
9292+/// Reference to a pack item within a PDS blob.
9393#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
9494-pub struct PackRef {
9595- /// The pack blob reference.
9494+pub struct PackItemRef {
9595+ /// The PDS blob containing the pack.
9696 pub blob: BlobRef,
9797- /// Byte offset within the pack blob.
9797+ /// Byte offset within the pack data section.
9898 pub offset: u64,
9999- /// Length of data within the pack blob.
9999+ /// Length of data within the pack data section.
100100 pub length: u64,
101101- /// Whether the pack blob is gzip-compressed.
101101+ /// Whether the pack is gzip-compressed.
102102 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
103103 pub compressed: bool,
104104 /// For chunked packs (>40MB), ordered list of chunk blob refs.
···162162 };
163163164164 for entry in entries.values() {
165165- add(&entry.snapshot_blob, &mut seen, &mut refs);
166166- if let Some(ref pr) = entry.pack_ref {
165165+ add(&entry.base_blob, &mut seen, &mut refs);
166166+ if let Some(ref pr) = entry.base {
167167 add(&pr.blob, &mut seen, &mut refs);
168168 if let Some(ref chunks) = pr.chunks {
169169 for chunk in chunks {
···220220 fn file_entry_serialization() {
221221 let entry = FileEntry {
222222 content_hash: "abc123def456".to_string(),
223223- snapshot_blob: BlobRef::new(
223223+ base_blob: BlobRef::new(
224224 "bafysnap".to_string(),
225225 "application/octet-stream".to_string(),
226226 100,
···228228 state_vector: "AQID".to_string(),
229229 updates: vec![],
230230 updates_count: 0,
231231- snapshot_at: "2026-03-13T00:00:00Z".to_string(),
231231+ base_at: "2026-03-13T00:00:00Z".to_string(),
232232 kind: FileKind::Text,
233233- pack_ref: None,
233233+ base: None,
234234 conflict_source: None,
235235 };
236236 let json = serde_json::to_string(&entry).unwrap();
237237- assert!(json.contains("\"snapshotBlob\""));
237237+ assert!(json.contains("\"baseBlob\""));
238238 assert!(json.contains("\"stateVector\""));
239239 assert!(!json.contains("updatesBlob")); // skipped when None
240240 assert!(json.contains("\"kind\":\"text\"")); // always serialized
···247247 fn binary_file_entry_serialization() {
248248 let entry = FileEntry {
249249 content_hash: String::new(),
250250- snapshot_blob: BlobRef::new(
250250+ base_blob: BlobRef::new(
251251 "bafybin".to_string(),
252252 "application/octet-stream".to_string(),
253253 5000,
···255255 state_vector: String::new(),
256256 updates: vec![],
257257 updates_count: 0,
258258- snapshot_at: "2026-03-13T00:00:00Z".to_string(),
258258+ base_at: "2026-03-13T00:00:00Z".to_string(),
259259 kind: FileKind::Binary,
260260- pack_ref: None,
260260+ base: None,
261261 conflict_source: None,
262262 };
263263 let json = serde_json::to_string(&entry).unwrap();
···273273 "application/octet-stream".to_string(),
274274 5000,
275275 );
276276- let index_ref = PackRef {
276276+ let index_ref = PackItemRef {
277277 blob: pack_blob.clone(),
278278 offset: 0,
279279 length: 200,
···300300 #[test]
301301 fn collect_blob_refs_deduplicates() {
302302 let blob = BlobRef::new("bafyshared".to_string(), "application/octet-stream".to_string(), 100);
303303- let pack_ref = PackRef {
303303+ let item_ref = PackItemRef {
304304 blob: blob.clone(), offset: 0, length: 50, compressed: false, chunks: None,
305305 };
306306 let mut index = FileIndex::new();
307307 index.insert("a.txt".to_string(), FileEntry {
308308 content_hash: String::new(),
309309- snapshot_blob: blob.clone(),
309309+ base_blob: blob.clone(),
310310 state_vector: String::new(),
311311 updates: vec![], updates_count: 0,
312312- snapshot_at: String::new(), kind: FileKind::Text,
313313- pack_ref: Some(pack_ref.clone()), conflict_source: None,
312312+ base_at: String::new(), kind: FileKind::Text,
313313+ base: Some(item_ref.clone()), conflict_source: None,
314314 });
315315 index.insert("b.txt".to_string(), FileEntry {
316316 content_hash: String::new(),
317317- snapshot_blob: blob.clone(),
317317+ base_blob: blob.clone(),
318318 state_vector: String::new(),
319319 updates: vec![], updates_count: 0,
320320- snapshot_at: String::new(), kind: FileKind::Text,
321321- pack_ref: Some(pack_ref), conflict_source: None,
320320+ base_at: String::new(), kind: FileKind::Text,
321321+ base: Some(item_ref), conflict_source: None,
322322 });
323323 let refs = collect_blob_refs(&index);
324324 assert_eq!(refs.len(), 1, "same blob CID should be deduplicated");
···332332 let mut index = FileIndex::new();
333333 index.insert("a.txt".to_string(), FileEntry {
334334 content_hash: String::new(),
335335- snapshot_blob: pack_blob.clone(),
335335+ base_blob: pack_blob.clone(),
336336 state_vector: String::new(),
337337- updates: vec![PackRef {
337337+ updates: vec![PackItemRef {
338338 blob: update_blob.clone(), offset: 0, length: 50, compressed: false, chunks: None,
339339 }],
340340 updates_count: 1,
341341- snapshot_at: String::new(), kind: FileKind::Text,
342342- pack_ref: Some(PackRef {
341341+ base_at: String::new(), kind: FileKind::Text,
342342+ base: Some(PackItemRef {
343343 blob: pack_blob.clone(), offset: 0, length: 100, compressed: false, chunks: None,
344344 }),
345345 conflict_source: None,
···352352 }
353353354354 #[test]
355355- fn pack_ref_serialization() {
356356- let pack_ref = PackRef {
355355+ fn pack_item_ref_serialization() {
356356+ let item_ref = PackItemRef {
357357 blob: BlobRef::new(
358358 "bafypack".to_string(),
359359 "application/octet-stream".to_string(),
···364364 compressed: true,
365365 chunks: None,
366366 };
367367- let json = serde_json::to_string(&pack_ref).unwrap();
367367+ let json = serde_json::to_string(&item_ref).unwrap();
368368 assert!(json.contains("\"compressed\":true"));
369369- let deserialized: PackRef = serde_json::from_str(&json).unwrap();
369369+ let deserialized: PackItemRef = serde_json::from_str(&json).unwrap();
370370 assert_eq!(deserialized.offset, 100);
371371 assert_eq!(deserialized.length, 200);
372372 assert!(deserialized.compressed);
···374374 }
375375376376 #[test]
377377- fn pack_ref_compressed_false_omitted() {
377377+ fn pack_item_ref_compressed_false_omitted() {
378378 // compressed=false should be skipped in serialization
379379- let pack_ref = PackRef {
379379+ let item_ref = PackItemRef {
380380 blob: BlobRef::new(
381381 "bafypack".to_string(),
382382 "application/octet-stream".to_string(),
···387387 compressed: false,
388388 chunks: None,
389389 };
390390- let json = serde_json::to_string(&pack_ref).unwrap();
390390+ let json = serde_json::to_string(&item_ref).unwrap();
391391 assert!(
392392 !json.contains("compressed"),
393393 "compressed=false should be omitted"
···395395 }
396396397397 #[test]
398398- fn pack_ref_with_chunks() {
399399- let pack_ref = PackRef {
398398+ fn pack_item_ref_with_chunks() {
399399+ let item_ref = PackItemRef {
400400 blob: BlobRef::new(
401401 "bafychunk0".to_string(),
402402 "application/octet-stream".to_string(),
···418418 ),
419419 ]),
420420 };
421421- let json = serde_json::to_string(&pack_ref).unwrap();
421421+ let json = serde_json::to_string(&item_ref).unwrap();
422422 assert!(json.contains("bafychunk1"));
423423- let deserialized: PackRef = serde_json::from_str(&json).unwrap();
423423+ let deserialized: PackItemRef = serde_json::from_str(&json).unwrap();
424424 assert_eq!(deserialized.chunks.as_ref().unwrap().len(), 2);
425425 }
426426
+53-54
src/yrs_pds.rs
···3838 text.get_string(&txn)
3939}
40404141-/// Encode a Doc's full state as bytes.
4242-pub fn encode_snapshot(doc: &Doc) -> Vec<u8> {
4141+/// Encode a Doc as a BaseYrsUpdate (full state against empty StateVector).
4242+pub fn encode_base_update(doc: &Doc) -> Vec<u8> {
4343 let txn = doc.transact();
4444 txn.encode_state_as_update_v1(&yrs::StateVector::default())
4545}
···5858 Ok(txn.encode_diff_v1(&sv))
5959}
60606161-/// Load a Doc from a snapshot blob.
6262-pub fn doc_from_snapshot(data: &[u8]) -> Result<Doc, String> {
6161+/// Load a Doc from a BaseYrsUpdate blob.
6262+pub fn doc_from_base_update(data: &[u8]) -> Result<Doc, String> {
6363 let doc = Doc::new();
6464 let _text = doc.get_or_insert_text("content");
6565- let update = yrs::Update::decode_v1(data).map_err(|e| format!("decode snapshot: {}", e))?;
6565+ let update = yrs::Update::decode_v1(data).map_err(|e| format!("decode base update: {}", e))?;
6666 doc.transact_mut()
6767 .apply_update(update)
6868- .map_err(|e| format!("apply snapshot: {}", e))?;
6868+ .map_err(|e| format!("apply base update: {}", e))?;
6969 Ok(doc)
7070}
7171···8484 client: &PdsClient,
8585 did: &str,
8686) -> Result<FileEntry, String> {
8787- let snapshot = encode_snapshot(doc);
8787+ let base_update = encode_base_update(doc);
8888 let sv = encode_state_vector(doc);
89899090- // Upload snapshot blob
9191- let snapshot_blob = client.upload_blob(snapshot.clone()).await?;
9090+ // Upload base update as a PDS blob
9191+ let base_blob = client.upload_blob(base_update.clone()).await?;
92929393 // We need to reference the blob in a record for it to persist,
9494 // so we return the FileEntry which will be embedded in a YrsRepo.
···98989999 Ok(FileEntry {
100100 content_hash: String::new(),
101101- snapshot_blob,
101101+ base_blob,
102102 state_vector: base64_encode(&sv),
103103 updates: vec![],
104104 updates_count: 0,
105105- snapshot_at: now,
105105+ base_at: now,
106106 kind: FileKind::Text,
107107- pack_ref: None,
107107+ base: None,
108108 conflict_source: None,
109109 })
110110}
111111112112/// Reconstruct a Doc from a FileEntry by downloading blobs from PDS.
113113///
114114-/// If the entry has a pack_ref, extracts data from the pack blob.
115115-/// Otherwise downloads the snapshot blob directly.
114114+/// Downloads the BaseYrsUpdate via base, then applies incremental updates.
116115pub async fn file_entry_to_doc(
117116 entry: &FileEntry,
118117 client: &PdsClient,
119118 did: &str,
120119) -> Result<Doc, String> {
121121- let snapshot_data = get_file_blob_data(entry, client, did).await?;
122122- let doc = doc_from_snapshot(&snapshot_data)?;
120120+ let base_data = fetch_file_data(entry, client, did).await?;
121121+ let doc = doc_from_base_update(&base_data)?;
123122124123 // Apply incremental updates if present
125124 for update_ref in &entry.updates {
126126- let update_data = get_pack_ref_data(update_ref, client, did).await?;
125125+ let update_data = fetch_pack_item(update_ref, client, did).await?;
127126 apply_update(&doc, &update_data)?;
128127 }
129128130129 Ok(doc)
131130}
132131133133-/// Extract data from a PackRef by downloading and parsing the pack blob.
134134-pub async fn get_pack_ref_data(
135135- pack_ref: &crate::types::PackRef,
132132+/// Extract data from a PackItemRef by downloading and parsing the pack from PDS.
133133+pub async fn fetch_pack_item(
134134+ item_ref: &crate::types::PackItemRef,
136135 client: &PdsClient,
137136 did: &str,
138137) -> Result<Vec<u8>, String> {
139139- let pack_data = if let Some(ref chunks) = pack_ref.chunks {
138138+ let pack_data = if let Some(ref chunks) = item_ref.chunks {
140139 let mut chunk_data = Vec::new();
141140 for chunk_ref in chunks {
142141 chunk_data.push(client.get_blob(did, chunk_ref.cid()).await?);
143142 }
144143 crate::pack::reassemble_chunks(&chunk_data)
145144 } else {
146146- client.get_blob(did, pack_ref.blob.cid()).await?
145145+ client.get_blob(did, item_ref.blob.cid()).await?
147146 };
148148- let (_, blob_data) = crate::pack::parse_pack_auto(&pack_data)?;
149149- let start = pack_ref.offset as usize;
150150- let end = start + pack_ref.length as usize;
151151- if end > blob_data.len() {
147147+ let (_, data_section) = crate::pack::parse_pack_auto(&pack_data)?;
148148+ let start = item_ref.offset as usize;
149149+ let end = start + item_ref.length as usize;
150150+ if end > data_section.len() {
152151 return Err(format!(
153153- "pack_ref out of bounds: {}..{} in {} bytes",
154154- start, end, blob_data.len()
152152+ "pack item ref out of bounds: {}..{} in {} bytes",
153153+ start, end, data_section.len()
155154 ));
156155 }
157157- Ok(blob_data[start..end].to_vec())
156156+ Ok(data_section[start..end].to_vec())
158157}
159158160160-/// Get the raw blob data for a FileEntry, handling pack_ref extraction.
161161-pub async fn get_file_blob_data(
159159+/// Get the raw data for a FileEntry's base update, via its base PackItemRef.
160160+pub async fn fetch_file_data(
162161 entry: &FileEntry,
163162 client: &PdsClient,
164163 did: &str,
165164) -> Result<Vec<u8>, String> {
166166- let pack_ref = entry.pack_ref.as_ref()
167167- .ok_or("missing pack_ref on FileEntry")?;
168168- get_pack_ref_data(pack_ref, client, did).await
165165+ let item_ref = entry.base.as_ref()
166166+ .ok_or("missing base on FileEntry")?;
167167+ fetch_pack_item(item_ref, client, did).await
169168}
170169171170/// Load the FileIndex from a YrsRepo's index blob.
···174173 client: &PdsClient,
175174 did: &str,
176175) -> Result<FileIndex, String> {
177177- let data = get_pack_ref_data(&repo.index, client, did).await?;
176176+ let data = fetch_pack_item(&repo.index, client, did).await?;
178177 serde_json::from_slice(&data).map_err(|e| format!("parse FileIndex: {}", e))
179178}
180179···360359 result
361360}
362361363363-/// Encode the manifest Doc as a snapshot (full state).
364364-pub fn encode_manifest_snapshot(doc: &Doc) -> Vec<u8> {
365365- encode_snapshot(doc)
362362+/// Encode the manifest Doc as a BaseYrsUpdate (full state).
363363+pub fn encode_manifest_base_update(doc: &Doc) -> Vec<u8> {
364364+ encode_base_update(doc)
366365}
367366368367/// Materialize manifest content as a string (for the content field).
···382381 lines.join("\n")
383382}
384383385385-/// Restore a manifest Doc from a snapshot.
386386-pub fn manifest_from_snapshot(data: &[u8]) -> Result<Doc, String> {
384384+/// Restore a manifest Doc from a BaseYrsUpdate.
385385+pub fn manifest_from_base_update(data: &[u8]) -> Result<Doc, String> {
387386 let doc = Doc::new();
388387 let _map = doc.get_or_insert_map("manifest");
389388 let update =
390390- yrs::Update::decode_v1(data).map_err(|e| format!("decode manifest snapshot: {}", e))?;
389389+ yrs::Update::decode_v1(data).map_err(|e| format!("decode manifest base update: {}", e))?;
391390 doc.transact_mut()
392391 .apply_update(update)
393393- .map_err(|e| format!("apply manifest snapshot: {}", e))?;
392392+ .map_err(|e| format!("apply manifest base update: {}", e))?;
394393 Ok(doc)
395394}
396395···461460 let doc = doc_from_text(content);
462461 assert_eq!(materialize(&doc), content);
463462464464- let snapshot = encode_snapshot(&doc);
465465- let restored = doc_from_snapshot(&snapshot).unwrap();
463463+ let snapshot = encode_base_update(&doc);
464464+ let restored = doc_from_base_update(&snapshot).unwrap();
466465 assert_eq!(materialize(&restored), content);
467466 }
468467469468 #[test]
470469 fn incremental_update() {
471470 let doc = doc_from_text("Hello");
472472- let snapshot = encode_snapshot(&doc);
471471+ let snapshot = encode_base_update(&doc);
473472 let sv = encode_state_vector(&doc);
474473475474 // Apply an edit
···484483 let diff = encode_diff(&doc, &sv).unwrap();
485484486485 // Apply diff to a copy restored from the same snapshot (same client history)
487487- let doc2 = doc_from_snapshot(&snapshot).unwrap();
486486+ let doc2 = doc_from_base_update(&snapshot).unwrap();
488487 assert_eq!(materialize(&doc2), "Hello");
489488 apply_update(&doc2, &diff).unwrap();
490489 assert_eq!(materialize(&doc2), "Hello world");
···530529 manifest_insert(&doc, "docs/readme.md", &FileKind::Text);
531530 manifest_insert(&doc, "images/photo.jpg", &FileKind::Binary);
532531533533- let snapshot = encode_manifest_snapshot(&doc);
534534- let restored = manifest_from_snapshot(&snapshot).unwrap();
532532+ let snapshot = encode_manifest_base_update(&doc);
533533+ let restored = manifest_from_base_update(&snapshot).unwrap();
535534 let entries = manifest_entries(&restored);
536535 assert_eq!(entries.len(), 2);
537536 assert_eq!(entries.get("docs/readme.md"), Some(&FileKind::Text));
···542541 // Two repos start from same base
543542 let base = new_manifest_doc();
544543 manifest_insert(&base, "shared.md", &FileKind::Text);
545545- let base_snapshot = encode_manifest_snapshot(&base);
544544+ let base_snapshot = encode_manifest_base_update(&base);
546545547546 // Repo A adds a file
548548- let repo_a = manifest_from_snapshot(&base_snapshot).unwrap();
547547+ let repo_a = manifest_from_base_update(&base_snapshot).unwrap();
549548 manifest_insert(&repo_a, "page-a.md", &FileKind::Text);
550549551550 // Repo B adds a different file
552552- let repo_b = manifest_from_snapshot(&base_snapshot).unwrap();
551551+ let repo_b = manifest_from_base_update(&base_snapshot).unwrap();
553552 manifest_insert(&repo_b, "page-b.md", &FileKind::Text);
554553555554 // Merge B into A
···571570 // Base has a file
572571 let base = new_manifest_doc();
573572 manifest_insert(&base, "file.md", &FileKind::Text);
574574- let base_snapshot = encode_manifest_snapshot(&base);
573573+ let base_snapshot = encode_manifest_base_update(&base);
575574576575 // Repo A deletes the file
577577- let repo_a = manifest_from_snapshot(&base_snapshot).unwrap();
576576+ let repo_a = manifest_from_base_update(&base_snapshot).unwrap();
578577 manifest_remove(&repo_a, "file.md");
579578580579 // Repo B re-asserts the file (simulating an edit)
581581- let repo_b = manifest_from_snapshot(&base_snapshot).unwrap();
580580+ let repo_b = manifest_from_base_update(&base_snapshot).unwrap();
582581 manifest_insert(&repo_b, "file.md", &FileKind::Text);
583582584583 // Merge B into A — set should win over delete
+8-8
tests/e2e_tests.rs
···113113 create_sample_site(source.path()).await;
114114115115 // Save to PDS
116116- let result = pds_yrs::save(source.path(), &client, &did, &rkey, false)
116116+ let result = pds_yrs::save(source.path(), &client, &did, &rkey, "e2e-test", None, false)
117117 .await
118118 .unwrap();
119119 assert_eq!(result.files_uploaded, 3);
···155155 create_sample_site(site.path()).await;
156156157157 // First save
158158- let result = pds_yrs::save(site.path(), &client, &did, &rkey, false)
158158+ let result = pds_yrs::save(site.path(), &client, &did, &rkey, "e2e-test", None, false)
159159 .await
160160 .unwrap();
161161 assert_eq!(result.files_uploaded, 3);
···164164 write_file(site.path(), "index.md", "# Home\n\nUpdated content.").await;
165165166166 // Second save — should only upload 1 changed file
167167- let result = pds_yrs::save(site.path(), &client, &did, &rkey, false)
167167+ let result = pds_yrs::save(site.path(), &client, &did, &rkey, "e2e-test", None, false)
168168 .await
169169 .unwrap();
170170 assert_eq!(
···217217 .await;
218218219219 // Save initial state for both
220220- pds_yrs::save(repo_a.path(), &client, &did, &rkey_a, false)
220220+ pds_yrs::save(repo_a.path(), &client, &did, &rkey_a, "e2e-merge", None, false)
221221 .await
222222 .unwrap();
223223- pds_yrs::save(repo_b.path(), &client, &did, &rkey_b, false)
223223+ pds_yrs::save(repo_b.path(), &client, &did, &rkey_b, "e2e-merge", None, false)
224224 .await
225225 .unwrap();
226226···231231 "# Shared\n\nOriginal content.\n\nAlice's addition.\n",
232232 )
233233 .await;
234234- pds_yrs::save(repo_a.path(), &client, &did, &rkey_a, false)
234234+ pds_yrs::save(repo_a.path(), &client, &did, &rkey_a, "e2e-merge", None, false)
235235 .await
236236 .unwrap();
237237···242242 "# Shared\n\nBob's edit to original content.\n",
243243 )
244244 .await;
245245- pds_yrs::save(repo_b.path(), &client, &did, &rkey_b, false)
245245+ pds_yrs::save(repo_b.path(), &client, &did, &rkey_b, "e2e-merge", None, false)
246246 .await
247247 .unwrap();
248248···279279280280 let source = tempfile::tempdir().unwrap();
281281 create_sample_site(source.path()).await;
282282- pds_yrs::save(source.path(), &client, &did, &rkey, false)
282282+ pds_yrs::save(source.path(), &client, &did, &rkey, "e2e-test", None, false)
283283 .await
284284 .unwrap();
285285