.tasks/core/INDEX-004-change-detection-system.md
Implement the dual-mode change detection system that keeps the index synchronized with filesystem state. Batch change detection runs during indexer jobs to detect offline changes (stale file detection), while real-time change detection processes filesystem watcher events as they occur.
The ChangeDetector compares database state against filesystem during indexer scans:
pub struct ChangeDetector {
// Maps inode → EntryRecord for existing entries
inode_map: HashMap<u64, EntryRecord>,
// Maps path → EntryRecord for path-only matching (Windows fallback)
path_map: HashMap<PathBuf, EntryRecord>,
// Tracks which entries we've seen this scan
seen_entries: HashSet<i32>,
}
impl ChangeDetector {
async fn check_path(
&self,
path: &Path,
metadata: &Metadata,
inode: Option<u64>,
) -> Option<Change> {
if let Some(inode) = inode {
// Unix: Check inode first (detects moves)
if let Some(existing) = self.inode_map.get(&inode) {
if existing.path != path {
return Some(Change::Moved { old: existing.path, new: path });
}
if existing.size != metadata.len() || existing.mtime != metadata.modified() {
return Some(Change::Modified { path });
}
return None; // Unchanged
}
}
// Not found by inode, check path
if let Some(existing) = self.path_map.get(path) {
if existing.size != metadata.len() || existing.mtime != metadata.modified() {
return Some(Change::Modified { path });
}
return None; // Unchanged
}
// Not in database
Some(Change::New { path })
}
fn find_deleted(&self) -> Vec<Change> {
self.path_map
.keys()
.filter(|path| !self.seen_entries.contains(&self.path_map[path].id))
.map(|path| Change::Deleted { path })
.collect()
}
}
The ChangeHandler trait defines the interface for responding to filesystem events:
pub trait ChangeHandler {
async fn find_by_path(&self, path: &Path) -> Result<Option<EntryRef>>;
async fn create(&mut self, metadata: &DirEntry, parent_path: &Path) -> Result<EntryRef>;
async fn update(&mut self, entry: &EntryRef, metadata: &DirEntry) -> Result<()>;
async fn move_entry(&mut self, entry: &EntryRef, old_path: &Path, new_path: &Path) -> Result<()>;
async fn delete(&mut self, entry: &EntryRef) -> Result<()>;
}
DatabaseAdapter (Persistent):
MemoryAdapter (Ephemeral):
The filesystem watcher routes events to the appropriate handler:
async fn handle_filesystem_event(&self, event: Event) -> Result<()> {
let path = event.path();
// Determine if this path belongs to ephemeral or persistent index
if let Some(ephemeral_index) = self.ephemeral_cache.get_index_for_path(path).await {
// Route to MemoryAdapter
let mut adapter = MemoryAdapter::new(ephemeral_index);
adapter.handle_change(event).await?;
} else if let Some(location) = self.find_location_for_path(path, db).await? {
// Route to DatabaseAdapter
let mut adapter = DatabaseAdapter::new(db, location.id);
adapter.handle_change(event).await?;
}
Ok(())
}
core/src/ops/indexing/change_detection/detector.rs - ChangeDetector implementationcore/src/ops/indexing/change_detection/types.rs - Change enum (New/Modified/Moved/Deleted)core/src/ops/indexing/phases/processing.rs - Integration into Phase 2core/src/ops/indexing/change_detection/handler.rs - ChangeHandler trait definitioncore/src/ops/indexing/change_detection/persistent.rs - DatabaseAdapter implementationcore/src/ops/indexing/handlers/persistent.rs - DatabaseAdapter for ChangeHandlercore/src/ops/indexing/handlers/ephemeral.rs - MemoryAdapter for ChangeHandlercore/src/ops/indexing/handlers/mod.rs - Handler module exportscore/src/ops/indexing/database_storage.rs - Low-level CRUD used by DatabaseAdaptercore/src/ops/indexing/ephemeral/writer.rs - In-memory operations used by MemoryAdapterNote: Automated stale detection on app startup is tracked separately in INDEX-009. The ChangeDetector provides the foundation but automatic reconciliation is not yet fully implemented.
| Platform | Inode Support | Move Detection | Path Stability |
|---|---|---|---|
| macOS | Yes (FSEvents) | Via inode | Stable |
| Linux | Yes | Via inode | Stable |
| Windows | Limited | Via path only | Unstable across reboots |
# Test batch change detection (stale detection)
# 1. Index a directory
spacedrive index location ~/Documents --mode shallow
# 2. Stop Spacedrive
spacedrive stop
# 3. Make changes while offline
touch ~/Documents/new_file.txt
echo "modified" >> ~/Documents/existing.txt
mv ~/Documents/old.txt ~/Documents/renamed.txt
rm ~/Documents/deleted.txt
# 4. Restart and verify detection
spacedrive start
spacedrive index location ~/Documents --mode shallow
# Should detect: 1 new, 1 modified, 1 moved, 1 deleted
Located in core/tests/indexing/:
test_change_detector_new_files - Detect new filestest_change_detector_modified_files - Detect size/mtime changestest_change_detector_moved_files_unix - Detect moves via inodetest_change_detector_deleted_files - Detect deleted filestest_change_handler_create - Real-time create eventstest_change_handler_modify - Real-time modify eventstest_change_handler_move - Real-time move eventstest_change_handler_delete - Real-time delete eventstest_stale_detection_after_offline - Offline change detection