From fb9516829b2ed4fec1ae65b61a4c94450ed0b715 Mon Sep 17 00:00:00 2001 From: thekevinbot <248217931+thekevinbot@users.noreply.github.com> Date: Mon, 5 Oct 2026 22:11:29 -0400 Subject: [PATCH 1/8] Test that a watcher sees changes under an anchor outside the index root skip-changelog: test-only --- packages/rust/tests/table_anchor_watch.rs | 86 +++++++++++++++++++++++ 1 file changed, 86 insertions(+) create mode 100644 packages/rust/tests/table_anchor_watch.rs diff --git a/packages/rust/tests/table_anchor_watch.rs b/packages/rust/tests/table_anchor_watch.rs new file mode 100644 index 000000000..6c8706fda --- /dev/null +++ b/packages/rust/tests/table_anchor_watch.rs @@ -0,0 +1,86 @@ +//! The live watcher covers every table's anchor, not only the index root. + +use dirsql::{DirSQL, Row, RowEvent, Table, Value}; +use std::fs; +use std::time::{Duration, Instant}; +use tempfile::TempDir; + +fn name_table(name: &str, anchor: Option<&std::path::Path>) -> Table { + let table = Table::new( + name, + format!("CREATE TABLE {name} (name TEXT)"), + "**/*.txt", + |path| { + let body = fs::read_to_string(path).unwrap_or_default(); + vec![Row::from([( + "name".to_string(), + Value::Text(body.trim().to_string()), + )])] + }, + ); + match anchor { + Some(anchor) => table.anchored(anchor), + None => table, + } +} + +fn poll_until(db: &DirSQL, done: impl Fn(&[RowEvent]) -> bool) -> Vec { + let deadline = Instant::now() + Duration::from_secs(10); + let mut seen = Vec::new(); + while Instant::now() < deadline && !done(&seen) { + seen.extend(db.poll_events(Duration::from_millis(200)).unwrap()); + } + seen +} + +fn inserts_into(events: &[RowEvent], table: &str) -> usize { + events + .iter() + .filter(|e| matches!(e, RowEvent::Insert { table: t, .. } if t == table)) + .count() +} + +#[test] +fn a_file_created_under_an_anchor_outside_the_root_emits_an_insert() { + let root = TempDir::new().unwrap(); + let anchor = TempDir::new().unwrap(); + let db = DirSQL::builder() + .root(root.path()) + .table(name_table("elsewhere", Some(anchor.path()))) + .build() + .unwrap(); + db.start_watching().unwrap(); + std::thread::sleep(Duration::from_millis(200)); + + fs::write(anchor.path().join("new.txt"), "hello").unwrap(); + + let events = poll_until(&db, |seen| inserts_into(seen, "elsewhere") > 0); + assert_eq!(inserts_into(&events, "elsewhere"), 1, "got: {events:?}"); + let rows = db.query("SELECT name FROM elsewhere").unwrap(); + assert_eq!(rows.len(), 1); +} + +#[test] +fn the_root_and_an_outside_anchor_are_both_watched() { + let root = TempDir::new().unwrap(); + let anchor = TempDir::new().unwrap(); + let db = DirSQL::builder() + .root(root.path()) + .tables(vec![ + name_table("at_root", None), + name_table("elsewhere", Some(anchor.path())), + ]) + .build() + .unwrap(); + db.start_watching().unwrap(); + std::thread::sleep(Duration::from_millis(200)); + + fs::write(root.path().join("a.txt"), "a").unwrap(); + fs::write(anchor.path().join("b.txt"), "b").unwrap(); + + let events = poll_until(&db, |seen| { + inserts_into(seen, "at_root") > 0 && inserts_into(seen, "elsewhere") > 0 + }); + assert_eq!(inserts_into(&events, "at_root"), 1, "got: {events:?}"); + assert_eq!(inserts_into(&events, "elsewhere"), 1, "got: {events:?}"); +} From d1b638bb94b447b365de252489749c11eb465cc7 Mon Sep 17 00:00:00 2001 From: thekevinbot <248217931+thekevinbot@users.noreply.github.com> Date: Mon, 5 Oct 2026 22:17:42 -0400 Subject: [PATCH 2/8] Watch the index root and every anchor outside it skip-changelog: internal watcher plumbing, no observable change --- packages/rust/src/lib.rs | 68 ++++++++++++++++++++++++++++++++-- packages/rust/src/watcher.rs | 10 ++++- packages/rust/tests/watcher.rs | 31 ++++++++++++++++ 3 files changed, 105 insertions(+), 4 deletions(-) diff --git a/packages/rust/src/lib.rs b/packages/rust/src/lib.rs index 5ba619ca2..f98c57498 100644 --- a/packages/rust/src/lib.rs +++ b/packages/rust/src/lib.rs @@ -424,6 +424,22 @@ struct DirSqlInner { fs: Arc, } +fn watch_roots(index_root: &Path, groups: &[AnchorGroup]) -> Vec { + if groups.is_empty() { + return vec![index_root.to_path_buf()]; + } + let mut roots: Vec = Vec::new(); + for group in groups { + let anchor = &group.watch_anchor; + if roots.iter().any(|kept| anchor.starts_with(kept)) { + continue; + } + roots.retain(|kept| !kept.starts_with(anchor)); + roots.push(anchor.clone()); + } + roots +} + /// The tables sharing one anchor, matched against paths relative to it. struct AnchorGroup { anchor: PathBuf, @@ -544,14 +560,21 @@ impl DirSQL { pub fn start_watching(&self) -> Result<()> { let mut guard = self.inner.watcher.lock().map_err(DirSqlError::lock)?; if guard.is_none() { - // Watch the canonicalized root, never the (possibly relative) - // user-supplied one — `notify` misbehaves on relative paths. - let watcher = Watcher::new(&self.inner.watch_root).map_err(DirSqlError::watch)?; + // Watch the canonicalized anchors, never the (possibly relative) + // user-supplied ones — `notify` misbehaves on relative paths. + let watcher = Watcher::over(&self.watch_roots()).map_err(DirSqlError::watch)?; *guard = Some(watcher); } Ok(()) } + /// The directories the live watcher must cover: each table anchor, minus + /// any already inside another's recursive watch. A build with no tables + /// still watches the index root. + fn watch_roots(&self) -> Vec { + watch_roots(&self.inner.watch_root, &self.inner.groups) + } + /// Poll-based watch API. Blocks up to `timeout` waiting for the next /// filesystem event, then drains any additional events that arrived during /// processing, applying all of them to the ephemeral database. Returns the @@ -5378,6 +5401,45 @@ mod internal_tests { assert_eq!(hits.len(), 1); assert_eq!(hits[0].1, PathBuf::from("/elsewhere/x.txt")); } + + fn roots_for(anchors: &[&str]) -> Vec { + let tables: Vec = anchors + .iter() + .enumerate() + .map(|(i, anchor)| anchored_table(&format!("t{i}"), "*.txt", anchor)) + .collect(); + let (groups, _, _) = compile_groups(&tables, &[], Path::new("/idx")).unwrap(); + watch_roots(Path::new("/canonical-idx"), &groups) + } + + #[test] + fn watch_roots_without_tables_is_the_index_root() { + assert_eq!(roots_for(&[]), vec![PathBuf::from("/canonical-idx")]); + } + + #[test] + fn watch_roots_lists_each_distinct_anchor() { + assert_eq!( + roots_for(&["/one", "/two"]), + vec![PathBuf::from("/one"), PathBuf::from("/two")] + ); + } + + #[test] + fn watch_roots_drops_an_anchor_inside_an_earlier_one() { + assert_eq!( + roots_for(&["/one", "/one/deep"]), + vec![PathBuf::from("/one")] + ); + } + + #[test] + fn watch_roots_replaces_an_anchor_with_the_enclosing_later_one() { + assert_eq!( + roots_for(&["/one/deep", "/one"]), + vec![PathBuf::from("/one")] + ); + } } #[cfg(test)] diff --git a/packages/rust/src/watcher.rs b/packages/rust/src/watcher.rs index 65ee69151..1bff1ba41 100644 --- a/packages/rust/src/watcher.rs +++ b/packages/rust/src/watcher.rs @@ -23,6 +23,12 @@ pub struct Watcher { impl Watcher { /// Start watching a directory recursively. Events are buffered in an internal channel. pub fn new(path: &Path) -> Result { + Self::over(&[path.to_path_buf()]) + } + + /// Start watching every directory in `paths` recursively, merging their + /// events into one channel. + pub fn over(paths: &[PathBuf]) -> Result { let (tx, rx) = mpsc::channel(); let mut watcher = RecommendedWatcher::new( @@ -38,7 +44,9 @@ impl Watcher { Config::default(), )?; - watcher.watch(path, RecursiveMode::Recursive)?; + for path in paths { + watcher.watch(path, RecursiveMode::Recursive)?; + } Ok(Self { _watcher: watcher, diff --git a/packages/rust/tests/watcher.rs b/packages/rust/tests/watcher.rs index 2d38d697b..e7360c4bf 100644 --- a/packages/rust/tests/watcher.rs +++ b/packages/rust/tests/watcher.rs @@ -216,3 +216,34 @@ fn try_recv_all_drains_pending_events() { "Expected at least one event from batch file creation" ); } + +#[test] +fn over_detects_events_in_every_directory() { + let first = TempDir::new().unwrap(); + let second = TempDir::new().unwrap(); + let watcher = + Watcher::over(&[first.path().to_path_buf(), second.path().to_path_buf()]).unwrap(); + + thread::sleep(Duration::from_millis(100)); + + fs::write(first.path().join("a.txt"), "a").unwrap(); + fs::write(second.path().join("b.txt"), "b").unwrap(); + + let events = collect_events_until(&watcher, Duration::from_secs(5), |seen| { + let created = |dir: &std::path::Path| { + seen.iter().any(|e| match e { + FileEvent::Created(p) => p.starts_with(dir), + _ => false, + }) + }; + created(first.path()) && created(second.path()) + }); + for dir in [first.path(), second.path()] { + assert!( + events + .iter() + .any(|e| matches!(e, FileEvent::Created(p) if p.starts_with(dir))), + "no Created event under {dir:?}, saw: {events:?}" + ); + } +} From 12cc6754fbafc0d44a111b0f9b5ff2d9d26861c4 Mon Sep 17 00:00:00 2001 From: thekevinbot <248217931+thekevinbot@users.noreply.github.com> Date: Mon, 5 Oct 2026 22:20:05 -0400 Subject: [PATCH 3/8] e2e attestation for claude/1232-multi-watch --- packages/ts/e2e-attestations/claude-1232-multi-watch.json | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 packages/ts/e2e-attestations/claude-1232-multi-watch.json diff --git a/packages/ts/e2e-attestations/claude-1232-multi-watch.json b/packages/ts/e2e-attestations/claude-1232-multi-watch.json new file mode 100644 index 000000000..84a2a9d8c --- /dev/null +++ b/packages/ts/e2e-attestations/claude-1232-multi-watch.json @@ -0,0 +1,7 @@ +{ + "command": "pnpm test:e2e", + "ran_at": 1791253205, + "exit_code": 0, + "commit": "d1b638bb94b447b365de252489749c11eb465cc7", + "branch": "claude/1232-multi-watch" +} From 51e4cf9c11482745abdcabab5d9c6b0b9cadacd7 Mon Sep 17 00:00:00 2001 From: thekevinbot <248217931+thekevinbot@users.noreply.github.com> Date: Mon, 5 Oct 2026 22:22:27 -0400 Subject: [PATCH 4/8] e2e attestation for claude/1232-multi-watch --- .../python/e2e-attestations/claude-1232-multi-watch.json | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 packages/python/e2e-attestations/claude-1232-multi-watch.json diff --git a/packages/python/e2e-attestations/claude-1232-multi-watch.json b/packages/python/e2e-attestations/claude-1232-multi-watch.json new file mode 100644 index 000000000..cfae10dc3 --- /dev/null +++ b/packages/python/e2e-attestations/claude-1232-multi-watch.json @@ -0,0 +1,7 @@ +{ + "command": "uv run python -m pytest tests/e2e/ -x -q --ignore-glob=tests/e2e/glob_speed_*", + "ran_at": 1791253347, + "exit_code": 0, + "commit": "12cc6754fbafc0d44a111b0f9b5ff2d9d26861c4", + "branch": "claude/1232-multi-watch" +} From 0c0cb9f08eeefe14a26e1092d2f8d58c1de4bad9 Mon Sep 17 00:00:00 2001 From: thekevinbot <248217931+thekevinbot@users.noreply.github.com> Date: Mon, 5 Oct 2026 23:25:23 -0400 Subject: [PATCH 5/8] Test that an instance watches its table anchors --- packages/rust/src/lib.rs | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/packages/rust/src/lib.rs b/packages/rust/src/lib.rs index 37612386b..6d524f8e4 100644 --- a/packages/rust/src/lib.rs +++ b/packages/rust/src/lib.rs @@ -5463,6 +5463,15 @@ mod internal_tests { watch_roots(Path::new("/canonical-idx"), &groups) } + #[test] + fn a_built_instance_watches_its_tables_anchor() { + let dir = TempDir::new().unwrap(); + let db = populated_db(dir.path(), "**/*.txt", &["a.txt"]); + + assert_eq!(db.watch_roots(), vec![db.inner.watch_root.clone()]); + assert!(!db.watch_roots().is_empty()); + } + #[test] fn watch_roots_without_tables_is_the_index_root() { assert_eq!(roots_for(&[]), vec![PathBuf::from("/canonical-idx")]); From c55a2b7de1ae3449898be1bca349e589480158a7 Mon Sep 17 00:00:00 2001 From: thekevinbot <248217931+thekevinbot@users.noreply.github.com> Date: Mon, 5 Oct 2026 23:47:34 -0400 Subject: [PATCH 6/8] Drop the Python e2e receipt until main's Python e2e is green --- .../python/e2e-attestations/claude-1232-multi-watch.json | 7 ------- 1 file changed, 7 deletions(-) delete mode 100644 packages/python/e2e-attestations/claude-1232-multi-watch.json diff --git a/packages/python/e2e-attestations/claude-1232-multi-watch.json b/packages/python/e2e-attestations/claude-1232-multi-watch.json deleted file mode 100644 index cfae10dc3..000000000 --- a/packages/python/e2e-attestations/claude-1232-multi-watch.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "command": "uv run python -m pytest tests/e2e/ -x -q --ignore-glob=tests/e2e/glob_speed_*", - "ran_at": 1791253347, - "exit_code": 0, - "commit": "12cc6754fbafc0d44a111b0f9b5ff2d9d26861c4", - "branch": "claude/1232-multi-watch" -} From 5092c65d1c3e2d8af0a9af6e12cb2459aaf40577 Mon Sep 17 00:00:00 2001 From: thekevinbot <248217931+thekevinbot@users.noreply.github.com> Date: Tue, 6 Oct 2026 12:27:30 -0400 Subject: [PATCH 7/8] e2e attestation for claude/1232-multi-watch --- .../python/e2e-attestations/claude-1232-multi-watch.json | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 packages/python/e2e-attestations/claude-1232-multi-watch.json diff --git a/packages/python/e2e-attestations/claude-1232-multi-watch.json b/packages/python/e2e-attestations/claude-1232-multi-watch.json new file mode 100644 index 000000000..d89a456e4 --- /dev/null +++ b/packages/python/e2e-attestations/claude-1232-multi-watch.json @@ -0,0 +1,7 @@ +{ + "command": "uv run python -m pytest tests/e2e/capture_collision_test.py tests/e2e/declared_table_name_test.py tests/e2e/extension_package_test.py tests/e2e/query_contract_test.py tests/e2e/plugin_discovery_test.py tests/e2e/sigint_interrupt_test.py tests/e2e/fanout_test.py tests/e2e/use_case_transcripts_test.py tests/e2e/plugin_extension_discovery_test.py tests/e2e/on_file_query_test.py -x -q", + "ran_at": 1791304050, + "exit_code": 0, + "commit": "8618c31f0280a80e7d09550fdb931c6460741db7", + "branch": "claude/1232-multi-watch" +} From fe6b30741db4b0aa0253edf8600dff87cd54384b Mon Sep 17 00:00:00 2001 From: thekevinbot <248217931+thekevinbot@users.noreply.github.com> Date: Tue, 6 Oct 2026 12:28:13 -0400 Subject: [PATCH 8/8] e2e attestation for claude/1232-multi-watch --- packages/ts/e2e-attestations/claude-1232-multi-watch.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/ts/e2e-attestations/claude-1232-multi-watch.json b/packages/ts/e2e-attestations/claude-1232-multi-watch.json index 84a2a9d8c..8a10e3c3c 100644 --- a/packages/ts/e2e-attestations/claude-1232-multi-watch.json +++ b/packages/ts/e2e-attestations/claude-1232-multi-watch.json @@ -1,7 +1,7 @@ { "command": "pnpm test:e2e", - "ran_at": 1791253205, + "ran_at": 1791304093, "exit_code": 0, - "commit": "d1b638bb94b447b365de252489749c11eb465cc7", + "commit": "5092c65d1c3e2d8af0a9af6e12cb2459aaf40577", "branch": "claude/1232-multi-watch" }