diff --git a/CHANGELOG.md b/CHANGELOG.md index 0cc1c1312..ab9f0027f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,6 @@ # Changelog +- **Fixed** The task cache is now stored in a per-schema-version subdirectory (e.g. `node_modules/.vite/task-cache/v13/`), so switching between branches that pin different Vite+ versions no longer fails with `Unrecognized database version`. Each version keeps its own cache directory; a cache from a different version is ignored rather than aborting the run ([#433](https://github.com/voidzero-dev/vite-task/pull/433)) - **Added** A task's `env` and `untrackedEnv` glob patterns now support `!` negation: a `!`-prefixed pattern excludes matching variables (e.g. `["VITE_*", "!VITE_SECRET"]` tracks every `VITE_*` except `VITE_SECRET`) ([#425](https://github.com/voidzero-dev/vite-task/pull/425)) - **Fixed** `package.json` and `pnpm-workspace.yaml` files with a UTF-8 BOM no longer fail to parse ([#424](https://github.com/voidzero-dev/vite-task/pull/424)) - **Changed** `vp run --filter ` now exits 0 with a warning when the filter matches no packages, matching pnpm. Use `--fail-if-no-match` to restore the previous strict behavior ([#393](https://github.com/voidzero-dev/vite-task/pull/393)) diff --git a/crates/vite_task/src/session/cache/mod.rs b/crates/vite_task/src/session/cache/mod.rs index eb56fb120..aa986703d 100644 --- a/crates/vite_task/src/session/cache/mod.rs +++ b/crates/vite_task/src/session/cache/mod.rs @@ -11,7 +11,7 @@ pub use display::{ SpawnFingerprintChange, detect_spawn_fingerprint_changes, format_input_change_str, format_spawn_change, }; -use rusqlite::{Connection, OptionalExtension as _, config::DbConfig}; +use rusqlite::{Connection, OptionalExtension as _}; use serde::{Deserialize, Serialize}; use tokio::sync::Mutex; use vite_path::{AbsolutePath, RelativePathBuf}; @@ -188,9 +188,30 @@ pub fn split_path(path: &str) -> (Option<&str>, &str) { } } +/// On-disk schema version of the cache database. +/// +/// Bump this whenever the database layout (table structure, serialization +/// format, or fingerprint semantics) changes in an incompatible way. +/// +/// The version is encoded *only* in the cache directory name (see +/// [`cache_schema_dir_name`], e.g. `v13`); there is no in-database version +/// marker. Keying the storage location on this version means Vite+ builds that +/// pin different schema versions never open each other's database: each keeps +/// its own cache warm across branch switches, and a cache from a different +/// version is simply ignored (it lives in a directory this build never looks +/// at) rather than aborting the run. Bumping the version starts a fresh cache. +const CACHE_SCHEMA_VERSION: u32 = 13; + +/// Name of the per-version subdirectory (e.g. `v13`) under the task-cache +/// directory that holds the database and output archives for the current +/// [`CACHE_SCHEMA_VERSION`]. +pub fn cache_schema_dir_name() -> Str { + vite_str::format!("v{CACHE_SCHEMA_VERSION}") +} + impl ExecutionCache { #[tracing::instrument(level = "debug", skip_all)] - pub fn load_from_path(path: &AbsolutePath, program_name: &str) -> anyhow::Result { + pub fn load_from_path(path: &AbsolutePath) -> anyhow::Result { tracing::info!("Creating task cache directory at {:?}", path); std::fs::create_dir_all(path)?; @@ -201,38 +222,18 @@ impl ExecutionCache { let db_path = path.join("cache.db"); let conn = Connection::open(db_path.as_path())?; - conn.execute_batch("PRAGMA journal_mode=WAL;")?; - loop { - let user_version: u32 = conn.query_one("PRAGMA user_version", (), |row| row.get(0))?; - match user_version { - 0 => { - // fresh new db - conn.execute( - "CREATE TABLE cache_entries (key BLOB PRIMARY KEY, value BLOB);", - (), - )?; - conn.execute( - "CREATE TABLE task_fingerprints (key BLOB PRIMARY KEY, value BLOB);", - (), - )?; - conn.execute("PRAGMA user_version = 13", ())?; - } - 1..=12 => { - // old internal db version. reset - conn.set_db_config(DbConfig::SQLITE_DBCONFIG_RESET_DATABASE, true)?; - conn.execute("VACUUM", ())?; - conn.set_db_config(DbConfig::SQLITE_DBCONFIG_RESET_DATABASE, false)?; - } - 13 => break, // current version - 14.. => { - return Err(anyhow::anyhow!( - "Unrecognized database version: {user_version}. \ - The cache may have been created by a newer version of Vite Task. \ - Run `{program_name} cache clean` to remove it." - )); - } - } - } + // The schema version is encoded in the directory name (see + // `cache_schema_dir_name`), so any database in this directory already has + // the current schema: there is nothing to migrate or version-check. Set + // WAL mode and ensure the tables exist in a single round-trip. On an + // existing database the `IF NOT EXISTS` creates are near-free no-ops (a + // schema lookup, no write); on a fresh one they create the tables. This + // runs once per process (the cache is `OnceCell`-initialized). + conn.execute_batch( + "PRAGMA journal_mode=WAL; + CREATE TABLE IF NOT EXISTS cache_entries (key BLOB PRIMARY KEY, value BLOB); + CREATE TABLE IF NOT EXISTS task_fingerprints (key BLOB PRIMARY KEY, value BLOB);", + )?; // Lock is released when lock_file is dropped Ok(Self { conn: Mutex::new(conn) }) } @@ -527,3 +528,73 @@ impl ExecutionCache { Ok(()) } } + +#[cfg(test)] +mod tests { + use rusqlite::Connection; + use tempfile::TempDir; + use vite_path::AbsolutePathBuf; + + use super::*; + + fn temp_dir() -> (TempDir, AbsolutePathBuf) { + let tmp = TempDir::new().unwrap(); + let dir = AbsolutePathBuf::new(tmp.path().to_path_buf()).unwrap(); + (tmp, dir) + } + + fn open_raw(db: &AbsolutePath) -> Connection { + Connection::open(db.as_path()).unwrap() + } + + /// Reopening the same cache directory keeps existing entries: the tables are + /// created with `IF NOT EXISTS`, so a second open never wipes the database. + #[test] + fn reopening_preserves_existing_entries() { + let (_tmp, dir) = temp_dir(); + + drop(ExecutionCache::load_from_path(&dir).unwrap()); + { + let conn = open_raw(&dir.join("cache.db")); + conn.execute("INSERT INTO cache_entries (key, value) VALUES (X'01', X'02')", ()) + .unwrap(); + } + + // Reopening must not recreate or clear the tables. + drop(ExecutionCache::load_from_path(&dir).unwrap()); + + let count: u32 = open_raw(&dir.join("cache.db")) + .query_one("SELECT COUNT(*) FROM cache_entries", (), |r| r.get(0)) + .unwrap(); + assert_eq!(count, 1); + } + + /// Regression test for vite-plus#1785: two different schema-version + /// directories under the same cache base are fully independent, so caches + /// from different Vite+ versions never collide (each version reads and + /// writes only its own directory). + #[test] + fn version_directories_are_isolated() { + let (_tmp, base) = temp_dir(); + + let dir_a = base.join("v13"); + let dir_b = base.join("v14"); + + drop(ExecutionCache::load_from_path(&dir_a).unwrap()); + drop(ExecutionCache::load_from_path(&dir_b).unwrap()); + + assert!(dir_a.join("cache.db").as_path().exists()); + assert!(dir_b.join("cache.db").as_path().exists()); + + // A row written into A is invisible to B. + { + let conn = open_raw(&dir_a.join("cache.db")); + conn.execute("INSERT INTO cache_entries (key, value) VALUES (X'01', X'02')", ()) + .unwrap(); + } + let count_b: u32 = open_raw(&dir_b.join("cache.db")) + .query_one("SELECT COUNT(*) FROM cache_entries", (), |r| r.get(0)) + .unwrap(); + assert_eq!(count_b, 0); + } +} diff --git a/crates/vite_task/src/session/mod.rs b/crates/vite_task/src/session/mod.rs index 19711dd48..c21f5aa9e 100644 --- a/crates/vite_task/src/session/mod.rs +++ b/crates/vite_task/src/session/mod.rs @@ -169,7 +169,13 @@ pub struct Session<'a> { /// Cache is lazily initialized to avoid `SQLite` race conditions when multiple /// processes (e.g., parallel `vt lib` commands) start simultaneously. cache: OnceCell, + /// Per-schema-version cache directory (e.g. `node_modules/.vite/task-cache/v13`) + /// that holds the database and output archives for this build. cache_path: AbsolutePathBuf, + /// Root task-cache directory (parent of all `vN` version directories). + /// Used by `cache clean` to remove every version's cache (and any leftover + /// from a pre-versioned layout) in one shot. + cache_root: AbsolutePathBuf, } fn get_cache_path_of_workspace(workspace_root: &AbsolutePath) -> AbsolutePathBuf { @@ -220,7 +226,10 @@ impl<'a> Session<'a> { config: SessionConfig<'a>, ) -> anyhow::Result { let (workspace_root, _) = find_workspace_root(&cwd)?; - let cache_path = get_cache_path_of_workspace(&workspace_root.path); + let cache_root = get_cache_path_of_workspace(&workspace_root.path); + // Nest the cache in a per-schema-version subdirectory so builds that pin + // different schema versions don't share (and corrupt) one database. + let cache_path = cache_root.join(cache::cache_schema_dir_name().as_str()); // Prepend workspace's node_modules/.bin to PATH let workspace_node_modules_bin = workspace_root.path.join("node_modules").join(".bin"); @@ -239,6 +248,7 @@ impl<'a> Session<'a> { program_name: config.program_name, cache: OnceCell::new(), cache_path, + cache_root, }) } @@ -403,8 +413,10 @@ impl<'a> Session<'a> { fn handle_cache_command(&self, subcmd: &CacheSubcommand) -> Result<(), SessionError> { match subcmd { CacheSubcommand::Clean => { - if self.cache_path.as_path().exists() { - std::fs::remove_dir_all(&self.cache_path)?; + // Remove the whole task-cache directory (every version), not just + // the current build's `vN` subdirectory. + if self.cache_root.as_path().exists() { + std::fs::remove_dir_all(&self.cache_root)?; } } } @@ -586,9 +598,7 @@ impl<'a> Session<'a> { /// /// Returns an error if the cache database cannot be loaded or created. pub fn cache(&self) -> anyhow::Result<&ExecutionCache> { - self.cache.get_or_try_init(|| { - ExecutionCache::load_from_path(&self.cache_path, &self.program_name) - }) + self.cache.get_or_try_init(|| ExecutionCache::load_from_path(&self.cache_path)) } pub fn workspace_path(&self) -> Arc { diff --git a/crates/vite_task_bin/src/vtt/list_dir.rs b/crates/vite_task_bin/src/vtt/list_dir.rs index 7a51297c6..0005c21f0 100644 --- a/crates/vite_task_bin/src/vtt/list_dir.rs +++ b/crates/vite_task_bin/src/vtt/list_dir.rs @@ -1,16 +1,23 @@ /// List entries in a directory, sorted by name, one per line. /// -/// Usage: `vtt list-dir [--ext ]` +/// Usage: `vtt list-dir [--ext ] [--recursive]` /// /// With `--ext`, only entries whose filename ends with the given suffix are /// printed (the leading `.` is part of the suffix you pass, e.g. `.tar.zst`). /// +/// With `--recursive`, subdirectories are walked and only their leaf files are +/// printed (by basename, not path). This lets tests assert on cache contents +/// without hardcoding the per-schema-version subdirectory (e.g. `v13`) that the +/// cache database and archives live under, which changes whenever the cache +/// schema version is bumped. +/// /// Used by e2e tests to assert on cache directory contents (e.g. exactly one /// `.tar.zst` archive after a re-run that should have cleaned up the prior /// archive). pub fn run(args: &[String]) -> Result<(), Box> { let mut dir: Option<&str> = None; let mut ext: Option<&str> = None; + let mut recursive = false; let mut i = 0; while i < args.len() { match args[i].as_str() { @@ -18,16 +25,38 @@ pub fn run(args: &[String]) -> Result<(), Box> { i += 1; ext = Some(args.get(i).ok_or("--ext requires a value")?.as_str()); } + "--recursive" => recursive = true, other if dir.is_none() => dir = Some(other), other => return Err(format!("unexpected argument: {other}").into()), } i += 1; } - let dir = dir.ok_or("Usage: vtt list-dir [--ext ]")?; + let dir = dir.ok_or("Usage: vtt list-dir [--ext ] [--recursive]")?; let mut names: Vec = Vec::new(); + collect(std::path::Path::new(dir), ext, recursive, &mut names)?; + names.sort(); + for name in names { + println!("{name}"); + } + Ok(()) +} + +/// Collect entry filenames under `dir`. In recursive mode, descend into +/// subdirectories and collect only leaf files (the directory names themselves +/// are not emitted). +fn collect( + dir: &std::path::Path, + ext: Option<&str>, + recursive: bool, + names: &mut Vec, +) -> Result<(), Box> { for entry in std::fs::read_dir(dir)? { let entry = entry?; + if recursive && entry.file_type()?.is_dir() { + collect(&entry.path(), ext, recursive, names)?; + continue; + } let name = entry.file_name().to_string_lossy().into_owned(); if let Some(suffix) = ext && !name.ends_with(suffix) @@ -36,9 +65,5 @@ pub fn run(args: &[String]) -> Result<(), Box> { } names.push(name); } - names.sort(); - for name in names { - println!("{name}"); - } Ok(()) } diff --git a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/legacy_cache_ignored/package.json b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/legacy_cache_ignored/package.json new file mode 100644 index 000000000..aeae8e0c7 --- /dev/null +++ b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/legacy_cache_ignored/package.json @@ -0,0 +1,3 @@ +{ + "name": "@test/legacy-cache-ignored" +} diff --git a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/legacy_cache_ignored/snapshots.toml b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/legacy_cache_ignored/snapshots.toml new file mode 100644 index 000000000..5028a3d31 --- /dev/null +++ b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/legacy_cache_ignored/snapshots.toml @@ -0,0 +1,23 @@ +[[e2e]] +name = "leftover_cache_from_another_version_is_ignored" +comment = """ +A cache left at the old top-level location by a different Vite+ version is ignored: this build reads and writes only its own per-schema-version subdirectory, so the leftover database never aborts the run (the bug behind vite-plus#1785) and caching still works. +""" +steps = [ + { argv = [ + "vtt", + "write-file", + "node_modules/.vite/task-cache/cache.db", + "cache from another version", + ], comment = "simulate a leftover cache database from a different Vite+ version" }, + { argv = [ + "vt", + "run", + "cached-task", + ], comment = "first run is unaffected by the leftover (cache miss)" }, + { argv = [ + "vt", + "run", + "cached-task", + ], comment = "second run hits this build's own per-version cache" }, +] diff --git a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/legacy_cache_ignored/snapshots/leftover_cache_from_another_version_is_ignored.md b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/legacy_cache_ignored/snapshots/leftover_cache_from_another_version_is_ignored.md new file mode 100644 index 000000000..a0af49ce7 --- /dev/null +++ b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/legacy_cache_ignored/snapshots/leftover_cache_from_another_version_is_ignored.md @@ -0,0 +1,31 @@ +# leftover_cache_from_another_version_is_ignored + +A cache left at the old top-level location by a different Vite+ version is ignored: this build reads and writes only its own per-schema-version subdirectory, so the leftover database never aborts the run (the bug behind vite-plus#1785) and caching still works. + +## `vtt write-file node_modules/.vite/task-cache/cache.db 'cache from another version'` + +simulate a leftover cache database from a different Vite+ version + +``` +``` + +## `vt run cached-task` + +first run is unaffected by the leftover (cache miss) + +``` +$ vtt print-file test.txt +test content +``` + +## `vt run cached-task` + +second run hits this build's own per-version cache + +``` +$ vtt print-file test.txt ◉ cache hit, replaying +test content + +--- +vt run: cache hit. +``` diff --git a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/legacy_cache_ignored/test.txt b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/legacy_cache_ignored/test.txt new file mode 100644 index 000000000..d670460b4 --- /dev/null +++ b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/legacy_cache_ignored/test.txt @@ -0,0 +1 @@ +test content diff --git a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/legacy_cache_ignored/vite-task.json b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/legacy_cache_ignored/vite-task.json new file mode 100644 index 000000000..741157963 --- /dev/null +++ b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/legacy_cache_ignored/vite-task.json @@ -0,0 +1,9 @@ +{ + "cache": true, + "tasks": { + "cached-task": { + "command": "vtt print-file test.txt", + "cache": true + } + } +} diff --git a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/output_cache_test/snapshots.toml b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/output_cache_test/snapshots.toml index 420a4ed3a..c72cc53f3 100644 --- a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/output_cache_test/snapshots.toml +++ b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/output_cache_test/snapshots.toml @@ -49,6 +49,7 @@ steps = [ "node_modules/.vite/task-cache", "--ext", ".tar.zst", + "--recursive", ], comment = "exactly one archive on disk" }, { argv = [ "vtt", @@ -67,6 +68,7 @@ steps = [ "node_modules/.vite/task-cache", "--ext", ".tar.zst", + "--recursive", ], comment = "still exactly one archive — A was cleaned up" }, ] diff --git a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/output_cache_test/snapshots/output_globs___old_archive_removed_on_rewrite.md b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/output_cache_test/snapshots/output_globs___old_archive_removed_on_rewrite.md index 6dadcc0ec..3bafc9d02 100644 --- a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/output_cache_test/snapshots/output_globs___old_archive_removed_on_rewrite.md +++ b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/output_cache_test/snapshots/output_globs___old_archive_removed_on_rewrite.md @@ -10,7 +10,7 @@ first run — cache miss, writes archive A $ vtt write-file dist/output.txt built ``` -## `vtt list-dir node_modules/.vite/task-cache --ext .tar.zst` +## `vtt list-dir node_modules/.vite/task-cache --ext .tar.zst --recursive` exactly one archive on disk @@ -33,7 +33,7 @@ second run — cache miss, writes archive B and removes A $ vtt write-file dist/output.txt built ○ cache miss: 'src/main.ts' modified, executing ``` -## `vtt list-dir node_modules/.vite/task-cache --ext .tar.zst` +## `vtt list-dir node_modules/.vite/task-cache --ext .tar.zst --recursive` still exactly one archive — A was cleaned up