From e0cdcfca0750f1250feed2956bc73c39610d8479 Mon Sep 17 00:00:00 2001 From: MK Date: Tue, 9 Jun 2026 14:16:19 +0800 Subject: [PATCH 1/9] fix(cache): store database in per-schema-version directory The task cache was one shared cache.db keyed only by an in-DB user_version. An older vp build opening a database written by a newer build aborted with "Unrecognized database version", so switching between branches that pin different Vite+ versions broke the cache. Store the cache in a per-schema-version subdirectory (e.g. task-cache/v13/) so incompatible builds use separate databases and stay warm across branch switches. An unrecognized database is now rebuilt instead of aborting, and a one-time sweep removes the legacy top-level cache files. Closes voidzero-dev/vite-plus#1785 --- CHANGELOG.md | 1 + crates/vite_task/src/session/cache/mod.rs | 177 +++++++++++++++--- crates/vite_task/src/session/mod.rs | 114 ++++++++++- crates/vite_task_bin/src/vtt/list_dir.rs | 37 +++- .../fixtures/output_cache_test/snapshots.toml | 2 + ..._globs___old_archive_removed_on_rewrite.md | 4 +- 6 files changed, 293 insertions(+), 42 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0cc1c1312..b8ec6370d 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, and an unrecognized cache database is rebuilt instead of aborting the run ([#1785](https://github.com/voidzero-dev/vite-plus/issues/1785)) - **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..fe39f2543 100644 --- a/crates/vite_task/src/session/cache/mod.rs +++ b/crates/vite_task/src/session/cache/mod.rs @@ -188,9 +188,28 @@ 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 database lives in a per-version subdirectory named by +/// [`cache_schema_dir_name`] (e.g. `v13`). 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, +/// instead of one build aborting on a database written by another. +pub 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)?; @@ -202,35 +221,31 @@ impl ExecutionCache { let db_path = path.join("cache.db"); let conn = Connection::open(db_path.as_path())?; conn.execute_batch("PRAGMA journal_mode=WAL;")?; + let set_version_sql = vite_str::format!("PRAGMA user_version = {CACHE_SCHEMA_VERSION}"); 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." - )); - } + if user_version == CACHE_SCHEMA_VERSION { + break; // current version, ready to use + } + if 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(set_version_sql.as_str(), ())?; + } else { + // Any other version (older or newer) is incompatible with this + // build, so reset and rebuild from scratch. The per-version + // cache directory makes this unreachable in normal use, but if a + // database from a different schema version ever lands here we + // self-heal rather than aborting the run. The VACUUM clears + // `user_version` back to 0, so the next iteration recreates the + // tables above. + conn.set_db_config(DbConfig::SQLITE_DBCONFIG_RESET_DATABASE, true)?; + conn.execute("VACUUM", ())?; + conn.set_db_config(DbConfig::SQLITE_DBCONFIG_RESET_DATABASE, false)?; } } // Lock is released when lock_file is dropped @@ -527,3 +542,109 @@ 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() + } + + /// Regression test for vite-plus#1785: a cache database written by a build + /// with a *newer* schema version must not crash an older build. The older + /// build should reset and rebuild the database instead of failing with + /// "Unrecognized database version". + #[test] + fn opens_future_version_database_by_resetting() { + let (_tmp, dir) = temp_dir(); + + // Create a current-version cache, then drop it to release the lock. + let cache = ExecutionCache::load_from_path(&dir).unwrap(); + drop(cache); + + // Simulate a newer build having bumped the schema and written an entry. + let db_path = dir.join("cache.db"); + { + let conn = open_raw(&db_path); + conn.execute("INSERT INTO cache_entries (key, value) VALUES (X'01', X'02')", ()) + .unwrap(); + conn.execute( + vite_str::format!("PRAGMA user_version = {}", CACHE_SCHEMA_VERSION + 1).as_str(), + (), + ) + .unwrap(); + } + + // Re-opening with the current build must succeed (previously a hard error). + let cache = ExecutionCache::load_from_path(&dir).unwrap(); + drop(cache); + + // The database was reset back to the current version and wiped. + let conn = open_raw(&db_path); + let version: u32 = conn.query_one("PRAGMA user_version", (), |r| r.get(0)).unwrap(); + assert_eq!(version, CACHE_SCHEMA_VERSION); + let count: u32 = + conn.query_one("SELECT COUNT(*) FROM cache_entries", (), |r| r.get(0)).unwrap(); + assert_eq!(count, 0); + } + + /// An older schema version is likewise reset rather than erroring. + #[test] + fn opens_older_version_database_by_resetting() { + let (_tmp, dir) = temp_dir(); + + let cache = ExecutionCache::load_from_path(&dir).unwrap(); + drop(cache); + + let db_path = dir.join("cache.db"); + { + let conn = open_raw(&db_path); + conn.execute("PRAGMA user_version = 1", ()).unwrap(); + } + + let cache = ExecutionCache::load_from_path(&dir).unwrap(); + drop(cache); + + let conn = open_raw(&db_path); + let version: u32 = conn.query_one("PRAGMA user_version", (), |r| r.get(0)).unwrap(); + assert_eq!(version, CACHE_SCHEMA_VERSION); + } + + /// Two different schema-version directories under the same cache base are + /// fully independent, so caches from different vp versions never collide. + #[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..7789fa354 100644 --- a/crates/vite_task/src/session/mod.rs +++ b/crates/vite_task/src/session/mod.rs @@ -169,7 +169,12 @@ 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, + /// Base task-cache directory (parent of all `vN` version directories). + /// Used by `cache clean` and legacy-layout cleanup. + cache_base_path: AbsolutePathBuf, } fn get_cache_path_of_workspace(workspace_root: &AbsolutePath) -> AbsolutePathBuf { @@ -181,6 +186,37 @@ fn get_cache_path_of_workspace(workspace_root: &AbsolutePath) -> AbsolutePathBuf ) } +/// Remove cache files left behind by the pre-versioned cache layout. +/// +/// Older builds stored the database and output archives directly in the +/// task-cache directory (e.g. `task-cache/cache.db`). The current layout nests +/// them under a per-schema-version subdirectory (e.g. `task-cache/v13/`), so a +/// top-level `cache.db` is a leftover that will never be read again. When we +/// detect one, we delete it along with its sidecar files and the now-orphaned +/// `*.tar.zst` output archives. Best-effort: errors are ignored. +fn cleanup_legacy_cache_layout(cache_base_path: &AbsolutePath) { + // A top-level `cache.db` is the sentinel of the old layout. Without it + // there is nothing to migrate, so take the fast path and leave any other + // top-level files (e.g. a user's own files) untouched. + if !cache_base_path.join("cache.db").as_path().exists() { + return; + } + + for name in ["cache.db", "cache.db-wal", "cache.db-shm", "db_open.lock", "last-summary.json"] { + let _ = std::fs::remove_file(cache_base_path.join(name).as_path()); + } + + // In the new layout no archive is ever written at the top level, so any + // top-level `*.tar.zst` is an orphan from the old layout. + if let Ok(entries) = std::fs::read_dir(cache_base_path.as_path()) { + for entry in entries.flatten() { + if entry.file_name().to_string_lossy().ends_with(".tar.zst") { + let _ = std::fs::remove_file(entry.path()); + } + } + } +} + impl<'a> Session<'a> { /// Initialize a session with real environment variables and cwd /// @@ -220,7 +256,12 @@ 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_base_path = get_cache_path_of_workspace(&workspace_root.path); + // Migrate away from the pre-versioned cache layout if present. + cleanup_legacy_cache_layout(&cache_base_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_base_path.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 +280,7 @@ impl<'a> Session<'a> { program_name: config.program_name, cache: OnceCell::new(), cache_path, + cache_base_path, }) } @@ -403,8 +445,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_base_path.as_path().exists() { + std::fs::remove_dir_all(&self.cache_base_path)?; } } } @@ -586,9 +630,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 { @@ -852,3 +894,63 @@ fn stderr_supports_color() -> bool { static CACHE: OnceLock = OnceLock::new(); *CACHE.get_or_init(|| supports_color::on(supports_color::Stream::Stderr).is_some()) } + +#[cfg(test)] +mod tests { + use std::fs; + + use tempfile::TempDir; + + use super::*; + + /// When a pre-versioned cache (`task-cache/cache.db` + archives at the top + /// level) is present, the legacy files are removed while the new + /// per-version directory is left untouched. + #[test] + fn cleanup_legacy_cache_layout_removes_top_level_artifacts() { + let tmp = TempDir::new().unwrap(); + let base = AbsolutePathBuf::new(tmp.path().to_path_buf()).unwrap(); + + // Old layout: db + sidecars + an output archive directly in the base dir. + fs::write(base.join("cache.db").as_path(), b"db").unwrap(); + fs::write(base.join("cache.db-wal").as_path(), b"wal").unwrap(); + fs::write(base.join("cache.db-shm").as_path(), b"shm").unwrap(); + fs::write(base.join("db_open.lock").as_path(), b"").unwrap(); + fs::write(base.join("last-summary.json").as_path(), b"{}").unwrap(); + fs::write(base.join("abc.tar.zst").as_path(), b"archive").unwrap(); + + // New layout: a per-version directory that must be preserved. + let v_dir = base.join("v13"); + fs::create_dir_all(v_dir.as_path()).unwrap(); + fs::write(v_dir.join("cache.db").as_path(), b"keep").unwrap(); + + cleanup_legacy_cache_layout(&base); + + assert!(!base.join("cache.db").as_path().exists()); + assert!(!base.join("cache.db-wal").as_path().exists()); + assert!(!base.join("cache.db-shm").as_path().exists()); + assert!(!base.join("db_open.lock").as_path().exists()); + assert!(!base.join("last-summary.json").as_path().exists()); + assert!(!base.join("abc.tar.zst").as_path().exists()); + // The versioned cache is untouched. + assert!(v_dir.join("cache.db").as_path().exists()); + } + + /// Without the legacy `cache.db` sentinel, nothing is touched, so unrelated + /// top-level files are never deleted. + #[test] + fn cleanup_legacy_cache_layout_is_noop_without_sentinel() { + let tmp = TempDir::new().unwrap(); + let base = AbsolutePathBuf::new(tmp.path().to_path_buf()).unwrap(); + let v_dir = base.join("v13"); + fs::create_dir_all(v_dir.as_path()).unwrap(); + fs::write(v_dir.join("cache.db").as_path(), b"keep").unwrap(); + // A stray top-level archive but no legacy `cache.db`: left alone. + fs::write(base.join("orphan.tar.zst").as_path(), b"x").unwrap(); + + cleanup_legacy_cache_layout(&base); + + assert!(v_dir.join("cache.db").as_path().exists()); + assert!(base.join("orphan.tar.zst").as_path().exists()); + } +} 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/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 From 50f99d8915d0a8a81f236241d60ad559dc46680d Mon Sep 17 00:00:00 2001 From: MK Date: Tue, 9 Jun 2026 15:26:19 +0800 Subject: [PATCH 2/9] refactor(cache): simplify version match and defer legacy cleanup Use an exhaustive match for the database schema-version check instead of a loop with stacked ifs, and inline the version-set statement into the create arm. Run the one-time legacy-layout cleanup lazily on first cache open rather than on every session startup, so commands that never touch the cache don't pay for it. No behavior change. --- crates/vite_task/src/session/cache/mod.rs | 52 +++++++++++++---------- crates/vite_task/src/session/mod.rs | 10 +++-- 2 files changed, 36 insertions(+), 26 deletions(-) diff --git a/crates/vite_task/src/session/cache/mod.rs b/crates/vite_task/src/session/cache/mod.rs index fe39f2543..dfeac3f92 100644 --- a/crates/vite_task/src/session/cache/mod.rs +++ b/crates/vite_task/src/session/cache/mod.rs @@ -221,31 +221,37 @@ impl ExecutionCache { let db_path = path.join("cache.db"); let conn = Connection::open(db_path.as_path())?; conn.execute_batch("PRAGMA journal_mode=WAL;")?; - let set_version_sql = vite_str::format!("PRAGMA user_version = {CACHE_SCHEMA_VERSION}"); loop { let user_version: u32 = conn.query_one("PRAGMA user_version", (), |row| row.get(0))?; - if user_version == CACHE_SCHEMA_VERSION { - break; // current version, ready to use - } - if 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(set_version_sql.as_str(), ())?; - } else { - // Any other version (older or newer) is incompatible with this - // build, so reset and rebuild from scratch. The per-version - // cache directory makes this unreachable in normal use, but if a - // database from a different schema version ever lands here we - // self-heal rather than aborting the run. The VACUUM clears - // `user_version` back to 0, so the next iteration recreates the - // tables above. - conn.set_db_config(DbConfig::SQLITE_DBCONFIG_RESET_DATABASE, true)?; - conn.execute("VACUUM", ())?; - conn.set_db_config(DbConfig::SQLITE_DBCONFIG_RESET_DATABASE, false)?; + match user_version { + v if v == CACHE_SCHEMA_VERSION => break, // current version, ready to use + 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( + vite_str::format!("PRAGMA user_version = {CACHE_SCHEMA_VERSION}").as_str(), + (), + )?; + } + _ => { + // Any other version (older or newer) is incompatible with this + // build, so reset and rebuild from scratch. The per-version + // cache directory makes this unreachable in normal use, but if a + // database from a different schema version ever lands here we + // self-heal rather than aborting the run. The VACUUM clears + // `user_version` back to 0, so the next iteration recreates the + // tables above. + conn.set_db_config(DbConfig::SQLITE_DBCONFIG_RESET_DATABASE, true)?; + conn.execute("VACUUM", ())?; + conn.set_db_config(DbConfig::SQLITE_DBCONFIG_RESET_DATABASE, false)?; + } } } // Lock is released when lock_file is dropped diff --git a/crates/vite_task/src/session/mod.rs b/crates/vite_task/src/session/mod.rs index 7789fa354..e55ef4f6f 100644 --- a/crates/vite_task/src/session/mod.rs +++ b/crates/vite_task/src/session/mod.rs @@ -257,8 +257,6 @@ impl<'a> Session<'a> { ) -> anyhow::Result { let (workspace_root, _) = find_workspace_root(&cwd)?; let cache_base_path = get_cache_path_of_workspace(&workspace_root.path); - // Migrate away from the pre-versioned cache layout if present. - cleanup_legacy_cache_layout(&cache_base_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_base_path.join(cache::cache_schema_dir_name().as_str()); @@ -630,7 +628,13 @@ 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.cache.get_or_try_init(|| { + // One-time migration away from the pre-versioned cache layout, done + // lazily here (rather than on every session startup) so commands + // that never open the cache don't pay for it. + cleanup_legacy_cache_layout(&self.cache_base_path); + ExecutionCache::load_from_path(&self.cache_path) + }) } pub fn workspace_path(&self) -> Arc { From ba2d4f94a1f0af0efbecd0a2c0ed1d31242b6d0f Mon Sep 17 00:00:00 2001 From: MK Date: Tue, 9 Jun 2026 15:27:55 +0800 Subject: [PATCH 3/9] docs(changelog): link the vite-task PR instead of the cross-repo issue --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b8ec6370d..34c16ce64 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +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, and an unrecognized cache database is rebuilt instead of aborting the run ([#1785](https://github.com/voidzero-dev/vite-plus/issues/1785)) +- **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, and an unrecognized cache database is rebuilt instead of 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)) From 0ee1f9aa4d4ed575873136f5ba9531307c932d52 Mon Sep 17 00:00:00 2001 From: MK Date: Tue, 9 Jun 2026 15:34:05 +0800 Subject: [PATCH 4/9] test(e2e): add legacy cache migration snapshot Plants a pre-versioned top-level cache.db and an orphaned .tar.zst, then asserts the first run sweeps them and recreates the cache under the per-version subdirectory, with the second run hitting the migrated cache. --- .../legacy_cache_migration/package.json | 3 + .../legacy_cache_migration/snapshots.toml | 39 +++++++++++++ .../legacy_top_level_cache_is_migrated.md | 55 +++++++++++++++++++ .../fixtures/legacy_cache_migration/test.txt | 1 + .../legacy_cache_migration/vite-task.json | 9 +++ 5 files changed, 107 insertions(+) create mode 100644 crates/vite_task_bin/tests/e2e_snapshots/fixtures/legacy_cache_migration/package.json create mode 100644 crates/vite_task_bin/tests/e2e_snapshots/fixtures/legacy_cache_migration/snapshots.toml create mode 100644 crates/vite_task_bin/tests/e2e_snapshots/fixtures/legacy_cache_migration/snapshots/legacy_top_level_cache_is_migrated.md create mode 100644 crates/vite_task_bin/tests/e2e_snapshots/fixtures/legacy_cache_migration/test.txt create mode 100644 crates/vite_task_bin/tests/e2e_snapshots/fixtures/legacy_cache_migration/vite-task.json diff --git a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/legacy_cache_migration/package.json b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/legacy_cache_migration/package.json new file mode 100644 index 000000000..75b5938ac --- /dev/null +++ b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/legacy_cache_migration/package.json @@ -0,0 +1,3 @@ +{ + "name": "@test/legacy-cache-migration" +} diff --git a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/legacy_cache_migration/snapshots.toml b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/legacy_cache_migration/snapshots.toml new file mode 100644 index 000000000..71677e053 --- /dev/null +++ b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/legacy_cache_migration/snapshots.toml @@ -0,0 +1,39 @@ +[[e2e]] +name = "legacy_top_level_cache_is_migrated" +comment = """ +A cache left by an older Vite+ build in the pre-versioned layout (a top-level `cache.db` and an orphaned output archive) is swept on the first run, and the cache is recreated under a per-schema-version subdirectory (`vN`). This is what lets builds that pin different Vite+ versions coexist instead of aborting on each other's database. +""" +steps = [ + { argv = [ + "vtt", + "write-file", + "node_modules/.vite/task-cache/cache.db", + "legacy-db", + ], comment = "plant a legacy top-level cache database" }, + { argv = [ + "vtt", + "write-file", + "node_modules/.vite/task-cache/stale.tar.zst", + "legacy-archive", + ], comment = "plant an orphaned output archive next to it" }, + { argv = [ + "vtt", + "list-dir", + "node_modules/.vite/task-cache", + ], comment = "before: legacy files sit directly in the cache directory" }, + { argv = [ + "vt", + "run", + "cached-task", + ], comment = "first run migrates the layout, then executes (cache miss)" }, + { argv = [ + "vtt", + "list-dir", + "node_modules/.vite/task-cache", + ], comment = "after: legacy files are gone, replaced by the per-version subdir" }, + { argv = [ + "vt", + "run", + "cached-task", + ], comment = "cache hit from the migrated, versioned cache" }, +] diff --git a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/legacy_cache_migration/snapshots/legacy_top_level_cache_is_migrated.md b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/legacy_cache_migration/snapshots/legacy_top_level_cache_is_migrated.md new file mode 100644 index 000000000..a4729fda8 --- /dev/null +++ b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/legacy_cache_migration/snapshots/legacy_top_level_cache_is_migrated.md @@ -0,0 +1,55 @@ +# legacy_top_level_cache_is_migrated + +A cache left by an older Vite+ build in the pre-versioned layout (a top-level `cache.db` and an orphaned output archive) is swept on the first run, and the cache is recreated under a per-schema-version subdirectory (`vN`). This is what lets builds that pin different Vite+ versions coexist instead of aborting on each other's database. + +## `vtt write-file node_modules/.vite/task-cache/cache.db legacy-db` + +plant a legacy top-level cache database + +``` +``` + +## `vtt write-file node_modules/.vite/task-cache/stale.tar.zst legacy-archive` + +plant an orphaned output archive next to it + +``` +``` + +## `vtt list-dir node_modules/.vite/task-cache` + +before: legacy files sit directly in the cache directory + +``` +cache.db +stale.tar.zst +``` + +## `vt run cached-task` + +first run migrates the layout, then executes (cache miss) + +``` +$ vtt print-file test.txt +test content +``` + +## `vtt list-dir node_modules/.vite/task-cache` + +after: legacy files are gone, replaced by the per-version subdir + +``` +v13 +``` + +## `vt run cached-task` + +cache hit from the migrated, versioned 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_migration/test.txt b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/legacy_cache_migration/test.txt new file mode 100644 index 000000000..d670460b4 --- /dev/null +++ b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/legacy_cache_migration/test.txt @@ -0,0 +1 @@ +test content diff --git a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/legacy_cache_migration/vite-task.json b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/legacy_cache_migration/vite-task.json new file mode 100644 index 000000000..741157963 --- /dev/null +++ b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/legacy_cache_migration/vite-task.json @@ -0,0 +1,9 @@ +{ + "cache": true, + "tasks": { + "cached-task": { + "command": "vtt print-file test.txt", + "cache": true + } + } +} From 30f24a68b549b625dbc0f14b3aa0ddf871e56726 Mon Sep 17 00:00:00 2001 From: MK Date: Tue, 9 Jun 2026 15:49:20 +0800 Subject: [PATCH 5/9] test(e2e): make legacy cache migration snapshot version-agnostic Assert on extension-filtered listings (the cache db left the top level and reappears nested) instead of snapshotting the literal v13 directory, so the test survives a CACHE_SCHEMA_VERSION bump. --- .../legacy_cache_migration/snapshots.toml | 21 ++++++++++++++++-- .../legacy_top_level_cache_is_migrated.md | 22 +++++++++++++++---- 2 files changed, 37 insertions(+), 6 deletions(-) diff --git a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/legacy_cache_migration/snapshots.toml b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/legacy_cache_migration/snapshots.toml index 71677e053..fa0225a86 100644 --- a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/legacy_cache_migration/snapshots.toml +++ b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/legacy_cache_migration/snapshots.toml @@ -1,7 +1,7 @@ [[e2e]] name = "legacy_top_level_cache_is_migrated" comment = """ -A cache left by an older Vite+ build in the pre-versioned layout (a top-level `cache.db` and an orphaned output archive) is swept on the first run, and the cache is recreated under a per-schema-version subdirectory (`vN`). This is what lets builds that pin different Vite+ versions coexist instead of aborting on each other's database. +A cache left by an older Vite+ build in the pre-versioned layout (a top-level `cache.db` and an orphaned output archive) is swept on the first run, and the cache is recreated under a per-schema-version subdirectory. This is what lets builds that pin different Vite+ versions coexist instead of aborting on each other's database. The assertions avoid naming the version directory so they survive a schema-version bump. """ steps = [ { argv = [ @@ -30,7 +30,24 @@ steps = [ "vtt", "list-dir", "node_modules/.vite/task-cache", - ], comment = "after: legacy files are gone, replaced by the per-version subdir" }, + "--ext", + ".db", + ], comment = "after: the legacy top-level database is gone" }, + { argv = [ + "vtt", + "list-dir", + "node_modules/.vite/task-cache", + "--ext", + ".tar.zst", + ], comment = "after: the orphaned top-level archive is gone" }, + { argv = [ + "vtt", + "list-dir", + "node_modules/.vite/task-cache", + "--ext", + ".db", + "--recursive", + ], comment = "the cache database was recreated inside the per-version subdirectory" }, { argv = [ "vt", "run", diff --git a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/legacy_cache_migration/snapshots/legacy_top_level_cache_is_migrated.md b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/legacy_cache_migration/snapshots/legacy_top_level_cache_is_migrated.md index a4729fda8..94e844673 100644 --- a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/legacy_cache_migration/snapshots/legacy_top_level_cache_is_migrated.md +++ b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/legacy_cache_migration/snapshots/legacy_top_level_cache_is_migrated.md @@ -1,6 +1,6 @@ # legacy_top_level_cache_is_migrated -A cache left by an older Vite+ build in the pre-versioned layout (a top-level `cache.db` and an orphaned output archive) is swept on the first run, and the cache is recreated under a per-schema-version subdirectory (`vN`). This is what lets builds that pin different Vite+ versions coexist instead of aborting on each other's database. +A cache left by an older Vite+ build in the pre-versioned layout (a top-level `cache.db` and an orphaned output archive) is swept on the first run, and the cache is recreated under a per-schema-version subdirectory. This is what lets builds that pin different Vite+ versions coexist instead of aborting on each other's database. The assertions avoid naming the version directory so they survive a schema-version bump. ## `vtt write-file node_modules/.vite/task-cache/cache.db legacy-db` @@ -34,12 +34,26 @@ $ vtt print-file test.txt test content ``` -## `vtt list-dir node_modules/.vite/task-cache` +## `vtt list-dir node_modules/.vite/task-cache --ext .db` + +after: the legacy top-level database is gone + +``` +``` + +## `vtt list-dir node_modules/.vite/task-cache --ext .tar.zst` -after: legacy files are gone, replaced by the per-version subdir +after: the orphaned top-level archive is gone ``` -v13 +``` + +## `vtt list-dir node_modules/.vite/task-cache --ext .db --recursive` + +the cache database was recreated inside the per-version subdirectory + +``` +cache.db ``` ## `vt run cached-task` From b90860cc69fc38ca673c1a5b3d46fefc1d08edcf Mon Sep 17 00:00:00 2001 From: MK Date: Tue, 9 Jun 2026 16:57:22 +0800 Subject: [PATCH 6/9] refactor(cache): drop legacy migration and in-db version check The per-version cache directory already isolates incompatible schemas, so the in-database user_version is redundant: remove the PRAGMA query, the match/reset loop, and the legacy-layout sweep. load_from_path now just ensures the tables exist (CREATE TABLE IF NOT EXISTS). A cache left by another version is harmlessly ignored (it lives in a directory this build never opens) and reclaimable via vp cache clean, which still wipes the whole task-cache base. Replace the migration e2e fixture with one asserting the leftover is ignored. --- CHANGELOG.md | 2 +- crates/vite_task/src/session/cache/mod.rs | 126 +++++------------- crates/vite_task/src/session/mod.rs | 102 +------------- .../legacy_cache_ignored/package.json | 3 + .../legacy_cache_ignored/snapshots.toml | 23 ++++ ...r_cache_from_another_version_is_ignored.md | 31 +++++ .../test.txt | 0 .../vite-task.json | 0 .../legacy_cache_migration/package.json | 3 - .../legacy_cache_migration/snapshots.toml | 56 -------- .../legacy_top_level_cache_is_migrated.md | 69 ---------- 11 files changed, 97 insertions(+), 318 deletions(-) create mode 100644 crates/vite_task_bin/tests/e2e_snapshots/fixtures/legacy_cache_ignored/package.json create mode 100644 crates/vite_task_bin/tests/e2e_snapshots/fixtures/legacy_cache_ignored/snapshots.toml create mode 100644 crates/vite_task_bin/tests/e2e_snapshots/fixtures/legacy_cache_ignored/snapshots/leftover_cache_from_another_version_is_ignored.md rename crates/vite_task_bin/tests/e2e_snapshots/fixtures/{legacy_cache_migration => legacy_cache_ignored}/test.txt (100%) rename crates/vite_task_bin/tests/e2e_snapshots/fixtures/{legacy_cache_migration => legacy_cache_ignored}/vite-task.json (100%) delete mode 100644 crates/vite_task_bin/tests/e2e_snapshots/fixtures/legacy_cache_migration/package.json delete mode 100644 crates/vite_task_bin/tests/e2e_snapshots/fixtures/legacy_cache_migration/snapshots.toml delete mode 100644 crates/vite_task_bin/tests/e2e_snapshots/fixtures/legacy_cache_migration/snapshots/legacy_top_level_cache_is_migrated.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 34c16ce64..ab9f0027f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +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, and an unrecognized cache database is rebuilt instead of aborting the run ([#433](https://github.com/voidzero-dev/vite-task/pull/433)) +- **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 dfeac3f92..de48a0c7b 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}; @@ -193,11 +193,13 @@ pub fn split_path(path: &str) -> (Option<&str>, &str) { /// Bump this whenever the database layout (table structure, serialization /// format, or fingerprint semantics) changes in an incompatible way. /// -/// The database lives in a per-version subdirectory named by -/// [`cache_schema_dir_name`] (e.g. `v13`). 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, -/// instead of one build aborting on a database written by another. +/// 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. pub const CACHE_SCHEMA_VERSION: u32 = 13; /// Name of the per-version subdirectory (e.g. `v13`) under the task-cache @@ -221,39 +223,19 @@ 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 { - v if v == CACHE_SCHEMA_VERSION => break, // current version, ready to use - 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( - vite_str::format!("PRAGMA user_version = {CACHE_SCHEMA_VERSION}").as_str(), - (), - )?; - } - _ => { - // Any other version (older or newer) is incompatible with this - // build, so reset and rebuild from scratch. The per-version - // cache directory makes this unreachable in normal use, but if a - // database from a different schema version ever lands here we - // self-heal rather than aborting the run. The VACUUM clears - // `user_version` back to 0, so the next iteration recreates the - // tables above. - conn.set_db_config(DbConfig::SQLITE_DBCONFIG_RESET_DATABASE, true)?; - conn.execute("VACUUM", ())?; - conn.set_db_config(DbConfig::SQLITE_DBCONFIG_RESET_DATABASE, false)?; - } - } - } + // 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: just + // make sure the tables exist (a fresh database has none, an existing one + // keeps its rows). + conn.execute( + "CREATE TABLE IF NOT EXISTS cache_entries (key BLOB PRIMARY KEY, value BLOB);", + (), + )?; + conn.execute( + "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) }) } @@ -567,68 +549,32 @@ mod tests { Connection::open(db.as_path()).unwrap() } - /// Regression test for vite-plus#1785: a cache database written by a build - /// with a *newer* schema version must not crash an older build. The older - /// build should reset and rebuild the database instead of failing with - /// "Unrecognized database version". + /// 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 opens_future_version_database_by_resetting() { + fn reopening_preserves_existing_entries() { let (_tmp, dir) = temp_dir(); - // Create a current-version cache, then drop it to release the lock. - let cache = ExecutionCache::load_from_path(&dir).unwrap(); - drop(cache); - - // Simulate a newer build having bumped the schema and written an entry. - let db_path = dir.join("cache.db"); + drop(ExecutionCache::load_from_path(&dir).unwrap()); { - let conn = open_raw(&db_path); + let conn = open_raw(&dir.join("cache.db")); conn.execute("INSERT INTO cache_entries (key, value) VALUES (X'01', X'02')", ()) .unwrap(); - conn.execute( - vite_str::format!("PRAGMA user_version = {}", CACHE_SCHEMA_VERSION + 1).as_str(), - (), - ) - .unwrap(); } - // Re-opening with the current build must succeed (previously a hard error). - let cache = ExecutionCache::load_from_path(&dir).unwrap(); - drop(cache); - - // The database was reset back to the current version and wiped. - let conn = open_raw(&db_path); - let version: u32 = conn.query_one("PRAGMA user_version", (), |r| r.get(0)).unwrap(); - assert_eq!(version, CACHE_SCHEMA_VERSION); - let count: u32 = - conn.query_one("SELECT COUNT(*) FROM cache_entries", (), |r| r.get(0)).unwrap(); - assert_eq!(count, 0); - } + // Reopening must not recreate or clear the tables. + drop(ExecutionCache::load_from_path(&dir).unwrap()); - /// An older schema version is likewise reset rather than erroring. - #[test] - fn opens_older_version_database_by_resetting() { - let (_tmp, dir) = temp_dir(); - - let cache = ExecutionCache::load_from_path(&dir).unwrap(); - drop(cache); - - let db_path = dir.join("cache.db"); - { - let conn = open_raw(&db_path); - conn.execute("PRAGMA user_version = 1", ()).unwrap(); - } - - let cache = ExecutionCache::load_from_path(&dir).unwrap(); - drop(cache); - - let conn = open_raw(&db_path); - let version: u32 = conn.query_one("PRAGMA user_version", (), |r| r.get(0)).unwrap(); - assert_eq!(version, CACHE_SCHEMA_VERSION); + 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); } - /// Two different schema-version directories under the same cache base are - /// fully independent, so caches from different vp versions never collide. + /// 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(); diff --git a/crates/vite_task/src/session/mod.rs b/crates/vite_task/src/session/mod.rs index e55ef4f6f..81ce10801 100644 --- a/crates/vite_task/src/session/mod.rs +++ b/crates/vite_task/src/session/mod.rs @@ -173,7 +173,8 @@ pub struct Session<'a> { /// that holds the database and output archives for this build. cache_path: AbsolutePathBuf, /// Base task-cache directory (parent of all `vN` version directories). - /// Used by `cache clean` and legacy-layout cleanup. + /// Used by `cache clean` to remove every version's cache (and any leftover + /// from a pre-versioned layout) in one shot. cache_base_path: AbsolutePathBuf, } @@ -186,37 +187,6 @@ fn get_cache_path_of_workspace(workspace_root: &AbsolutePath) -> AbsolutePathBuf ) } -/// Remove cache files left behind by the pre-versioned cache layout. -/// -/// Older builds stored the database and output archives directly in the -/// task-cache directory (e.g. `task-cache/cache.db`). The current layout nests -/// them under a per-schema-version subdirectory (e.g. `task-cache/v13/`), so a -/// top-level `cache.db` is a leftover that will never be read again. When we -/// detect one, we delete it along with its sidecar files and the now-orphaned -/// `*.tar.zst` output archives. Best-effort: errors are ignored. -fn cleanup_legacy_cache_layout(cache_base_path: &AbsolutePath) { - // A top-level `cache.db` is the sentinel of the old layout. Without it - // there is nothing to migrate, so take the fast path and leave any other - // top-level files (e.g. a user's own files) untouched. - if !cache_base_path.join("cache.db").as_path().exists() { - return; - } - - for name in ["cache.db", "cache.db-wal", "cache.db-shm", "db_open.lock", "last-summary.json"] { - let _ = std::fs::remove_file(cache_base_path.join(name).as_path()); - } - - // In the new layout no archive is ever written at the top level, so any - // top-level `*.tar.zst` is an orphan from the old layout. - if let Ok(entries) = std::fs::read_dir(cache_base_path.as_path()) { - for entry in entries.flatten() { - if entry.file_name().to_string_lossy().ends_with(".tar.zst") { - let _ = std::fs::remove_file(entry.path()); - } - } - } -} - impl<'a> Session<'a> { /// Initialize a session with real environment variables and cwd /// @@ -628,13 +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(|| { - // One-time migration away from the pre-versioned cache layout, done - // lazily here (rather than on every session startup) so commands - // that never open the cache don't pay for it. - cleanup_legacy_cache_layout(&self.cache_base_path); - ExecutionCache::load_from_path(&self.cache_path) - }) + self.cache.get_or_try_init(|| ExecutionCache::load_from_path(&self.cache_path)) } pub fn workspace_path(&self) -> Arc { @@ -898,63 +862,3 @@ fn stderr_supports_color() -> bool { static CACHE: OnceLock = OnceLock::new(); *CACHE.get_or_init(|| supports_color::on(supports_color::Stream::Stderr).is_some()) } - -#[cfg(test)] -mod tests { - use std::fs; - - use tempfile::TempDir; - - use super::*; - - /// When a pre-versioned cache (`task-cache/cache.db` + archives at the top - /// level) is present, the legacy files are removed while the new - /// per-version directory is left untouched. - #[test] - fn cleanup_legacy_cache_layout_removes_top_level_artifacts() { - let tmp = TempDir::new().unwrap(); - let base = AbsolutePathBuf::new(tmp.path().to_path_buf()).unwrap(); - - // Old layout: db + sidecars + an output archive directly in the base dir. - fs::write(base.join("cache.db").as_path(), b"db").unwrap(); - fs::write(base.join("cache.db-wal").as_path(), b"wal").unwrap(); - fs::write(base.join("cache.db-shm").as_path(), b"shm").unwrap(); - fs::write(base.join("db_open.lock").as_path(), b"").unwrap(); - fs::write(base.join("last-summary.json").as_path(), b"{}").unwrap(); - fs::write(base.join("abc.tar.zst").as_path(), b"archive").unwrap(); - - // New layout: a per-version directory that must be preserved. - let v_dir = base.join("v13"); - fs::create_dir_all(v_dir.as_path()).unwrap(); - fs::write(v_dir.join("cache.db").as_path(), b"keep").unwrap(); - - cleanup_legacy_cache_layout(&base); - - assert!(!base.join("cache.db").as_path().exists()); - assert!(!base.join("cache.db-wal").as_path().exists()); - assert!(!base.join("cache.db-shm").as_path().exists()); - assert!(!base.join("db_open.lock").as_path().exists()); - assert!(!base.join("last-summary.json").as_path().exists()); - assert!(!base.join("abc.tar.zst").as_path().exists()); - // The versioned cache is untouched. - assert!(v_dir.join("cache.db").as_path().exists()); - } - - /// Without the legacy `cache.db` sentinel, nothing is touched, so unrelated - /// top-level files are never deleted. - #[test] - fn cleanup_legacy_cache_layout_is_noop_without_sentinel() { - let tmp = TempDir::new().unwrap(); - let base = AbsolutePathBuf::new(tmp.path().to_path_buf()).unwrap(); - let v_dir = base.join("v13"); - fs::create_dir_all(v_dir.as_path()).unwrap(); - fs::write(v_dir.join("cache.db").as_path(), b"keep").unwrap(); - // A stray top-level archive but no legacy `cache.db`: left alone. - fs::write(base.join("orphan.tar.zst").as_path(), b"x").unwrap(); - - cleanup_legacy_cache_layout(&base); - - assert!(v_dir.join("cache.db").as_path().exists()); - assert!(base.join("orphan.tar.zst").as_path().exists()); - } -} 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_migration/test.txt b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/legacy_cache_ignored/test.txt similarity index 100% rename from crates/vite_task_bin/tests/e2e_snapshots/fixtures/legacy_cache_migration/test.txt rename to crates/vite_task_bin/tests/e2e_snapshots/fixtures/legacy_cache_ignored/test.txt diff --git a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/legacy_cache_migration/vite-task.json b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/legacy_cache_ignored/vite-task.json similarity index 100% rename from crates/vite_task_bin/tests/e2e_snapshots/fixtures/legacy_cache_migration/vite-task.json rename to crates/vite_task_bin/tests/e2e_snapshots/fixtures/legacy_cache_ignored/vite-task.json diff --git a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/legacy_cache_migration/package.json b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/legacy_cache_migration/package.json deleted file mode 100644 index 75b5938ac..000000000 --- a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/legacy_cache_migration/package.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "name": "@test/legacy-cache-migration" -} diff --git a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/legacy_cache_migration/snapshots.toml b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/legacy_cache_migration/snapshots.toml deleted file mode 100644 index fa0225a86..000000000 --- a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/legacy_cache_migration/snapshots.toml +++ /dev/null @@ -1,56 +0,0 @@ -[[e2e]] -name = "legacy_top_level_cache_is_migrated" -comment = """ -A cache left by an older Vite+ build in the pre-versioned layout (a top-level `cache.db` and an orphaned output archive) is swept on the first run, and the cache is recreated under a per-schema-version subdirectory. This is what lets builds that pin different Vite+ versions coexist instead of aborting on each other's database. The assertions avoid naming the version directory so they survive a schema-version bump. -""" -steps = [ - { argv = [ - "vtt", - "write-file", - "node_modules/.vite/task-cache/cache.db", - "legacy-db", - ], comment = "plant a legacy top-level cache database" }, - { argv = [ - "vtt", - "write-file", - "node_modules/.vite/task-cache/stale.tar.zst", - "legacy-archive", - ], comment = "plant an orphaned output archive next to it" }, - { argv = [ - "vtt", - "list-dir", - "node_modules/.vite/task-cache", - ], comment = "before: legacy files sit directly in the cache directory" }, - { argv = [ - "vt", - "run", - "cached-task", - ], comment = "first run migrates the layout, then executes (cache miss)" }, - { argv = [ - "vtt", - "list-dir", - "node_modules/.vite/task-cache", - "--ext", - ".db", - ], comment = "after: the legacy top-level database is gone" }, - { argv = [ - "vtt", - "list-dir", - "node_modules/.vite/task-cache", - "--ext", - ".tar.zst", - ], comment = "after: the orphaned top-level archive is gone" }, - { argv = [ - "vtt", - "list-dir", - "node_modules/.vite/task-cache", - "--ext", - ".db", - "--recursive", - ], comment = "the cache database was recreated inside the per-version subdirectory" }, - { argv = [ - "vt", - "run", - "cached-task", - ], comment = "cache hit from the migrated, versioned cache" }, -] diff --git a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/legacy_cache_migration/snapshots/legacy_top_level_cache_is_migrated.md b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/legacy_cache_migration/snapshots/legacy_top_level_cache_is_migrated.md deleted file mode 100644 index 94e844673..000000000 --- a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/legacy_cache_migration/snapshots/legacy_top_level_cache_is_migrated.md +++ /dev/null @@ -1,69 +0,0 @@ -# legacy_top_level_cache_is_migrated - -A cache left by an older Vite+ build in the pre-versioned layout (a top-level `cache.db` and an orphaned output archive) is swept on the first run, and the cache is recreated under a per-schema-version subdirectory. This is what lets builds that pin different Vite+ versions coexist instead of aborting on each other's database. The assertions avoid naming the version directory so they survive a schema-version bump. - -## `vtt write-file node_modules/.vite/task-cache/cache.db legacy-db` - -plant a legacy top-level cache database - -``` -``` - -## `vtt write-file node_modules/.vite/task-cache/stale.tar.zst legacy-archive` - -plant an orphaned output archive next to it - -``` -``` - -## `vtt list-dir node_modules/.vite/task-cache` - -before: legacy files sit directly in the cache directory - -``` -cache.db -stale.tar.zst -``` - -## `vt run cached-task` - -first run migrates the layout, then executes (cache miss) - -``` -$ vtt print-file test.txt -test content -``` - -## `vtt list-dir node_modules/.vite/task-cache --ext .db` - -after: the legacy top-level database is gone - -``` -``` - -## `vtt list-dir node_modules/.vite/task-cache --ext .tar.zst` - -after: the orphaned top-level archive is gone - -``` -``` - -## `vtt list-dir node_modules/.vite/task-cache --ext .db --recursive` - -the cache database was recreated inside the per-version subdirectory - -``` -cache.db -``` - -## `vt run cached-task` - -cache hit from the migrated, versioned cache - -``` -$ vtt print-file test.txt ◉ cache hit, replaying -test content - ---- -vt run: cache hit. -``` From 1a1937fc8c2c5ac01b52f8fab326fdb78e2554eb Mon Sep 17 00:00:00 2001 From: MK Date: Tue, 9 Jun 2026 17:05:23 +0800 Subject: [PATCH 7/9] refactor(cache): open the database in a single round-trip Fold the WAL pragma and the two CREATE TABLE IF NOT EXISTS statements into one execute_batch, so opening the cache is one SQLite call instead of three. On an existing database the IF NOT EXISTS creates are near-free no-ops; load_from_path runs once per process (OnceCell). --- crates/vite_task/src/session/cache/mod.rs | 20 +++++++++----------- 1 file changed, 9 insertions(+), 11 deletions(-) diff --git a/crates/vite_task/src/session/cache/mod.rs b/crates/vite_task/src/session/cache/mod.rs index de48a0c7b..822714ce6 100644 --- a/crates/vite_task/src/session/cache/mod.rs +++ b/crates/vite_task/src/session/cache/mod.rs @@ -222,19 +222,17 @@ impl ExecutionCache { let db_path = path.join("cache.db"); let conn = Connection::open(db_path.as_path())?; - conn.execute_batch("PRAGMA journal_mode=WAL;")?; // 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: just - // make sure the tables exist (a fresh database has none, an existing one - // keeps its rows). - conn.execute( - "CREATE TABLE IF NOT EXISTS cache_entries (key BLOB PRIMARY KEY, value BLOB);", - (), - )?; - conn.execute( - "CREATE TABLE IF NOT EXISTS task_fingerprints (key BLOB PRIMARY KEY, value BLOB);", - (), + // 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) }) From 5a0583b59e392fd689777392818f9544b383e7e6 Mon Sep 17 00:00:00 2001 From: wan9chi Date: Tue, 9 Jun 2026 23:52:52 +0800 Subject: [PATCH 8/9] refactor(cache): rename session field cache_base_path to cache_root The name collided with execute/mod.rs's `cache_base_path`, which means the workspace root that cached relative paths anchor to. This field is the parent of all `vN` version dirs, so `cache_root` is clearer and pairs with the versioned sibling `cache_path` (cache_root.join("v13")). Co-Authored-By: Claude Opus 4.8 (1M context) --- crates/vite_task/src/session/mod.rs | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/crates/vite_task/src/session/mod.rs b/crates/vite_task/src/session/mod.rs index 81ce10801..c21f5aa9e 100644 --- a/crates/vite_task/src/session/mod.rs +++ b/crates/vite_task/src/session/mod.rs @@ -172,10 +172,10 @@ pub struct Session<'a> { /// 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, - /// Base task-cache directory (parent of all `vN` version directories). + /// 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_base_path: AbsolutePathBuf, + cache_root: AbsolutePathBuf, } fn get_cache_path_of_workspace(workspace_root: &AbsolutePath) -> AbsolutePathBuf { @@ -226,10 +226,10 @@ impl<'a> Session<'a> { config: SessionConfig<'a>, ) -> anyhow::Result { let (workspace_root, _) = find_workspace_root(&cwd)?; - let cache_base_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_base_path.join(cache::cache_schema_dir_name().as_str()); + 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"); @@ -248,7 +248,7 @@ impl<'a> Session<'a> { program_name: config.program_name, cache: OnceCell::new(), cache_path, - cache_base_path, + cache_root, }) } @@ -415,8 +415,8 @@ impl<'a> Session<'a> { CacheSubcommand::Clean => { // Remove the whole task-cache directory (every version), not just // the current build's `vN` subdirectory. - if self.cache_base_path.as_path().exists() { - std::fs::remove_dir_all(&self.cache_base_path)?; + if self.cache_root.as_path().exists() { + std::fs::remove_dir_all(&self.cache_root)?; } } } From 1d5bc23bc51523132a482c44a0e423dd42aaa559 Mon Sep 17 00:00:00 2001 From: wan9chi Date: Tue, 9 Jun 2026 23:52:52 +0800 Subject: [PATCH 9/9] refactor(cache): make CACHE_SCHEMA_VERSION private It is only referenced within the module (by cache_schema_dir_name), so the `pub` was unnecessary. Co-Authored-By: Claude Opus 4.8 (1M context) --- crates/vite_task/src/session/cache/mod.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/vite_task/src/session/cache/mod.rs b/crates/vite_task/src/session/cache/mod.rs index 822714ce6..aa986703d 100644 --- a/crates/vite_task/src/session/cache/mod.rs +++ b/crates/vite_task/src/session/cache/mod.rs @@ -200,7 +200,7 @@ pub fn split_path(path: &str) -> (Option<&str>, &str) { /// 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. -pub const CACHE_SCHEMA_VERSION: u32 = 13; +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