Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions packages/python/e2e-attestations/claude-1232-multi-watch.json
Original file line number Diff line number Diff line change
@@ -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"
}
77 changes: 74 additions & 3 deletions packages/rust/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -425,6 +425,22 @@ struct DirSqlInner {
fs: Arc<dyn FileSystem>,
}

fn watch_roots(index_root: &Path, groups: &[AnchorGroup]) -> Vec<PathBuf> {
if groups.is_empty() {
return vec![index_root.to_path_buf()];
}
let mut roots: Vec<PathBuf> = 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,
Expand Down Expand Up @@ -558,14 +574,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<PathBuf> {
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
Expand Down Expand Up @@ -5443,6 +5466,54 @@ mod internal_tests {
assert_eq!(hits.len(), 1);
assert_eq!(hits[0].1, PathBuf::from("/elsewhere/x.txt"));
}

fn roots_for(anchors: &[&str]) -> Vec<PathBuf> {
let tables: Vec<Table> = 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 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")]);
}

#[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)]
Expand Down
10 changes: 9 additions & 1 deletion packages/rust/src/watcher.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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, notify::Error> {
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<Self, notify::Error> {
let (tx, rx) = mpsc::channel();

let mut watcher = RecommendedWatcher::new(
Expand All @@ -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,
Expand Down
86 changes: 86 additions & 0 deletions packages/rust/tests/table_anchor_watch.rs
Original file line number Diff line number Diff line change
@@ -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<RowEvent> {
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:?}");
}
31 changes: 31 additions & 0 deletions packages/rust/tests/watcher.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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:?}"
);
}
}
7 changes: 7 additions & 0 deletions packages/ts/e2e-attestations/claude-1232-multi-watch.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"command": "pnpm test:e2e",
"ran_at": 1791304093,
"exit_code": 0,
"commit": "5092c65d1c3e2d8af0a9af6e12cb2459aaf40577",
"branch": "claude/1232-multi-watch"
}
Loading