ANW-36 Watcher: drop index entries when note files are deleted

This commit is contained in:
Andreas Brenner 2026-07-24 20:58:31 +03:00
parent 23ce90e103
commit 275c6ecfa0
4 changed files with 381 additions and 49 deletions

View file

@ -40,19 +40,45 @@ impl NoteStore {
}
}
/// Apply one debounce-window's worth of changes. Mirrors the order in
/// [`crate::index::NoteIndex::apply_batch`]: deletes first, then upserts.
/// Apply one debounce-window's worth of changes: deletes first (paths,
/// then whole directories), then upserts. A "delete-then-upsert"
/// sequence on one path is therefore unambiguous, and a directory that
/// was removed and recreated inside one window keeps only what the walk
/// found.
///
/// Each entry of `delete_prefixes` is a vault-relative directory; every
/// note under it drops [ANW-36]. Returns the number of notes dropped
/// that way.
///
/// # Panics
/// Panics if the inner `RwLock` has been poisoned.
pub fn apply_batch(&self, upserts: Vec<Note>, deletes: &[String]) {
pub fn apply_batch(
&self,
upserts: Vec<Note>,
deletes: &[String],
delete_prefixes: &[String],
) -> usize {
let mut guard = self.inner.write().expect("note_store: write lock poisoned");
for path in deletes {
guard.remove(path);
}
let mut dropped = 0;
for dir in delete_prefixes {
let prefix = format!("{}/", dir.trim_end_matches('/'));
let doomed: Vec<String> = guard
.range(prefix.clone()..)
.take_while(|(p, _)| p.starts_with(&prefix))
.map(|(p, _)| p.clone())
.collect();
dropped += doomed.len();
for path in doomed {
guard.remove(&path);
}
}
for note in upserts {
guard.insert(note.path.clone(), note);
}
dropped
}
/// # Panics
@ -143,13 +169,42 @@ mod tests {
fn apply_batch_deletes_then_upserts() {
let s = NoteStore::new();
s.replace(vec![note("a.md"), note("b.md")]);
s.apply_batch(vec![note("c.md")], &["a.md".to_string()]);
s.apply_batch(vec![note("c.md")], &["a.md".to_string()], &[]);
assert_eq!(s.len(), 2);
assert!(s.get("a.md").is_none());
assert!(s.get("b.md").is_some());
assert!(s.get("c.md").is_some());
}
#[test]
fn apply_batch_prefix_drops_the_whole_subtree() {
let s = NoteStore::new();
s.replace(vec![
note("Notes/a.md"),
note("Notes/deep/b.md"),
note("Notesy/c.md"),
note("Other/d.md"),
]);
let dropped = s.apply_batch(vec![], &[], &["Notes".to_string()]);
assert_eq!(dropped, 2);
assert!(s.get("Notes/a.md").is_none());
assert!(s.get("Notes/deep/b.md").is_none());
// A sibling sharing the prefix as a string, but not as a directory.
assert!(s.get("Notesy/c.md").is_some());
assert!(s.get("Other/d.md").is_some());
}
#[test]
fn apply_batch_prefix_delete_precedes_upserts() {
let s = NoteStore::new();
s.replace(vec![note("Notes/gone.md")]);
// A directory removed and recreated inside one window: only what the
// walk found survives.
s.apply_batch(vec![note("Notes/fresh.md")], &[], &["Notes".to_string()]);
assert!(s.get("Notes/gone.md").is_none());
assert!(s.get("Notes/fresh.md").is_some());
}
#[test]
fn upsert_replaces_by_path() {
let s = NoteStore::new();