From f3a1b1da2c75b3e87de963c06da26bf04fc2b6cf Mon Sep 17 00:00:00 2001 From: Damo Date: Sat, 6 Jun 2026 23:08:55 +0800 Subject: [PATCH 1/5] fix storage get id lookup cache --- src-tauri/crates/storage/src/lib.rs | 595 +++++++++++++++++++++++++++- 1 file changed, 574 insertions(+), 21 deletions(-) diff --git a/src-tauri/crates/storage/src/lib.rs b/src-tauri/crates/storage/src/lib.rs index 69deb39e6f..ff9195bdf2 100644 --- a/src-tauri/crates/storage/src/lib.rs +++ b/src-tauri/crates/storage/src/lib.rs @@ -21,14 +21,33 @@ const STORAGE_SAVE_DEBOUNCE_MS: u64 = 750; #[derive(Default)] struct StorageCache { collections: HashMap, + id_indexes: HashMap, projected_lists: HashMap, } struct CachedCollection { rows: Vec, + row_indices_by_id: HashMap, dirty: bool, } +struct CachedCollectionIdIndex { + records_by_id: HashMap, + stamp: Option, +} + +#[derive(Clone)] +enum CachedCollectionRecord { + PrettyRange(CachedRecordRange), + Row(Value), +} + +#[derive(Clone, Copy)] +struct CachedRecordRange { + start: u64, + end: u64, +} + #[derive(Clone, Debug, Eq, Hash, PartialEq)] struct ProjectionCacheKey { collection: String, @@ -41,6 +60,12 @@ struct ProjectionShape { field_selections: Vec<(String, Vec)>, } +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +struct CollectionMetadataStamp { + len: u64, + modified_nanos: u128, +} + #[derive(Clone, Copy, Debug, Eq, PartialEq)] struct CollectionFileStamp { len: u64, @@ -742,6 +767,44 @@ impl FileStorage { .map(|cached| cached.rows.clone())) } + fn cached_row_by_id(&self, collection: &str, id: &str) -> AppResult>> { + validate_collection_name(collection)?; + let cache = self + .cache + .read() + .map_err(|_| AppError::new("lock_error", "Storage cache lock poisoned"))?; + Ok(cache.collections.get(collection).map(|cached| { + cached + .row_indices_by_id + .get(id) + .and_then(|index| cached.rows.get(*index)) + .cloned() + })) + } + + fn cached_dirty_row_by_id( + &self, + collection: &str, + id: &str, + ) -> AppResult>> { + validate_collection_name(collection)?; + let cache = self + .cache + .read() + .map_err(|_| AppError::new("lock_error", "Storage cache lock poisoned"))?; + Ok(cache + .collections + .get(collection) + .filter(|cached| cached.dirty) + .map(|cached| { + cached + .row_indices_by_id + .get(id) + .and_then(|index| cached.rows.get(*index)) + .cloned() + })) + } + fn cached_dirty_rows(&self, collection: &str) -> AppResult>> { validate_collection_name(collection)?; let cache = self @@ -771,6 +834,7 @@ impl FileStorage { .write() .map_err(|_| AppError::new("lock_error", "Storage cache lock poisoned"))?; if dirty { + cache.id_indexes.remove(collection); cache .projected_lists .retain(|key, _| key.collection != collection); @@ -779,6 +843,7 @@ impl FileStorage { collection.to_string(), CachedCollection { rows: rows.to_vec(), + row_indices_by_id: row_indices_by_id(rows), dirty, }, ); @@ -791,16 +856,18 @@ impl FileStorage { .write() .map_err(|_| AppError::new("lock_error", "Storage cache lock poisoned"))?; cache.collections.clear(); + cache.id_indexes.clear(); cache.projected_lists.clear(); Ok(()) } - fn invalidate_projected_cache_for_collection(&self, collection: &str) -> AppResult<()> { + fn invalidate_read_indexes_for_collection(&self, collection: &str) -> AppResult<()> { validate_collection_name(collection)?; let mut cache = self .cache .write() .map_err(|_| AppError::new("lock_error", "Storage cache lock poisoned"))?; + cache.id_indexes.remove(collection); cache .projected_lists .retain(|key, _| key.collection != collection); @@ -1239,15 +1306,17 @@ impl FileStorage { id: &str, recover_on_fallback: bool, ) -> AppResult> { - if let Some(rows) = self.cached_rows(collection)? { - return Ok(rows - .into_iter() - .find(|row| row.get("id").and_then(Value::as_str) == Some(id))); + if let Some(row) = self.cached_row_by_id(collection, id)? { + return Ok(row); } let path = self.collection_path(collection)?; if !path.exists() || fs::metadata(&path)?.len() == 0 { return Ok(None); } + match self.indexed_row_by_id_from_disk(collection, id, recover_on_fallback) { + Ok(row) => return Ok(row), + Err(_) => {} + } match read_pretty_record_by_id_from_file(&path, id) { Ok(Some(row)) => return Ok(Some(row)), Ok(None) => {} @@ -1285,11 +1354,8 @@ impl FileStorage { let field_set: HashSet = fields.iter().cloned().collect(); let nested_field_sets = selected_nested_fields(field_selections); - if let Some(rows) = self.cached_dirty_rows(collection)? { - return Ok(rows - .into_iter() - .find(|row| row.get("id").and_then(Value::as_str) == Some(id)) - .map(|row| project_row(row, &field_set, &nested_field_sets))); + if let Some(row) = self.cached_dirty_row_by_id(collection, id)? { + return Ok(row.map(|row| project_row(row, &field_set, &nested_field_sets))); } let path = self.collection_path(collection)?; @@ -1297,6 +1363,16 @@ impl FileStorage { return Ok(None); } + match self.indexed_projected_row_by_id_from_disk( + collection, + id, + &field_set, + &nested_field_sets, + recover_on_fallback, + ) { + Ok(row) => return Ok(row), + Err(_) => {} + } match read_pretty_projected_record_by_id_from_file( &path, id, @@ -1718,6 +1794,109 @@ impl FileStorage { Ok(()) } + fn indexed_row_by_id_from_disk( + &self, + collection: &str, + id: &str, + recover_on_fallback: bool, + ) -> AppResult> { + let Some((path, record)) = + self.indexed_record_by_id_from_disk(collection, id, recover_on_fallback)? + else { + return Ok(None); + }; + read_indexed_record_value(&path, &record) + } + + fn indexed_projected_row_by_id_from_disk( + &self, + collection: &str, + id: &str, + fields: &HashSet, + field_selections: &HashMap>, + recover_on_fallback: bool, + ) -> AppResult> { + let Some((path, record)) = + self.indexed_record_by_id_from_disk(collection, id, recover_on_fallback)? + else { + return Ok(None); + }; + read_indexed_record_projected_value(&path, &record, id, fields, field_selections) + } + + fn indexed_record_by_id_from_disk( + &self, + collection: &str, + id: &str, + recover_on_fallback: bool, + ) -> AppResult> { + let path = self.collection_path(collection)?; + let stamp = collection_metadata_stamp(&path)?; + if stamp.is_none() { + return Ok(None); + } + if let Some(row) = self.cached_indexed_row_by_id(collection, id, stamp)? { + return Ok(row.map(|record| (path, record))); + } + + let records_by_id = if let Some(ranges) = pretty_record_ranges_by_id(&path)? { + ranges + .into_iter() + .map(|(id, range)| (id, CachedCollectionRecord::PrettyRange(range))) + .collect() + } else { + let rows = if recover_on_fallback { + self.read_collection_from_disk(collection)? + } else { + self.read_collection_from_disk_no_recovery(collection)? + }; + records_by_id(&rows) + }; + let refreshed_stamp = collection_metadata_stamp(&path)?; + let record = records_by_id.get(id).cloned(); + self.cache_id_index(collection, records_by_id, refreshed_stamp)?; + Ok(record.map(|record| (path, record))) + } + + fn cached_indexed_row_by_id( + &self, + collection: &str, + id: &str, + stamp: Option, + ) -> AppResult>> { + validate_collection_name(collection)?; + let cache = self + .cache + .read() + .map_err(|_| AppError::new("lock_error", "Storage cache lock poisoned"))?; + Ok(cache + .id_indexes + .get(collection) + .filter(|cached| cached.stamp == stamp) + .map(|cached| cached.records_by_id.get(id).cloned())) + } + + fn cache_id_index( + &self, + collection: &str, + records_by_id: HashMap, + stamp: Option, + ) -> AppResult<()> { + validate_collection_name(collection)?; + let mut cache = self + .cache + .write() + .map_err(|_| AppError::new("lock_error", "Storage cache lock poisoned"))?; + cache.id_indexes.insert( + collection.to_string(), + CachedCollectionIdIndex { + records_by_id, + stamp, + }, + ); + Ok(()) + } + fn write_collection(&self, collection: &str, rows: &[Value]) -> AppResult<()> { self.cache_collection(collection, rows, true)?; self.schedule_dirty_flush(); @@ -1726,7 +1905,7 @@ impl FileStorage { fn write_collection_immediate(&self, collection: &str, rows: &[Value]) -> AppResult<()> { self.write_collection_file(collection, rows)?; - self.invalidate_projected_cache_for_collection(collection)?; + self.invalidate_read_indexes_for_collection(collection)?; self.cache_collection(collection, rows, false)?; Ok(()) } @@ -1947,7 +2126,7 @@ impl FileStorage { cleanup_pending_collection_transaction_files(&pending); for (collection, rows) in replacements { - self.invalidate_projected_cache_for_collection(collection)?; + self.invalidate_read_indexes_for_collection(collection)?; self.cache_collection(collection, &rows, false)?; } Ok(()) @@ -2305,25 +2484,206 @@ fn projection_shape( } } +fn records_by_id(rows: &[Value]) -> HashMap { + let mut index = HashMap::new(); + for row in rows { + let Some(id) = row.get("id").and_then(Value::as_str) else { + continue; + }; + index + .entry(id.to_string()) + .or_insert_with(|| CachedCollectionRecord::Row(row.clone())); + } + index +} + +fn pretty_record_ranges_by_id( + path: &Path, +) -> AppResult>> { + let file = fs::File::open(path)?; + let mut reader = BufReader::new(file); + let mut ranges = HashMap::new(); + let mut in_record = false; + let mut saw_array_start = false; + let mut saw_record = false; + let mut record_start = 0_u64; + let mut record_id: Option = None; + let mut line = String::new(); + + loop { + let line_start = reader.stream_position()?; + line.clear(); + if reader.read_line(&mut line)? == 0 { + break; + } + let line_end = reader.stream_position()?; + let line = line.trim_end_matches(['\r', '\n']); + let trimmed = line.trim_start(); + + if !in_record { + if trimmed.starts_with('[') { + saw_array_start = true; + continue; + } + if trimmed.starts_with(']') { + break; + } + if trimmed.trim().is_empty() { + continue; + } + if trimmed.starts_with('{') { + in_record = true; + saw_record = true; + record_start = line_start; + record_id = None; + continue; + } + return Ok(None); + } + + if is_pretty_top_level_record_end(line) { + if let Some(id) = record_id.take() { + ranges.entry(id).or_insert(CachedRecordRange { + start: record_start, + end: line_end, + }); + } + in_record = false; + continue; + } + + if record_id.is_none() { + let Some((field, value_start)) = pretty_json_field(line, 4)? else { + continue; + }; + if field == "id" { + let value = value_start + .trim() + .strip_suffix(',') + .unwrap_or(value_start.trim()) + .trim_end(); + if let Ok(Value::String(id)) = serde_json::from_str::(value) { + record_id = Some(id); + } + } + } + } + + if !saw_array_start || in_record || !saw_record { + return Ok(None); + } + Ok(Some(ranges)) +} + +fn read_indexed_record_value( + path: &Path, + record: &CachedCollectionRecord, +) -> AppResult> { + match record { + CachedCollectionRecord::PrettyRange(range) => { + read_pretty_record_range(path, *range).map(Some) + } + CachedCollectionRecord::Row(row) => Ok(Some(row.clone())), + } +} + +fn read_indexed_record_projected_value( + path: &Path, + record: &CachedCollectionRecord, + id: &str, + fields: &HashSet, + field_selections: &HashMap>, +) -> AppResult> { + match record { + CachedCollectionRecord::PrettyRange(range) => { + read_pretty_projected_record_range(path, *range, id, fields, field_selections) + } + CachedCollectionRecord::Row(row) => { + Ok(Some(project_row(row.clone(), fields, field_selections))) + } + } +} + +fn read_pretty_record_range(path: &Path, range: CachedRecordRange) -> AppResult { + let mut bytes = read_file_range(path, range)?; + strip_trailing_json_comma(&mut bytes); + Ok(serde_json::from_slice(&bytes)?) +} + +fn read_pretty_projected_record_range( + path: &Path, + range: CachedRecordRange, + id: &str, + fields: &HashSet, + field_selections: &HashMap>, +) -> AppResult> { + let bytes = read_file_range(path, range)?; + let mut wrapped = Vec::with_capacity(bytes.len() + 4); + wrapped.extend_from_slice(b"[\n"); + wrapped.extend_from_slice(&bytes); + wrapped.extend_from_slice(b"\n]"); + let reader = BufReader::new(Cursor::new(wrapped)); + read_pretty_projected_record_by_id_from_reader(reader, id, fields, field_selections) +} + +fn read_file_range(path: &Path, range: CachedRecordRange) -> AppResult> { + let len = range.end.checked_sub(range.start).ok_or_else(|| { + AppError::invalid_input("Cached storage record range ended before it started") + })?; + let len = usize::try_from(len) + .map_err(|_| AppError::invalid_input("Cached storage record range is too large"))?; + let mut bytes = vec![0_u8; len]; + let mut file = fs::File::open(path)?; + file.seek(SeekFrom::Start(range.start))?; + file.read_exact(&mut bytes)?; + Ok(bytes) +} + +fn row_indices_by_id(rows: &[Value]) -> HashMap { + let mut index = HashMap::new(); + for (row_index, row) in rows.iter().enumerate() { + let Some(id) = row.get("id").and_then(Value::as_str) else { + continue; + }; + index.entry(id.to_string()).or_insert(row_index); + } + index +} + +fn collection_metadata_stamp(path: &Path) -> AppResult> { + let metadata = match fs::metadata(path) { + Ok(metadata) => metadata, + Err(error) if error.kind() == ErrorKind::NotFound => return Ok(None), + Err(error) => return Err(error.into()), + }; + Ok(Some(CollectionMetadataStamp { + len: metadata.len(), + modified_nanos: metadata_modified_nanos(&metadata), + })) +} + fn collection_file_stamp(path: &Path) -> AppResult> { let metadata = match fs::metadata(path) { Ok(metadata) => metadata, Err(error) if error.kind() == ErrorKind::NotFound => return Ok(None), Err(error) => return Err(error.into()), }; - let modified_nanos = metadata - .modified() - .ok() - .and_then(|modified| modified.duration_since(UNIX_EPOCH).ok()) - .map(|duration| duration.as_nanos()) - .unwrap_or(0); Ok(Some(CollectionFileStamp { len: metadata.len(), - modified_nanos, + modified_nanos: metadata_modified_nanos(&metadata), content_signature: collection_content_signature(path, metadata.len())?, })) } +fn metadata_modified_nanos(metadata: &fs::Metadata) -> u128 { + metadata + .modified() + .ok() + .and_then(|modified| modified.duration_since(UNIX_EPOCH).ok()) + .map(|duration| duration.as_nanos()) + .unwrap_or(0) +} + fn collection_content_signature(path: &Path, len: u64) -> AppResult { let mut file = fs::File::open(path)?; let mut hasher = std::collections::hash_map::DefaultHasher::new(); @@ -3611,7 +3971,16 @@ fn read_pretty_projected_record_by_id_from_file( field_selections: &HashMap>, ) -> AppResult> { let file = fs::File::open(path)?; - let mut reader = BufReader::new(file); + let reader = BufReader::new(file); + read_pretty_projected_record_by_id_from_reader(reader, id, fields, field_selections) +} + +fn read_pretty_projected_record_by_id_from_reader( + mut reader: R, + id: &str, + fields: &HashSet, + field_selections: &HashMap>, +) -> AppResult> { let mut in_record = false; let mut saw_array_start = false; let mut saw_record = false; @@ -4436,6 +4805,187 @@ mod tests { fs::remove_dir_all(root).unwrap(); } + #[test] + fn repeated_get_uses_cached_id_index_after_disk_read() { + let root = temp_storage_root("get-uses-id-index"); + let storage = FileStorage::new(&root).unwrap(); + let collection = root.join("collections").join("characters.json"); + fs::create_dir_all(collection.parent().unwrap()).unwrap(); + fs::write( + &collection, + serde_json::to_vec_pretty(&json!([ + { "id": "first", "name": "First" }, + { "id": "target", "name": "Target" }, + { "id": "last", "name": "Last" } + ])) + .unwrap(), + ) + .unwrap(); + + assert_eq!( + storage + .get("characters", "target") + .expect("get should build id index") + .expect("target should exist")["name"], + "Target" + ); + assert_eq!( + storage + .get("characters", "target") + .expect("cached get should reuse id index") + .expect("target should still come from id index")["name"], + "Target" + ); + assert!(storage + .get("characters", "missing") + .expect("missing id should be cached in the same index") + .is_none()); + let cache = storage.cache.read().expect("cache lock should be readable"); + let id_index = cache + .id_indexes + .get("characters") + .expect("id index should be cached"); + assert!(matches!( + id_index.records_by_id.get("target"), + Some(CachedCollectionRecord::PrettyRange(_)) + )); + assert!(!id_index.records_by_id.contains_key("missing")); + + fs::remove_dir_all(root).unwrap(); + } + + #[test] + fn repeated_projected_get_uses_cached_id_index_after_disk_read() { + let root = temp_storage_root("projected-get-uses-id-index"); + let storage = FileStorage::new(&root).unwrap(); + let collection = root.join("collections").join("characters.json"); + fs::create_dir_all(collection.parent().unwrap()).unwrap(); + fs::write( + &collection, + serde_json::to_vec_pretty(&json!([ + { + "id": "target", + "data": { "name": "Rina", "description": "large prompt text" }, + "avatar": "large image payload" + } + ])) + .unwrap(), + ) + .unwrap(); + let fields = vec!["id".to_string(), "data".to_string()]; + let mut selections = Map::new(); + selections.insert("data".to_string(), json!(["name"])); + + let record = storage + .get_projected("characters", "target", &fields, &selections) + .expect("projected get should build id index") + .expect("target should exist"); + assert_eq!( + record, + json!({ "id": "target", "data": { "name": "Rina" } }) + ); + + let cached = storage + .get_projected("characters", "target", &fields, &selections) + .expect("cached projected get should reuse id index") + .expect("target should still come from id index"); + assert_eq!( + cached, + json!({ "id": "target", "data": { "name": "Rina" } }) + ); + let cache = storage.cache.read().expect("cache lock should be readable"); + assert!(cache + .id_indexes + .get("characters") + .is_some_and(|cached| matches!( + cached.records_by_id.get("target"), + Some(CachedCollectionRecord::PrettyRange(_)) + ))); + + fs::remove_dir_all(root).unwrap(); + } + + #[test] + fn projected_get_id_index_avoids_caching_full_pretty_rows() { + let root = temp_storage_root("projected-get-index-uses-ranges"); + let storage = FileStorage::new(&root).unwrap(); + let collection = root.join("collections").join("characters.json"); + fs::create_dir_all(collection.parent().unwrap()).unwrap(); + fs::write( + &collection, + serde_json::to_vec_pretty(&json!([ + { + "id": "target", + "data": { "name": "Rina", "description": "large prompt text" }, + "avatar": "large image payload" + } + ])) + .unwrap(), + ) + .unwrap(); + let fields = vec!["id".to_string(), "data".to_string()]; + let mut selections = Map::new(); + selections.insert("data".to_string(), json!(["name"])); + + let record = storage + .get_projected("characters", "target", &fields, &selections) + .expect("projected get should build range index") + .expect("target should exist"); + assert_eq!( + record, + json!({ "id": "target", "data": { "name": "Rina" } }) + ); + + let cache = storage.cache.read().expect("cache lock should be readable"); + let id_index = cache + .id_indexes + .get("characters") + .expect("id index should be cached"); + assert!(matches!( + id_index.records_by_id.get("target"), + Some(CachedCollectionRecord::PrettyRange(_)) + )); + + fs::remove_dir_all(root).unwrap(); + } + + #[test] + fn get_id_index_invalidates_when_file_stamp_changes() { + let root = temp_storage_root("get-id-index-invalidates"); + let storage = FileStorage::new(&root).unwrap(); + let collection = root.join("collections").join("characters.json"); + fs::create_dir_all(collection.parent().unwrap()).unwrap(); + fs::write( + &collection, + serde_json::to_vec_pretty(&json!([{ "id": "target", "name": "Before" }])).unwrap(), + ) + .unwrap(); + + assert_eq!( + storage + .get("characters", "target") + .expect("get should build id index") + .expect("target should exist")["name"], + "Before" + ); + fs::write( + &collection, + serde_json::to_vec_pretty(&json!([{ "id": "target", "name": "After value changed" }])) + .unwrap(), + ) + .unwrap(); + + assert_eq!( + storage + .get("characters", "target") + .expect("changed file should rebuild id index") + .expect("target should still exist")["name"], + "After value changed" + ); + + fs::remove_dir_all(root).unwrap(); + } + #[test] fn get_projected_returns_matching_row_without_unrequested_fields() { let root = temp_storage_root("get-projected-skips-unrequested-fields"); @@ -4579,7 +5129,10 @@ mod tests { storage .cache_collection("characters", &[json!({ "id": "pending" })], true) .unwrap(); - assert!(storage.dirty_collection_count() > 0, "write should be pending"); + assert!( + storage.dirty_collection_count() > 0, + "write should be pending" + ); storage.flush().unwrap(); From 8a117486bd0f08703fb79f33e3ef8ebe5dea8c18 Mon Sep 17 00:00:00 2001 From: Damo Date: Sat, 6 Jun 2026 23:19:45 +0800 Subject: [PATCH 2/5] Merge projected storage list cache stamp --- src-tauri/crates/storage/src/lib.rs | 78 ++++++++++++++++++++++++----- 1 file changed, 66 insertions(+), 12 deletions(-) diff --git a/src-tauri/crates/storage/src/lib.rs b/src-tauri/crates/storage/src/lib.rs index ff9195bdf2..81b9a68353 100644 --- a/src-tauri/crates/storage/src/lib.rs +++ b/src-tauri/crates/storage/src/lib.rs @@ -67,7 +67,13 @@ struct CollectionMetadataStamp { } #[derive(Clone, Copy, Debug, Eq, PartialEq)] -struct CollectionFileStamp { +struct ProjectedCollectionStamp { + len: u64, + modified_nanos: u128, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +struct CollectionContentStamp { len: u64, modified_nanos: u128, content_signature: u64, @@ -75,7 +81,7 @@ struct CollectionFileStamp { struct CachedProjectedList { rows: Vec, - stamp: Option, + stamp: Option, } struct AtomicUpdateGuard { @@ -661,7 +667,7 @@ impl FileStorage { collection: collection.to_string(), rows: self.read_collection_no_recovery(collection)?, }); - original_stamps.push((collection.to_string(), collection_file_stamp(&path)?)); + original_stamps.push((collection.to_string(), collection_content_stamp(&path)?)); } (loaded, original_stamps) }; @@ -675,7 +681,7 @@ impl FileStorage { self.flush_dirty_collections_locked()?; for (collection, original_stamp) in &original_stamps { let path = self.collection_path(collection)?; - if collection_file_stamp(&path)? != *original_stamp { + if collection_content_stamp(&path)? != *original_stamp { return Err(AppError::new( "storage_conflict", format!("Collection changed during atomic update: {collection}"), @@ -1132,7 +1138,7 @@ impl FileStorage { shape: projection_shape(fields, &nested_field_sets), }; let path = self.collection_path(collection)?; - let stamp = collection_file_stamp(&path)?; + let stamp = projected_collection_stamp(&path)?; if let Some(rows) = self.cached_projected_list_rows(&cache_key, stamp)? { return Ok(rows); } @@ -1761,7 +1767,7 @@ impl FileStorage { fn cached_projected_list_rows( &self, key: &ProjectionCacheKey, - stamp: Option, + stamp: Option, ) -> AppResult>> { let cache = self .cache @@ -1778,7 +1784,7 @@ impl FileStorage { &self, key: &ProjectionCacheKey, rows: &[Value], - stamp: Option, + stamp: Option, ) -> AppResult<()> { let mut cache = self .cache @@ -2662,13 +2668,25 @@ fn collection_metadata_stamp(path: &Path) -> AppResult AppResult> { +fn projected_collection_stamp(path: &Path) -> AppResult> { + let metadata = match fs::metadata(path) { + Ok(metadata) => metadata, + Err(error) if error.kind() == ErrorKind::NotFound => return Ok(None), + Err(error) => return Err(error.into()), + }; + Ok(Some(ProjectedCollectionStamp { + len: metadata.len(), + modified_nanos: metadata_modified_nanos(&metadata), + })) +} + +fn collection_content_stamp(path: &Path) -> AppResult> { let metadata = match fs::metadata(path) { Ok(metadata) => metadata, Err(error) if error.kind() == ErrorKind::NotFound => return Ok(None), Err(error) => return Err(error.into()), }; - Ok(Some(CollectionFileStamp { + Ok(Some(CollectionContentStamp { len: metadata.len(), modified_nanos: metadata_modified_nanos(&metadata), content_signature: collection_content_signature(path, metadata.len())?, @@ -5216,8 +5234,8 @@ mod tests { } #[test] - fn list_projected_cache_detects_same_length_rewrite() { - let root = temp_storage_root("list-projected-cache-same-length-rewrite"); + fn list_projected_cache_detects_same_length_rewrite_when_mtime_changes() { + let root = temp_storage_root("list-projected-cache-same-length-rewrite-mtime"); let storage = FileStorage::new(&root).unwrap(); storage @@ -5244,6 +5262,10 @@ mod tests { ); let collection = root.join("collections").join("characters.json"); + let original_modified = fs::metadata(&collection) + .unwrap() + .modified() + .expect("collection mtime should be readable"); let replacement = serde_json::to_vec_pretty(&json!([ { "id": "target", @@ -5259,10 +5281,15 @@ mod tests { .len() ); fs::write(&collection, replacement).unwrap(); + let file = fs::File::options().write(true).open(&collection).unwrap(); + file.set_times( + std::fs::FileTimes::new().set_modified(original_modified + Duration::from_secs(1)), + ) + .unwrap(); let changed = storage .list_projected("characters", &fields, &selections) - .expect("projected list should notice same-length file rewrite"); + .expect("projected list should notice same-length file rewrite with changed mtime"); assert_eq!( changed, vec![json!({ "id": "target", "data": { "name": "Bravo" } })] @@ -5271,6 +5298,33 @@ mod tests { fs::remove_dir_all(root).unwrap(); } + #[test] + fn projected_collection_stamp_uses_metadata_without_content_signature() { + let root = temp_storage_root("projected-collection-stamp-metadata-only"); + let collection = root.join("collections").join("characters.json"); + fs::create_dir_all(collection.parent().unwrap()).unwrap(); + + fs::write(&collection, b"alpha").unwrap(); + let original_modified = fs::metadata(&collection) + .unwrap() + .modified() + .expect("collection mtime should be readable"); + let first_stamp = projected_collection_stamp(&collection).unwrap(); + + fs::write(&collection, b"bravo").unwrap(); + let file = fs::File::options().write(true).open(&collection).unwrap(); + file.set_times(std::fs::FileTimes::new().set_modified(original_modified)) + .unwrap(); + + assert_eq!(fs::metadata(&collection).unwrap().len(), 5); + assert_eq!( + projected_collection_stamp(&collection).unwrap(), + first_stamp + ); + + fs::remove_dir_all(root).unwrap(); + } + #[test] fn collection_content_signature_hashes_large_unsampled_bytes() { let root = temp_storage_root("collection-content-signature-large"); From 33238026e0b204bc517af4280ec369e2a90a1f2a Mon Sep 17 00:00:00 2001 From: Damo Date: Sat, 6 Jun 2026 23:49:50 +0800 Subject: [PATCH 3/5] Fix storage clippy single match --- src-tauri/crates/storage/src/lib.rs | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/src-tauri/crates/storage/src/lib.rs b/src-tauri/crates/storage/src/lib.rs index 81b9a68353..4385b03206 100644 --- a/src-tauri/crates/storage/src/lib.rs +++ b/src-tauri/crates/storage/src/lib.rs @@ -1319,9 +1319,8 @@ impl FileStorage { if !path.exists() || fs::metadata(&path)?.len() == 0 { return Ok(None); } - match self.indexed_row_by_id_from_disk(collection, id, recover_on_fallback) { - Ok(row) => return Ok(row), - Err(_) => {} + if let Ok(row) = self.indexed_row_by_id_from_disk(collection, id, recover_on_fallback) { + return Ok(row); } match read_pretty_record_by_id_from_file(&path, id) { Ok(Some(row)) => return Ok(Some(row)), @@ -1369,15 +1368,14 @@ impl FileStorage { return Ok(None); } - match self.indexed_projected_row_by_id_from_disk( + if let Ok(row) = self.indexed_projected_row_by_id_from_disk( collection, id, &field_set, &nested_field_sets, recover_on_fallback, ) { - Ok(row) => return Ok(row), - Err(_) => {} + return Ok(row); } match read_pretty_projected_record_by_id_from_file( &path, From bc9767772779e94c5527752c34865259548e781c Mon Sep 17 00:00:00 2001 From: Damo Date: Sun, 7 Jun 2026 00:06:05 +0800 Subject: [PATCH 4/5] Harden storage read cache stamps --- src-tauri/crates/storage/src/lib.rs | 286 +++++++++++++++++++++------- 1 file changed, 217 insertions(+), 69 deletions(-) diff --git a/src-tauri/crates/storage/src/lib.rs b/src-tauri/crates/storage/src/lib.rs index 4385b03206..b5afe90fe3 100644 --- a/src-tauri/crates/storage/src/lib.rs +++ b/src-tauri/crates/storage/src/lib.rs @@ -33,7 +33,7 @@ struct CachedCollection { struct CachedCollectionIdIndex { records_by_id: HashMap, - stamp: Option, + stamp: Option, } #[derive(Clone)] @@ -60,18 +60,6 @@ struct ProjectionShape { field_selections: Vec<(String, Vec)>, } -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -struct CollectionMetadataStamp { - len: u64, - modified_nanos: u128, -} - -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -struct ProjectedCollectionStamp { - len: u64, - modified_nanos: u128, -} - #[derive(Clone, Copy, Debug, Eq, PartialEq)] struct CollectionContentStamp { len: u64, @@ -81,7 +69,7 @@ struct CollectionContentStamp { struct CachedProjectedList { rows: Vec, - stamp: Option, + stamp: Option, } struct AtomicUpdateGuard { @@ -1074,7 +1062,7 @@ impl FileStorage { return Ok(Vec::new()); } - let file = fs::File::open(path)?; + let file = fs::File::open(&path)?; let reader = BufReader::new(file); let mut deserializer = serde_json::Deserializer::from_reader(reader); match deserializer.deserialize_seq(FilteredRowsWhereInVisitor { @@ -1138,7 +1126,7 @@ impl FileStorage { shape: projection_shape(fields, &nested_field_sets), }; let path = self.collection_path(collection)?; - let stamp = projected_collection_stamp(&path)?; + let stamp = collection_content_stamp(&path)?; if let Some(rows) = self.cached_projected_list_rows(&cache_key, stamp)? { return Ok(rows); } @@ -1147,7 +1135,7 @@ impl FileStorage { return Ok(Vec::new()); } - let file = fs::File::open(path)?; + let file = fs::File::open(&path)?; let reader = BufReader::new(file); let mut deserializer = serde_json::Deserializer::from_reader(reader); match deserializer.deserialize_seq(ProjectedRowsVisitor { @@ -1155,20 +1143,24 @@ impl FileStorage { field_selections: &nested_field_sets, }) { Ok(rows) => { - self.cache_projected_list(&cache_key, &rows, stamp)?; + if collection_content_stamp(&path)? == stamp { + self.cache_projected_list(&cache_key, &rows, stamp)?; + } Ok(rows) } Err(_) => { let rows = if recover_on_fallback { - self.read_collection(collection)? + self.read_collection_from_disk(collection)? } else { - self.read_collection_no_recovery(collection)? + self.read_collection_from_disk_no_recovery(collection)? }; let projected = rows .into_iter() .map(|row| project_row(row, &field_set, &nested_field_sets)) .collect::>(); - self.cache_projected_list(&cache_key, &projected, stamp)?; + if collection_content_stamp(&path)? == stamp { + self.cache_projected_list(&cache_key, &projected, stamp)?; + } Ok(projected) } } @@ -1765,7 +1757,7 @@ impl FileStorage { fn cached_projected_list_rows( &self, key: &ProjectionCacheKey, - stamp: Option, + stamp: Option, ) -> AppResult>> { let cache = self .cache @@ -1782,7 +1774,7 @@ impl FileStorage { &self, key: &ProjectionCacheKey, rows: &[Value], - stamp: Option, + stamp: Option, ) -> AppResult<()> { let mut cache = self .cache @@ -1835,7 +1827,7 @@ impl FileStorage { recover_on_fallback: bool, ) -> AppResult> { let path = self.collection_path(collection)?; - let stamp = collection_metadata_stamp(&path)?; + let stamp = collection_content_stamp(&path)?; if stamp.is_none() { return Ok(None); } @@ -1856,7 +1848,13 @@ impl FileStorage { }; records_by_id(&rows) }; - let refreshed_stamp = collection_metadata_stamp(&path)?; + let refreshed_stamp = collection_content_stamp(&path)?; + if refreshed_stamp != stamp { + return Err(AppError::new( + "storage_index_unstable", + format!("Collection changed while building id index: {collection}"), + )); + } let record = records_by_id.get(id).cloned(); self.cache_id_index(collection, records_by_id, refreshed_stamp)?; Ok(record.map(|record| (path, record))) @@ -1866,7 +1864,7 @@ impl FileStorage { &self, collection: &str, id: &str, - stamp: Option, + stamp: Option, ) -> AppResult>> { validate_collection_name(collection)?; let cache = self @@ -1884,7 +1882,7 @@ impl FileStorage { &self, collection: &str, records_by_id: HashMap, - stamp: Option, + stamp: Option, ) -> AppResult<()> { validate_collection_name(collection)?; let mut cache = self @@ -2621,7 +2619,8 @@ fn read_pretty_projected_record_range( fields: &HashSet, field_selections: &HashMap>, ) -> AppResult> { - let bytes = read_file_range(path, range)?; + let mut bytes = read_file_range(path, range)?; + strip_trailing_json_comma(&mut bytes); let mut wrapped = Vec::with_capacity(bytes.len() + 4); wrapped.extend_from_slice(b"[\n"); wrapped.extend_from_slice(&bytes); @@ -2654,30 +2653,6 @@ fn row_indices_by_id(rows: &[Value]) -> HashMap { index } -fn collection_metadata_stamp(path: &Path) -> AppResult> { - let metadata = match fs::metadata(path) { - Ok(metadata) => metadata, - Err(error) if error.kind() == ErrorKind::NotFound => return Ok(None), - Err(error) => return Err(error.into()), - }; - Ok(Some(CollectionMetadataStamp { - len: metadata.len(), - modified_nanos: metadata_modified_nanos(&metadata), - })) -} - -fn projected_collection_stamp(path: &Path) -> AppResult> { - let metadata = match fs::metadata(path) { - Ok(metadata) => metadata, - Err(error) if error.kind() == ErrorKind::NotFound => return Ok(None), - Err(error) => return Err(error.into()), - }; - Ok(Some(ProjectedCollectionStamp { - len: metadata.len(), - modified_nanos: metadata_modified_nanos(&metadata), - })) -} - fn collection_content_stamp(path: &Path) -> AppResult> { let metadata = match fs::metadata(path) { Ok(metadata) => metadata, @@ -4426,6 +4401,13 @@ mod tests { .count() } + fn rewrite_with_modified_time(path: &Path, bytes: &[u8], modified: SystemTime) { + fs::write(path, bytes).unwrap(); + let file = fs::File::options().write(true).open(path).unwrap(); + file.set_times(std::fs::FileTimes::new().set_modified(modified)) + .unwrap(); + } + #[test] fn replace_all_many_updates_multiple_collections() { let root = temp_storage_root("replace-many"); @@ -5002,6 +4984,56 @@ mod tests { fs::remove_dir_all(root).unwrap(); } + #[test] + fn get_id_index_detects_same_length_rewrite_with_same_mtime() { + let root = temp_storage_root("get-id-index-same-metadata-rewrite"); + let storage = FileStorage::new(&root).unwrap(); + let collection = root.join("collections").join("characters.json"); + fs::create_dir_all(collection.parent().unwrap()).unwrap(); + let initial = br#"[ + { + "id": "target", + "name": "Alpha" + }, + { + "id": "decoy", + "name": "Omega" + } +]"#; + let replacement = br#"[ + { + "id": "decoy", + "name": "Omega" + }, + { + "id": "target", + "name": "Bravo" + } +]"#; + assert_eq!(initial.len(), replacement.len()); + fs::write(&collection, initial).unwrap(); + let original_modified = fs::metadata(&collection).unwrap().modified().unwrap(); + + assert_eq!( + storage + .get("characters", "target") + .expect("get should build id index") + .expect("target should exist"), + json!({ "id": "target", "name": "Alpha" }) + ); + rewrite_with_modified_time(&collection, replacement, original_modified); + + assert_eq!( + storage + .get("characters", "target") + .expect("same-metadata rewrite should rebuild id index") + .expect("target should still exist"), + json!({ "id": "target", "name": "Bravo" }) + ); + + fs::remove_dir_all(root).unwrap(); + } + #[test] fn get_projected_returns_matching_row_without_unrequested_fields() { let root = temp_storage_root("get-projected-skips-unrequested-fields"); @@ -5297,27 +5329,49 @@ mod tests { } #[test] - fn projected_collection_stamp_uses_metadata_without_content_signature() { - let root = temp_storage_root("projected-collection-stamp-metadata-only"); - let collection = root.join("collections").join("characters.json"); - fs::create_dir_all(collection.parent().unwrap()).unwrap(); - - fs::write(&collection, b"alpha").unwrap(); - let original_modified = fs::metadata(&collection) - .unwrap() - .modified() - .expect("collection mtime should be readable"); - let first_stamp = projected_collection_stamp(&collection).unwrap(); + fn list_projected_cache_detects_same_length_rewrite_with_same_mtime() { + let root = temp_storage_root("list-projected-cache-same-metadata-rewrite"); + let storage = FileStorage::new(&root).unwrap(); - fs::write(&collection, b"bravo").unwrap(); - let file = fs::File::options().write(true).open(&collection).unwrap(); - file.set_times(std::fs::FileTimes::new().set_modified(original_modified)) + storage + .replace_all( + "characters", + vec![json!({ + "id": "target", + "data": { "name": "Alpha", "description": "large prompt" }, + "avatar": "large image payload" + })], + ) .unwrap(); - assert_eq!(fs::metadata(&collection).unwrap().len(), 5); + let fields = vec!["id".to_string(), "data".to_string()]; + let mut selections = Map::new(); + selections.insert("data".to_string(), json!(["name"])); + assert_eq!( + storage + .list_projected("characters", &fields, &selections) + .expect("first projected list should cache"), + vec![json!({ "id": "target", "data": { "name": "Alpha" } })] + ); + + let collection = root.join("collections").join("characters.json"); + let original_modified = fs::metadata(&collection).unwrap().modified().unwrap(); + let replacement = serde_json::to_vec_pretty(&json!([ + { + "id": "target", + "data": { "name": "Bravo", "description": "large prompt" }, + "avatar": "large image payload" + } + ])) + .unwrap(); + assert_eq!(replacement.len() as u64, fs::metadata(&collection).unwrap().len()); + rewrite_with_modified_time(&collection, &replacement, original_modified); + assert_eq!( - projected_collection_stamp(&collection).unwrap(), - first_stamp + storage + .list_projected("characters", &fields, &selections) + .expect("same-metadata rewrite should invalidate projected cache"), + vec![json!({ "id": "target", "data": { "name": "Bravo" } })] ); fs::remove_dir_all(root).unwrap(); @@ -5634,6 +5688,100 @@ mod tests { fs::remove_dir_all(root).unwrap(); } + #[test] + fn projected_pretty_range_reads_non_final_record_with_trailing_comma() { + let root = temp_storage_root("projected-pretty-range-non-final"); + let collection = root.join("collections").join("characters.json"); + fs::create_dir_all(collection.parent().unwrap()).unwrap(); + fs::write( + &collection, + serde_json::to_vec_pretty(&json!([ + { + "id": "target", + "data": { "name": "Rina", "description": "large prompt text" }, + "avatar": "large image payload" + }, + { + "id": "other", + "data": { "name": "Other", "description": "ignore" }, + "avatar": "ignore" + } + ])) + .unwrap(), + ) + .unwrap(); + let ranges = pretty_record_ranges_by_id(&collection) + .expect("range scan should succeed") + .expect("pretty ranges should be available"); + let fields = HashSet::from(["id".to_string(), "data".to_string()]); + let field_selections = HashMap::from([( + "data".to_string(), + HashSet::from(["name".to_string()]), + )]); + + assert_eq!( + read_pretty_projected_record_range( + &collection, + *ranges.get("target").expect("target range should exist"), + "target", + &fields, + &field_selections, + ) + .expect("non-final projected range should parse") + .expect("target should be projected"), + json!({ "id": "target", "data": { "name": "Rina" } }) + ); + + fs::remove_dir_all(root).unwrap(); + } + + #[test] + fn projected_pretty_range_reads_final_record_without_trailing_comma() { + let root = temp_storage_root("projected-pretty-range-final"); + let collection = root.join("collections").join("characters.json"); + fs::create_dir_all(collection.parent().unwrap()).unwrap(); + fs::write( + &collection, + serde_json::to_vec_pretty(&json!([ + { + "id": "other", + "data": { "name": "Other", "description": "ignore" }, + "avatar": "ignore" + }, + { + "id": "target", + "data": { "name": "Rina", "description": "large prompt text" }, + "avatar": "large image payload" + } + ])) + .unwrap(), + ) + .unwrap(); + let ranges = pretty_record_ranges_by_id(&collection) + .expect("range scan should succeed") + .expect("pretty ranges should be available"); + let fields = HashSet::from(["id".to_string(), "data".to_string()]); + let field_selections = HashMap::from([( + "data".to_string(), + HashSet::from(["name".to_string()]), + )]); + + assert_eq!( + read_pretty_projected_record_range( + &collection, + *ranges.get("target").expect("target range should exist"), + "target", + &fields, + &field_selections, + ) + .expect("final projected range should parse") + .expect("target should be projected"), + json!({ "id": "target", "data": { "name": "Rina" } }) + ); + + fs::remove_dir_all(root).unwrap(); + } + #[test] fn get_projected_preserves_selected_array_fields() { let root = temp_storage_root("get-projected-preserves-selected-arrays"); From d0a089772e088ab90b10b8bd06f1a01713718240 Mon Sep 17 00:00:00 2001 From: Damo Date: Sun, 7 Jun 2026 00:31:17 +0800 Subject: [PATCH 5/5] Retry unstable storage id index builds --- src-tauri/crates/storage/src/lib.rs | 136 ++++++++++++++++++++++------ 1 file changed, 109 insertions(+), 27 deletions(-) diff --git a/src-tauri/crates/storage/src/lib.rs b/src-tauri/crates/storage/src/lib.rs index b5afe90fe3..03ee5b04de 100644 --- a/src-tauri/crates/storage/src/lib.rs +++ b/src-tauri/crates/storage/src/lib.rs @@ -18,6 +18,13 @@ use std::time::{Duration, SystemTime, UNIX_EPOCH}; const MESSAGE_REVERSE_READ_CHUNK_SIZE: u64 = 1024 * 1024; const STORAGE_SAVE_DEBOUNCE_MS: u64 = 750; +#[cfg(test)] +type IndexBuildTestHook = Box; + +#[cfg(test)] +static INDEX_BUILD_TEST_HOOK: std::sync::Mutex> = + std::sync::Mutex::new(None); + #[derive(Default)] struct StorageCache { collections: HashMap, @@ -1827,37 +1834,58 @@ impl FileStorage { recover_on_fallback: bool, ) -> AppResult> { let path = self.collection_path(collection)?; - let stamp = collection_content_stamp(&path)?; - if stamp.is_none() { - return Ok(None); - } - if let Some(row) = self.cached_indexed_row_by_id(collection, id, stamp)? { - return Ok(row.map(|record| (path, record))); - } + for _ in 0..2 { + let stamp = collection_content_stamp(&path)?; + if stamp.is_none() { + return Ok(None); + } + if let Some(row) = self.cached_indexed_row_by_id(collection, id, stamp)? { + return Ok(row.map(|record| (path, record))); + } - let records_by_id = if let Some(ranges) = pretty_record_ranges_by_id(&path)? { - ranges - .into_iter() - .map(|(id, range)| (id, CachedCollectionRecord::PrettyRange(range))) - .collect() - } else { - let rows = if recover_on_fallback { - self.read_collection_from_disk(collection)? + let records_by_id = if let Some(ranges) = pretty_record_ranges_by_id(&path)? { + ranges + .into_iter() + .map(|(id, range)| (id, CachedCollectionRecord::PrettyRange(range))) + .collect() } else { - self.read_collection_from_disk_no_recovery(collection)? + let rows = if recover_on_fallback { + self.read_collection_from_disk(collection)? + } else { + self.read_collection_from_disk_no_recovery(collection)? + }; + records_by_id(&rows) }; - records_by_id(&rows) - }; - let refreshed_stamp = collection_content_stamp(&path)?; - if refreshed_stamp != stamp { - return Err(AppError::new( - "storage_index_unstable", - format!("Collection changed while building id index: {collection}"), - )); + #[cfg(test)] + run_index_build_test_hook(&path)?; + let refreshed_stamp = collection_content_stamp(&path)?; + if refreshed_stamp != stamp { + continue; + } + let record = records_by_id.get(id).cloned(); + self.cache_id_index(collection, records_by_id, refreshed_stamp)?; + return Ok(record.map(|record| (path, record))); } - let record = records_by_id.get(id).cloned(); - self.cache_id_index(collection, records_by_id, refreshed_stamp)?; - Ok(record.map(|record| (path, record))) + + self.uncached_record_by_id_from_disk(collection, id, recover_on_fallback) + } + + fn uncached_record_by_id_from_disk( + &self, + collection: &str, + id: &str, + recover_on_fallback: bool, + ) -> AppResult> { + let path = self.collection_path(collection)?; + let rows = if recover_on_fallback { + self.read_collection_from_disk(collection)? + } else { + self.read_collection_from_disk_no_recovery(collection)? + }; + Ok(rows + .into_iter() + .find(|row| row.get("id").and_then(Value::as_str) == Some(id)) + .map(|row| (path, CachedCollectionRecord::Row(row)))) } fn cached_indexed_row_by_id( @@ -2486,6 +2514,17 @@ fn projection_shape( } } +#[cfg(test)] +fn run_index_build_test_hook(path: &Path) -> AppResult<()> { + let mut hook = INDEX_BUILD_TEST_HOOK + .lock() + .map_err(|_| AppError::new("lock_error", "Storage index test hook lock poisoned"))?; + if let Some(hook) = hook.as_mut() { + hook(path); + } + Ok(()) +} + fn records_by_id(rows: &[Value]) -> HashMap { let mut index = HashMap::new(); for row in rows { @@ -5034,6 +5073,49 @@ mod tests { fs::remove_dir_all(root).unwrap(); } + #[test] + fn get_id_index_retries_when_file_changes_during_index_build() { + let root = temp_storage_root("get-id-index-retries-unstable-scan"); + let storage = FileStorage::new(&root).unwrap(); + let collection = root.join("collections").join("characters.json"); + fs::create_dir_all(collection.parent().unwrap()).unwrap(); + let initial = serde_json::to_vec_pretty(&json!([ + { "id": "target", "name": "Alpha" }, + { "id": "other", "name": "Omega" } + ])) + .unwrap(); + let replacement = serde_json::to_vec_pretty(&json!([ + { "id": "target", "name": "Bravo" }, + { "id": "other", "name": "Omega" } + ])) + .unwrap(); + fs::write(&collection, initial).unwrap(); + let original_modified = fs::metadata(&collection).unwrap().modified().unwrap(); + let rewrite_path = collection.clone(); + let mut replacement = Some(replacement); + *INDEX_BUILD_TEST_HOOK.lock().unwrap() = Some(Box::new(move |path| { + if path == rewrite_path.as_path() { + if let Some(bytes) = replacement.take() { + rewrite_with_modified_time( + path, + &bytes, + original_modified + Duration::from_secs(1), + ); + } + } + })); + + let row = storage + .get("characters", "target") + .expect("scan-time rewrite should retry instead of surfacing instability") + .expect("target should still exist"); + *INDEX_BUILD_TEST_HOOK.lock().unwrap() = None; + + assert_eq!(row, json!({ "id": "target", "name": "Bravo" })); + + fs::remove_dir_all(root).unwrap(); + } + #[test] fn get_projected_returns_matching_row_without_unrequested_fields() { let root = temp_storage_root("get-projected-skips-unrequested-fields");