From c8ba191fa18d48619976d6b26a2a2b630e81e344 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 21 Aug 2026 02:58:21 +0000 Subject: [PATCH 1/4] =?UTF-8?q?test:=20RED=20=E2=80=94=20a=20persist=20swe?= =?UTF-8?q?ep=20cannot=20drop=20an=20extension-backed=20virtual=20table?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `prepare_persist` sweeps a stale cache before the connection has loaded any `[[dirsql.extension]]`, so `DROP TABLE` on a virtual table whose module came from an extension answers `no such module` and wedges the cache permanently. #956 covered this flow with FTS5, whose module is compiled into SQLite and so is registered on every connection — a built-in standing in for a loadable one. The fixture extension now registers a virtual table module (`dirsql_testext_vtab`) alongside its scalar function, which no built-in can substitute for, and `build_fixture_extension` moves to `tests/common/` so the integration and e2e binaries share the one implementation. Both new tests fail today with the issue's error: no such module: dirsql_testext_vtab Refs #1008 Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01K4n9Q2DYnepFrNUzk94rTF --- packages/rust/tests/common/mod.rs | 55 +++++++++ packages/rust/tests/ddl_batch.rs | 52 ++++++++- packages/rust/tests/ddl_batch_e2e.rs | 78 +++++++++++++ packages/rust/tests/extensions.rs | 56 +--------- .../rust/tests/fixtures/testext/Cargo.toml | 2 +- .../rust/tests/fixtures/testext/src/lib.rs | 105 +++++++++++++++++- 6 files changed, 288 insertions(+), 60 deletions(-) create mode 100644 packages/rust/tests/common/mod.rs diff --git a/packages/rust/tests/common/mod.rs b/packages/rust/tests/common/mod.rs new file mode 100644 index 000000000..6dfd39b89 --- /dev/null +++ b/packages/rust/tests/common/mod.rs @@ -0,0 +1,55 @@ +//! Helpers shared across integration and e2e test binaries. + +use std::path::PathBuf; +use std::process::Command; + +/// Build the test-only loadable extension fixture +/// (`tests/fixtures/testext`) and return the path to its compiled shared +/// library. Built into `CARGO_TARGET_TMPDIR` with coverage/instrumentation +/// env removed so the nested build is independent of an outer +/// `cargo llvm-cov` invocation. +pub fn build_fixture_extension() -> PathBuf { + let manifest = concat!( + env!("CARGO_MANIFEST_DIR"), + "/tests/fixtures/testext/Cargo.toml" + ); + let output = Command::new(env!("CARGO")) + .args([ + "build", + "--manifest-path", + manifest, + "--target-dir", + env!("CARGO_TARGET_TMPDIR"), + "--message-format=json", + ]) + .env_remove("RUSTFLAGS") + .env_remove("RUSTDOCFLAGS") + .env_remove("LLVM_PROFILE_FILE") + .output() + .expect("failed to spawn cargo build for the extension fixture"); + assert!( + output.status.success(), + "fixture build failed:\n{}", + String::from_utf8_lossy(&output.stderr), + ); + + // Parse cargo's JSON output for the cdylib artifact path (platform- + // independent: .so / .dylib / .dll). + let stdout = String::from_utf8(output.stdout).unwrap(); + let mut artifact: Option = None; + for line in stdout.lines() { + let Ok(msg) = serde_json::from_str::(line) else { + continue; + }; + if msg["reason"] == "compiler-artifact" { + if let Some(files) = msg["filenames"].as_array() { + for f in files.iter().filter_map(|f| f.as_str()) { + if f.ends_with(".so") || f.ends_with(".dylib") || f.ends_with(".dll") { + artifact = Some(PathBuf::from(f)); + } + } + } + } + } + artifact.expect("no cdylib artifact in cargo build output for the fixture") +} diff --git a/packages/rust/tests/ddl_batch.rs b/packages/rust/tests/ddl_batch.rs index b1890080f..9e82b59d9 100644 --- a/packages/rust/tests/ddl_batch.rs +++ b/packages/rust/tests/ddl_batch.rs @@ -5,7 +5,10 @@ //! settled afterwards against SQLite's own catalog. That is what makes //! indexes, FTS5 virtual tables and triggers expressible in config. -use dirsql::{DirSQL, Row, Table, Value}; +mod common; + +use common::build_fixture_extension; +use dirsql::{DirSQL, Extension, Row, Table, Value}; use std::collections::HashMap; use std::fs; use std::path::Path; @@ -25,6 +28,12 @@ CREATE TRIGGER notes_ad AFTER DELETE ON notes BEGIN INSERT INTO notes_fts(notes_fts, rowid, body) VALUES ('delete', old.rowid, old.body); END;"; +/// The same shape as [`FTS_DDL`], but the virtual table's module arrives with +/// a loaded extension instead of being compiled into SQLite. +const EXT_VTAB_DDL: &str = "\ +CREATE TABLE notes (path TEXT, body TEXT); +CREATE VIRTUAL TABLE notes_ext USING dirsql_testext_vtab();"; + /// A `notes` table whose rows are each markdown file's name and contents. fn notes_table(ddl: &str) -> Table { Table::new("notes", ddl, "*.md", |path| { @@ -61,6 +70,18 @@ fn build_persist(root: &Path, ddl: &str) -> dirsql::Result { .build() } +fn build_persist_with_extension(root: &Path, ddl: &str, ext: &Path) -> dirsql::Result { + DirSQL::builder() + .root(root) + .table(notes_table(ddl)) + .extension(Extension { + path: ext.to_path_buf(), + entrypoint: Some("sqlite3_extension_init".into()), + }) + .persist(None::<&Path>) + .build() +} + /// The error message from a build that must fail. `DirSQL` is not `Debug`, /// so `expect_err` is unavailable. fn build_error(result: dirsql::Result, what: &str) -> String { @@ -253,3 +274,32 @@ fn a_rebuild_sweeps_a_cache_holding_virtual_tables() { "the rebuilt index must hold the re-ingested row, got {hits:?}" ); } + +/// The sibling [`a_rebuild_sweeps_a_cache_holding_virtual_tables`] cannot be: +/// FTS5's module is compiled into SQLite, so it is registered on every +/// connection whether or not dirsql configured anything. A module that arrives +/// with a `[[dirsql.extension]]` is registered only where that extension was +/// loaded — so the sweep must run somewhere that loaded it, or `DROP TABLE` +/// answers `no such module` and the cache is wedged for every later run. +#[test] +fn a_rebuild_sweeps_a_cache_holding_an_extension_backed_virtual_table() { + let ext = build_fixture_extension(); + let root = TempDir::new().unwrap(); + seed(root.path()); + drop(build_persist_with_extension(root.path(), EXT_VTAB_DDL, &ext).expect("first build")); + + let db = build_persist_with_extension( + root.path(), + &format!("{EXT_VTAB_DDL}\nCREATE INDEX notes_path ON notes(path);"), + &ext, + ) + .expect("sweeping a cache holding an extension-provided virtual table must succeed"); + + let rows = db.query("SELECT n FROM notes_ext").unwrap(); + assert_eq!( + rows.len(), + 1, + "the rebuilt batch must have re-created the extension's virtual table, got {rows:?}" + ); + assert_eq!(rows[0]["n"], Value::Integer(42)); +} diff --git a/packages/rust/tests/ddl_batch_e2e.rs b/packages/rust/tests/ddl_batch_e2e.rs index 44d89eacb..8aa6500f3 100644 --- a/packages/rust/tests/ddl_batch_e2e.rs +++ b/packages/rust/tests/ddl_batch_e2e.rs @@ -9,10 +9,13 @@ #![cfg(all(feature = "cli", unix))] +mod common; + use std::fs; use std::process::Output; use assert_cmd::prelude::*; +use common::build_fixture_extension; use serde_json::Value; use tempfile::TempDir; @@ -41,6 +44,21 @@ fn query(root: &TempDir, sql: &str) -> Output { .expect("spawning `dirsql query` failed") } +/// Like [`query`], but keeps the on-disk cache across runs (`--persist`), so a +/// second invocation reuses — or, on an edited `ddl`, sweeps — the first's. +fn query_persist(root: &TempDir, sql: &str) -> Output { + std::process::Command::cargo_bin("dirsql") + .expect("`dirsql` binary must be built with --features cli") + .arg("query") + .arg(sql) + .arg("--config") + .arg(root.path().join(".dirsql.toml")) + .arg("--persist") + .current_dir(root.path()) + .output() + .expect("spawning `dirsql query --persist` failed") +} + fn rows(out: &Output) -> Vec { assert!( out.status.success(), @@ -155,3 +173,63 @@ ddl = "CREATE VIRTUAL TABLE records USING fts5(id, body)" "stderr must name the entry and say the table is virtual, got {stderr:?}" ); } + +/// Editing the `ddl` of a `--persist` cache that holds a virtual table from a +/// loaded extension must rebuild it, not wedge it. The sweep that precedes the +/// rebuild issues `DROP TABLE records_ext`, which only a connection carrying +/// the extension's module can execute — and once it fails, every later run +/// repeats the failure until the cache is deleted by hand. +#[test] +fn a_persisted_cache_holding_an_extension_virtual_table_survives_a_ddl_edit() { + let ext = build_fixture_extension(); + let config = |extra: &str| { + format!( + r#" +[[dirsql.extension]] +path = "{}" +entrypoint = "sqlite3_extension_init" + +[[table]] +name = "records" +glob = "data/*.json" +on-file = "cat {{path}}" +ddl = ''' +CREATE TABLE records (id TEXT, body TEXT); +CREATE VIRTUAL TABLE records_ext USING dirsql_testext_vtab();{extra} +''' +"#, + ext.display(), + ) + }; + + let root = fixture(&config("")); + let first = query_persist(&root, "SELECT n FROM records_ext"); + assert_eq!( + rows(&first)[0]["n"], + Value::from(42), + "the extension's virtual table must be queryable on a cold build" + ); + + fs::write( + root.path().join(".dirsql.toml"), + config("\nCREATE INDEX records_id ON records(id);"), + ) + .unwrap(); + + let second = query_persist(&root, "SELECT n FROM records_ext"); + assert!( + second.status.success(), + "an edited ddl must sweep and rebuild the cache, stderr: {}", + String::from_utf8_lossy(&second.stderr) + ); + assert_eq!(rows(&second)[0]["n"], Value::from(42)); + + // The wedge the issue describes is permanent: assert the *next* run is + // clean too, not merely that one rebuild squeaked through. + let third = query_persist(&root, "SELECT n FROM records_ext"); + assert!( + third.status.success(), + "the rebuilt cache must stay openable, stderr: {}", + String::from_utf8_lossy(&third.stderr) + ); +} diff --git a/packages/rust/tests/extensions.rs b/packages/rust/tests/extensions.rs index dd5d00172..79573a135 100644 --- a/packages/rust/tests/extensions.rs +++ b/packages/rust/tests/extensions.rs @@ -6,63 +6,13 @@ //! `CREATE TABLE`, then disables loading again so the SQL `load_extension()` //! function is never left open. +mod common; + +use common::build_fixture_extension; use dirsql::{DirSQL, Extension, Value}; use std::fs; -use std::path::PathBuf; -use std::process::Command; use tempfile::TempDir; -/// Build the test-only loadable extension fixture -/// (`tests/fixtures/testext`) and return the path to its compiled shared -/// library. Built into `CARGO_TARGET_TMPDIR` with coverage/instrumentation -/// env removed so the nested build is independent of an outer -/// `cargo llvm-cov` invocation. -fn build_fixture_extension() -> PathBuf { - let manifest = concat!( - env!("CARGO_MANIFEST_DIR"), - "/tests/fixtures/testext/Cargo.toml" - ); - let output = Command::new(env!("CARGO")) - .args([ - "build", - "--manifest-path", - manifest, - "--target-dir", - env!("CARGO_TARGET_TMPDIR"), - "--message-format=json", - ]) - .env_remove("RUSTFLAGS") - .env_remove("RUSTDOCFLAGS") - .env_remove("LLVM_PROFILE_FILE") - .output() - .expect("failed to spawn cargo build for the extension fixture"); - assert!( - output.status.success(), - "fixture build failed:\n{}", - String::from_utf8_lossy(&output.stderr), - ); - - // Parse cargo's JSON output for the cdylib artifact path (platform- - // independent: .so / .dylib / .dll). - let stdout = String::from_utf8(output.stdout).unwrap(); - let mut artifact: Option = None; - for line in stdout.lines() { - let Ok(msg) = serde_json::from_str::(line) else { - continue; - }; - if msg["reason"] == "compiler-artifact" { - if let Some(files) = msg["filenames"].as_array() { - for f in files.iter().filter_map(|f| f.as_str()) { - if f.ends_with(".so") || f.ends_with(".dylib") || f.ends_with(".dll") { - artifact = Some(PathBuf::from(f)); - } - } - } - } - } - artifact.expect("no cdylib artifact in cargo build output for the fixture") -} - /// A config that names an extension file which does not exist on disk must /// fail construction. dirsql loads configured extensions at startup and /// surfaces load failures rather than silently ignoring them. diff --git a/packages/rust/tests/fixtures/testext/Cargo.toml b/packages/rust/tests/fixtures/testext/Cargo.toml index 678e970d4..a63df869b 100644 --- a/packages/rust/tests/fixtures/testext/Cargo.toml +++ b/packages/rust/tests/fixtures/testext/Cargo.toml @@ -20,4 +20,4 @@ crate-type = ["cdylib"] # `loadable_extension` (not `bundled`): the host SQLite provides the symbols at # load time, so this links against whatever SQLite loads it — including the # bundled SQLite inside dirsql, since both resolve the same libsqlite3-sys. -rusqlite = { version = "0.34", features = ["loadable_extension", "functions"] } +rusqlite = { version = "0.34", features = ["loadable_extension", "functions", "vtab"] } diff --git a/packages/rust/tests/fixtures/testext/src/lib.rs b/packages/rust/tests/fixtures/testext/src/lib.rs index d7aa9c277..c762ea0f1 100644 --- a/packages/rust/tests/fixtures/testext/src/lib.rs +++ b/packages/rust/tests/fixtures/testext/src/lib.rs @@ -1,16 +1,33 @@ //! Minimal SQLite loadable extension for dirsql's integration tests. //! -//! Registers a single deterministic scalar function, -//! `dirsql_testext_answer()`, returning 42. A test loads this extension -//! through dirsql's public config surface and calls the function to prove the +//! Registers a deterministic scalar function, `dirsql_testext_answer()`, +//! returning 42, and a virtual table module, `dirsql_testext_vtab`, serving a +//! single row of that same answer. A test loads this extension through +//! dirsql's public config surface and calls the function to prove the //! extension was actually loaded onto the connection. +//! +//! The module is what a *built-in* one (FTS5) cannot stand in for: a table +//! created with it is only droppable on a connection that has this extension +//! loaded, so a cache holding one exercises the sweep's real dependency on +//! the module set. -use std::os::raw::{c_char, c_int}; +use std::ffi::c_int; +use std::os::raw::c_char; use rusqlite::ffi; use rusqlite::functions::FunctionFlags; +use rusqlite::vtab::{ + read_only_module, Context, CreateVTab, IndexInfo, VTab, VTabConnection, VTabCursor, VTabKind, + Values, +}; use rusqlite::{Connection, Result}; +/// The answer both the scalar function and the virtual table serve. +const ANSWER: i64 = 42; + +/// SQL module name the extension's virtual table is created with. +const MODULE_NAME: &str = "dirsql_testext_vtab"; + /// SQLite loadable-extension entry point. /// /// # Safety @@ -30,8 +47,86 @@ fn extension_init(db: Connection) -> Result { "dirsql_testext_answer", 0, FunctionFlags::SQLITE_DETERMINISTIC, - |_ctx| Ok(42_i64), + |_ctx| Ok(ANSWER), )?; + let aux: Option<()> = None; + db.create_module(MODULE_NAME, read_only_module::(), aux)?; // `false` => not a persistent extension; unloaded with the connection. Ok(false) } + +#[repr(C)] +struct AnswerTab { + /// Base class. Must be first. + base: ffi::sqlite3_vtab, +} + +unsafe impl<'vtab> VTab<'vtab> for AnswerTab { + type Aux = (); + type Cursor = AnswerCursor; + + fn connect( + _db: &mut VTabConnection, + _aux: Option<&()>, + _args: &[&[u8]], + ) -> Result<(String, Self)> { + Ok(( + "CREATE TABLE x(n INTEGER)".to_owned(), + Self { + base: ffi::sqlite3_vtab::default(), + }, + )) + } + + fn best_index(&self, info: &mut IndexInfo) -> Result<()> { + info.set_estimated_cost(1.); + Ok(()) + } + + fn open(&'vtab mut self) -> Result { + Ok(AnswerCursor { + base: ffi::sqlite3_vtab_cursor::default(), + row: 0, + }) + } +} + +impl<'vtab> CreateVTab<'vtab> for AnswerTab { + const KIND: VTabKind = VTabKind::Default; +} + +#[repr(C)] +struct AnswerCursor { + /// Base class. Must be first. + base: ffi::sqlite3_vtab_cursor, + row: i64, +} + +unsafe impl VTabCursor for AnswerCursor { + fn filter( + &mut self, + _idx_num: c_int, + _idx_str: Option<&str>, + _args: &Values<'_>, + ) -> Result<()> { + self.row = 0; + Ok(()) + } + + fn next(&mut self) -> Result<()> { + self.row += 1; + Ok(()) + } + + fn eof(&self) -> bool { + self.row >= 1 + } + + fn column(&self, ctx: &mut Context, _i: c_int) -> Result<()> { + ctx.set_result(&ANSWER) + } + + fn rowid(&self) -> Result { + Ok(self.row) + } +} From 40ea3cfe1a19eb7a83afeaf46d168ec9ce368423 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 21 Aug 2026 03:05:03 +0000 Subject: [PATCH 2/4] fix: sweep a stale persist cache once its extensions are loaded MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `prepare_persist` dropped the old user tables the moment it opened the cache — before anything had loaded the configured `[[dirsql.extension]]` entries or registered the `[[dirsql.function]]` workers. Dropping a virtual table needs its module registered on that connection, and a module that arrives with an extension is registered nowhere else, so `DROP TABLE notes_vec` answered `no such module: vec0`. Editing the `ddl` of a `--persist` cache holding one left it unopenable on every later run until `.dirsql` was removed by hand. The sweep is now deferred: `prepare_persist` only flags it (`needs_sweep`), and `finish_build` performs it after the extensions and functions are on the connection and before the batch that re-creates the tables. That is the invariant the bug was missing — the drop sees exactly the module set the batch that created these tables saw. Nothing between the two phases reads the swept tables: `reconcile_scan` is pure over the already-read file index, and the `warm` catalog probe that decides whether to re-run a batch comes after. Deferring rather than loading the extensions twice is deliberate: it is one connection, so a second `load_extension` would re-enter the init function and `sqlite3_create_module` rejects a name it already holds. Fixes #1008 Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01K4n9Q2DYnepFrNUzk94rTF --- .../2026-08-21-sweep-loads-extensions.md | 3 + packages/rust/src/lib.rs | 94 +++++++++++++++++-- 2 files changed, 90 insertions(+), 7 deletions(-) create mode 100644 packages/rust/changelog.d/2026-08-21-sweep-loads-extensions.md diff --git a/packages/rust/changelog.d/2026-08-21-sweep-loads-extensions.md b/packages/rust/changelog.d/2026-08-21-sweep-loads-extensions.md new file mode 100644 index 000000000..0c41d3e85 --- /dev/null +++ b/packages/rust/changelog.d/2026-08-21-sweep-loads-extensions.md @@ -0,0 +1,3 @@ +**Fixed** + +- **Editing the `ddl` of a `--persist` cache holding an extension-provided virtual table no longer wedges it.** The sweep that precedes a rebuild ran on the cache connection at the moment it was opened, before anything loaded the configured `[[dirsql.extension]]` entries — so `DROP TABLE` on a virtual table whose module came from an extension answered `SQLite error: no such module: vec0`, and every subsequent run repeated the failure until `.dirsql` was deleted by hand. The sweep now runs once the connection carries the same extensions and `[[dirsql.function]]` registrations the batch that created those tables had, so it can drop whatever that batch was able to create. FTS5 masked this: its module is compiled into SQLite and registered on every connection, loadable ones are not. (#1008) diff --git a/packages/rust/src/lib.rs b/packages/rust/src/lib.rs index 25b1df0e0..ed66ed7c8 100644 --- a/packages/rust/src/lib.rs +++ b/packages/rust/src/lib.rs @@ -787,6 +787,7 @@ impl DirSQL { deleted, meta: ctx.expected_meta, meta_current: ctx.meta_current, + needs_sweep: ctx.needs_sweep, }), poll_interval, path_table_parser, @@ -841,13 +842,13 @@ impl DirSQL { no_ignore, } = prepared; - let (mut db, persist_ready) = match persist { + let (mut db, persist_ready, needs_sweep) = match persist { Some(p) => { let mut db = p.db; db.set_path_table_cache(p.path); - (db, Some((p.deleted, p.meta, p.meta_current))) + (db, Some((p.deleted, p.meta, p.meta_current)), p.needs_sweep) } - None => (Db::new()?, None), + None => (Db::new()?, None, false), }; db.set_path_table_root(root.clone()); db.set_hint_legacy_files_table(hint_legacy_files_table); @@ -873,6 +874,17 @@ impl DirSQL { // query's first call to the function. functions::register_all(db.conn(), &functions).map_err(DirSqlError::sqlite)?; + // Sweep a cache the reconcile rejected. It happens *here*, after the + // extensions and functions are on the connection, rather than where + // the cache was opened: the drop has to see the same modules the batch + // that created these tables saw. A virtual table from a + // `[[dirsql.extension]]` is undroppable otherwise -- `DROP TABLE` + // answers `no such module`, and the cache stays wedged on every later + // run until it is deleted by hand (#1008). + if needs_sweep { + drop_user_tables(db.conn()).map_err(DirSqlError::sqlite)?; + } + let mut on_file_map: HashMap> = HashMap::new(); let mut strict_map: HashMap = HashMap::new(); let mut ddl_map: HashMap = HashMap::new(); @@ -1423,6 +1435,10 @@ pub struct PreparedPersist { /// write is skipped, so a run that changed nothing leaves the cache file /// byte-for-byte alone. meta_current: bool, + /// Whether the stale cache still has to be swept. Carried across the split + /// so the drop runs on a fully configured connection — see + /// [`PersistContext::needs_sweep`]. + needs_sweep: bool, } /// A file the reconcile decided to trust: its cached rows are kept as-is and @@ -1444,6 +1460,13 @@ struct PersistContext { expected_meta: HashMap, /// Whether the on-disk meta already equals `expected_meta`. meta_current: bool, + /// Whether the cache holds tables from an incompatible prior run that must + /// be dropped before the DDL batch re-creates them. The drop itself is + /// deferred to `finish_build`, which runs it once the connection carries + /// the configured extensions and functions: dropping a virtual table needs + /// its module registered, and an extension-provided module exists only on + /// a connection that loaded that extension (#1008). + needs_sweep: bool, } fn compile_matcher( @@ -1476,9 +1499,9 @@ fn compile_matcher( } /// Open (or create) the persistent SQLite cache and read its meta. If the -/// meta is missing or incompatible with the current build, the cache is -/// wiped and the resulting [`PersistContext`] carries an empty file index, -/// so the rest of the pipeline treats every file as new. +/// meta is missing or incompatible with the current build, the resulting +/// [`PersistContext`] carries an empty file index and asks for a sweep, so +/// the rest of the pipeline treats every file as new. fn prepare_persist( root: &Path, tables: &[Table], @@ -1501,7 +1524,6 @@ fn prepare_persist( let cached = if compatible { read_cached_files(db.conn()).map_err(DirSqlError::sqlite)? } else { - drop_user_tables(db.conn()).map_err(DirSqlError::sqlite)?; HashMap::new() }; @@ -1513,6 +1535,7 @@ fn prepare_persist( // equality, which is what lets the write be skipped outright. meta_current: compatible && cached_meta.len() == expected_meta.len(), expected_meta, + needs_sweep: !compatible, }) } @@ -2629,6 +2652,7 @@ mod internal_tests { cached, expected_meta: HashMap::new(), meta_current: false, + needs_sweep: false, }; let scanned = vec![(abs.clone(), "t".to_string())]; let (to_parse, trusted, deleted) = @@ -2666,6 +2690,7 @@ mod internal_tests { cached, expected_meta: HashMap::new(), meta_current: false, + needs_sweep: false, }; let scanned = vec![(abs.clone(), "t".to_string())]; let (to_parse, trusted, _deleted) = @@ -2683,6 +2708,7 @@ mod internal_tests { cached: HashMap::new(), expected_meta: HashMap::new(), meta_current: false, + needs_sweep: false, }; let missing = dir.path().join("ghost.txt"); let scanned = vec![(missing, "t".to_string())]; @@ -3350,6 +3376,57 @@ mod internal_tests { let ctx = prepare_persist(dir.path(), &tables, &[], None).unwrap(); assert!(ctx.cached.is_empty()); assert!(!ctx.expected_meta.is_empty()); + assert!( + ctx.needs_sweep, + "an incompatible cache must be handed on for sweeping" + ); + } + + /// The counterpart: a cache whose meta matches keeps its tables, so no + /// sweep is requested and the warm rows survive. + #[test] + fn prepare_persist_warm_cache_asks_for_no_sweep() { + let dir = TempDir::new().unwrap(); + let cache = dir.path().join("cache.db"); + let tables = vec![Table::new( + "t", + "CREATE TABLE t (x TEXT)", + "*.txt", + |_| vec![], + )]; + drop( + DirSQL::builder() + .root(dir.path()) + .tables(tables.clone()) + .persist(Some(&cache)) + .build() + .unwrap(), + ); + let ctx = prepare_persist(dir.path(), &tables, &[], Some(&cache)).unwrap(); + assert!(!ctx.needs_sweep, "a compatible cache must not be swept"); + } + + /// The sweep itself, from the build side: an edited `ddl` invalidates the + /// cache, and only a sweep lets the batch re-run — an un-dropped `t` reads + /// as a warm table and the new column never appears. + #[test] + fn an_edited_ddl_sweeps_the_cache_before_rebuilding() { + let dir = TempDir::new().unwrap(); + let cache = dir.path().join("cache.db"); + let build = |ddl: &'static str| { + DirSQL::builder() + .root(dir.path()) + .tables(vec![Table::new("t", ddl, "*.txt", |_| vec![])]) + .persist(Some(&cache)) + .build() + }; + drop(build("CREATE TABLE t (x TEXT)").unwrap()); + + let second = build("CREATE TABLE t (x TEXT, y TEXT)").unwrap(); + assert!( + second.query("SELECT y FROM t").is_ok(), + "the rebuilt table must carry the edited batch's new column" + ); } /// `snapshot_ns > mtime_ns`: the file is outside the racy window, so a @@ -3377,6 +3454,7 @@ mod internal_tests { cached, expected_meta: HashMap::new(), meta_current: false, + needs_sweep: false, }; let scanned = vec![(abs, "t".to_string())]; let (to_parse, trusted, deleted) = @@ -3411,6 +3489,7 @@ mod internal_tests { cached, expected_meta: HashMap::new(), meta_current: false, + needs_sweep: false, }; let scanned = vec![(abs, "t".to_string())]; let (to_parse, trusted, _deleted) = @@ -3439,6 +3518,7 @@ mod internal_tests { cached, expected_meta: HashMap::new(), meta_current: false, + needs_sweep: false, }; let fake = FakeFs::default(); let (to_parse, trusted, deleted) = From f3fdca78f77144a6ffa5c27e91f05a07dc8dcc79 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 21 Aug 2026 03:09:50 +0000 Subject: [PATCH 3/4] e2e attestation for cc/keen-knuth-f11gyq --- packages/python/e2e-attestations/cc-keen-knuth-f11gyq.json | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 packages/python/e2e-attestations/cc-keen-knuth-f11gyq.json diff --git a/packages/python/e2e-attestations/cc-keen-knuth-f11gyq.json b/packages/python/e2e-attestations/cc-keen-knuth-f11gyq.json new file mode 100644 index 000000000..3bcd73150 --- /dev/null +++ b/packages/python/e2e-attestations/cc-keen-knuth-f11gyq.json @@ -0,0 +1,7 @@ +{ + "command": "uv run python -m pytest tests/e2e/ -x -q", + "ran_at": 1787281790, + "exit_code": 0, + "commit": "40ea3cfe1a19eb7a83afeaf46d168ec9ce368423", + "branch": "cc/keen-knuth-f11gyq" +} From 731eec7bdb19f8c63681422f55f931f0880e3992 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 21 Aug 2026 03:13:37 +0000 Subject: [PATCH 4/4] e2e attestation for cc/keen-knuth-f11gyq --- packages/ts/e2e-attestations/cc-keen-knuth-f11gyq.json | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 packages/ts/e2e-attestations/cc-keen-knuth-f11gyq.json diff --git a/packages/ts/e2e-attestations/cc-keen-knuth-f11gyq.json b/packages/ts/e2e-attestations/cc-keen-knuth-f11gyq.json new file mode 100644 index 000000000..7c4dd0b1c --- /dev/null +++ b/packages/ts/e2e-attestations/cc-keen-knuth-f11gyq.json @@ -0,0 +1,7 @@ +{ + "command": "pnpm test:e2e", + "ran_at": 1787282017, + "exit_code": 0, + "commit": "f3fdca78f77144a6ffa5c27e91f05a07dc8dcc79", + "branch": "cc/keen-knuth-f11gyq" +}