diff --git a/packages/cli/lib/cli/commands/cache.js b/packages/cli/lib/cli/commands/cache.js index c580f835e01..47a1258ff35 100644 --- a/packages/cli/lib/cli/commands/cache.js +++ b/packages/cli/lib/cli/commands/cache.js @@ -3,12 +3,15 @@ import path from "node:path"; import process from "node:process"; import {isLogLevelEnabled} from "@ui5/logger"; import baseMiddleware from "../middlewares/base.js"; +import {applyProjectConfigOptions, applyWorkspaceOptions} from "../options.js"; import {getUi5DataDirOrDefault, formatPath} from "../../dataDir.js"; import { CACHE_CLEAN_HELP_USAGE, displayCacheCleanWarning, displayCacheInfo, displayCleanupResult, + displayProjectCacheInfo, + displayProjectCleanupResult, } from "./helpers/cacheOutput.js"; const cacheCommand = { @@ -24,6 +27,8 @@ cacheCommand.builder = function(cli) { .command("clean", "Remove all cached UI5 data", { handler: handleCache, builder: function(yargs) { + applyProjectConfigOptions(yargs); + applyWorkspaceOptions(yargs); return yargs .usage(CACHE_CLEAN_HELP_USAGE) .option("force", { @@ -32,10 +37,22 @@ cacheCommand.builder = function(cli) { default: false, type: "boolean", }) + .option("project", { + alias: "p", + describe: "Remove only the build cache of a single project, leaving other " + + "projects' build cache and the downloaded framework packages intact. " + + "Without a value, targets the project in the current directory. Pass a " + + "project id (e.g. --project sap.ui.core) to target a specific project", + type: "string", + }) .example("$0 cache clean", "Remove all cached UI5 data after confirmation") .example("$0 cache clean --force", "Remove all cached UI5 data without confirmation (e.g. in CI scenarios)") + .example("$0 cache clean --project", + "Remove only the build cache of the project in the current directory") + .example("$0 cache clean --project @openui5/sap.ui.core", + "Remove only the build cache of the project 'sap.ui.core'") .example("UI5_DATA_DIR=/custom/path $0 cache clean", "Remove cached data from a non-default UI5 data directory"); }, @@ -46,16 +63,17 @@ cacheCommand.builder = function(cli) { * Prompt the user for confirmation before proceeding with cache cleanup. * * @param {Yargs.Arguments} argv + * @param {string} [question] Confirmation prompt text * @returns {Promise} Confirmation result */ -async function getConfirmation(argv) { +async function getConfirmation(argv, question = "Proceed with cache cleanup? (y/N)") { if (argv.force) { return true; } displayCacheCleanWarning(); const {default: yesno} = await import("yesno"); return yesno({ - question: "Proceed with cache cleanup? (y/N)", + question, defaultValue: false }); } @@ -74,6 +92,9 @@ function getAbsPath(ui5DataDir, cacheEntry) { } async function handleCache(argv) { + if (argv.project !== undefined) { + return handleProjectCache(argv); + } // Lazy loading to prevent unnecessary imports when the command is not executed const [{default: FrameworkCache}, {default: CacheManager}] = await Promise.all([ import("@ui5/project/internal/ui5Framework/cache"), @@ -168,4 +189,88 @@ async function handleCache(argv) { } } +/** + * Resolves the project graph for the current directory and returns the root project's id, + * which is the key used for its entries in the build cache. + * + * Framework dependencies are not resolved: the root project id is its package name and does + * not depend on the framework version, so resolving the framework would only add network + * access and a failure surface to a command meant to leave framework packages untouched. + * + * @param {Yargs.Arguments} argv + * @returns {Promise} Root project id + */ +async function getRootProjectId(argv) { + const {graphFromStaticFile, graphFromPackageDependencies} = await import("@ui5/project/graph"); + let graph; + if (argv.dependencyDefinition) { + graph = await graphFromStaticFile({ + filePath: argv.dependencyDefinition, + rootConfigPath: argv.config, + resolveFrameworkDependencies: false, + }); + } else { + graph = await graphFromPackageDependencies({ + rootConfigPath: argv.config, + workspaceConfigPath: argv.workspaceConfig, + workspaceName: argv.workspace === false ? null : argv.workspace, + resolveFrameworkDependencies: false, + }); + } + return graph.getRoot().getId(); +} + +/** + * Removes the build cache of a single project, leaving other projects' build cache and the + * framework cache intact. + * + * A project id passed via --project is used directly. Without one, the root project id of the + * current directory is resolved from the project graph the same way 'ui5 build' does. + * + * @param {Yargs.Arguments} argv + */ +async function handleProjectCache(argv) { + const {default: CacheManager} = await import("@ui5/project/internal/build/cache/CacheManager"); + + const projectId = argv.project || await getRootProjectId(argv); + const ui5DataDir = await getUi5DataDirOrDefault({cwd: process.cwd()}); + const isVerbose = isLogLevelEnabled("verbose"); + + if (isVerbose) { + process.stderr.write( + `Checking build cache for project ${chalk.bold(projectId)} at ${chalk.bold(formatPath(ui5DataDir))} …\n` + ); + } + + const info = await CacheManager.getProjectCacheInfo(ui5DataDir, projectId); + if (!info) { + if (isVerbose) { + process.stderr.write(`${chalk.italic("Nothing to clean")}\n`); + } + return; + } + + if (isVerbose) { + displayProjectCacheInfo({projectId, absPath: getAbsPath(ui5DataDir, info)}); + } + + const confirmed = await getConfirmation(argv, `Delete build cache for '${projectId}'? (y/N)`); + if (!confirmed) { + if (isVerbose) { + process.stderr.write(`${chalk.italic("Cancelled")}\n`); + } + return; + } + + const result = await CacheManager.cleanProject(ui5DataDir, projectId); + + if (isVerbose) { + displayProjectCleanupResult({ + projectId, + absPath: getAbsPath(ui5DataDir, result), + deletedEntries: result?.deletedEntries ?? 0, + }); + } +} + export default cacheCommand; diff --git a/packages/cli/lib/cli/commands/helpers/cacheOutput.js b/packages/cli/lib/cli/commands/helpers/cacheOutput.js index 6295a0437ed..a9bb1febfaa 100644 --- a/packages/cli/lib/cli/commands/helpers/cacheOutput.js +++ b/packages/cli/lib/cli/commands/helpers/cacheOutput.js @@ -82,6 +82,45 @@ export function displayCacheCleanWarning() { process.stderr.write(`${chalk.italic(CACHE_CLEAN_WARNING_IMPACT)}\n\n`); } +function formatEntries(count) { + return `${count.toLocaleString("en-US")} ${count === 1 ? "entry" : "entries"}`; +} + +/** + * Display information about the build cache entries of a single project that will be removed. + * + * @param {object} data + * @param {string} data.projectId + * @param {string} data.absPath + */ +export function displayProjectCacheInfo({projectId, absPath}) { + process.stderr.write( + `\n${chalk.bold(`The build cache of project ${chalk.cyan(projectId)} will be removed:`)}\n\n` + ); + writePreviewItem(absPath); + process.stderr.write("\n"); +} + +/** + * Display the result of a project-scoped build cache cleanup. + * + * @param {object} data + * @param {string} data.projectId + * @param {string|null} data.absPath + * @param {number} data.deletedEntries + */ +export function displayProjectCleanupResult({projectId, absPath, deletedEntries}) { + if (!absPath) { + process.stderr.write(`${chalk.italic(PARALLEL_CLEANUP_NOTICE)}\n`); + return; + } + process.stderr.write(`\n${chalk.bold("Cleanup result:")}\n\n`); + writeCleanupItem(absPath, formatEntries(deletedEntries)); + process.stderr.write( + `\n${chalk.green("Success:")} Removed the build cache of project ${chalk.cyan(projectId)}\n` + ); +} + function createFrameworkItems(entries) { const items = []; for (const entry of entries) { diff --git a/packages/cli/test/lib/cli/commands/cache.js b/packages/cli/test/lib/cli/commands/cache.js index c5ac660e9c8..c226624e72c 100644 --- a/packages/cli/test/lib/cli/commands/cache.js +++ b/packages/cli/test/lib/cli/commands/cache.js @@ -57,9 +57,15 @@ test.beforeEach(async (t) => { t.context.buildCacheCleanCache = sinon.stub(); t.context.buildCacheCleanAdditional = sinon.stub().resolves([]); t.context.buildCacheGetAdditionalCacheInfo = sinon.stub().resolves([]); + t.context.buildCacheGetProjectCacheInfo = sinon.stub(); + t.context.buildCacheCleanProject = sinon.stub(); t.context.yesnoStub = sinon.stub(); + t.context.getRootStub = sinon.stub().returns({getId: () => "my.project"}); + t.context.graphFromPackageDependencies = sinon.stub().resolves({getRoot: t.context.getRootStub}); + t.context.graphFromStaticFile = sinon.stub().resolves({getRoot: t.context.getRootStub}); + t.context.cache = await esmock.p("../../../../lib/cli/commands/cache.js", { "@ui5/project/internal/ui5Framework/cache": { default: class { @@ -75,8 +81,14 @@ test.beforeEach(async (t) => { static cleanCache = t.context.buildCacheCleanCache; static cleanAdditional = t.context.buildCacheCleanAdditional; static getAdditionalCacheInfo = t.context.buildCacheGetAdditionalCacheInfo; + static getProjectCacheInfo = t.context.buildCacheGetProjectCacheInfo; + static cleanProject = t.context.buildCacheCleanProject; } }, + "@ui5/project/graph": { + graphFromPackageDependencies: t.context.graphFromPackageDependencies, + graphFromStaticFile: t.context.graphFromStaticFile, + }, "yesno": { default: t.context.yesnoStub, }, @@ -106,6 +118,7 @@ test("Command builder", async (t) => { const yargsStub = { usage: sinon.stub().returnsThis(), option: sinon.stub().returnsThis(), + coerce: sinon.stub().returnsThis(), example: sinon.stub().returnsThis(), }; const cliStub = { @@ -125,8 +138,9 @@ test("Command builder", async (t) => { t.is(yargsStub.usage.callCount, 1, "usage called once for warning help banner"); t.true(yargsStub.usage.firstCall.args[0].startsWith("WARNING:"), "usage banner starts with warning"); - t.is(yargsStub.option.callCount, 1, "option called for --force flag"); - t.is(yargsStub.example.callCount, 3, "example called 3 times"); + // config, dependency-definition, workspace-config, workspace, force, project + t.is(yargsStub.option.callCount, 6, "option called for all clean options"); + t.is(yargsStub.example.callCount, 5, "example called 5 times"); }); test.serial("Command definition is correct", (t) => { @@ -869,3 +883,176 @@ test.serial("ui5 cache clean: pre-clean summary shows both groups when active an t.true(allOutput.includes(ACTIVE_CACHE_HEADER), "Shows Active Cache group header"); t.true(allOutput.includes(STALE_CACHE_HEADER), "Shows Stale Cache group header"); }); + +// ─── Project-scoped clean (ui5 cache clean --project) ──────────────────────── + +test.serial("ui5 cache clean --project: nothing to clean", async (t) => { + const {cache, argv, stderrWriteStub, buildCacheGetProjectCacheInfo, + buildCacheCleanProject, frameworkCacheCleanCache, yesnoStub} = t.context; + + buildCacheGetProjectCacheInfo.resolves(null); + + argv["_"] = ["cache", "clean"]; + argv["project"] = ""; // bare --project resolves the root project id + setLogLevel("verbose"); + await cache.handler(argv); + + t.is(buildCacheGetProjectCacheInfo.callCount, 1, "Looks up project cache info"); + t.is(buildCacheGetProjectCacheInfo.firstCall.args[0], TEST_UI5_DATA_DIR, "Uses resolved ui5DataDir"); + t.is(buildCacheGetProjectCacheInfo.firstCall.args[1], "my.project", "Uses resolved root project id"); + t.is(yesnoStub.callCount, 0, "Does not prompt when nothing to clean"); + t.is(buildCacheCleanProject.callCount, 0, "Does not clean when nothing to clean"); + t.is(frameworkCacheCleanCache.callCount, 0, "Never touches framework cache in project mode"); + + const allOutput = stderrWriteStub.args.map((a) => a[0]).join(""); + t.true(allOutput.includes("Nothing to clean"), "Prints nothing to clean"); +}); + +test.serial("ui5 cache clean --project: removes project build cache with --force", async (t) => { + const {cache, argv, stderrWriteStub, buildCacheGetProjectCacheInfo, + buildCacheCleanProject, frameworkCacheCleanCache, buildCacheCleanCache, yesnoStub} = t.context; + + buildCacheGetProjectCacheInfo.resolves({path: BUILD_CACHE_PATH, projectId: "my.project"}); + buildCacheCleanProject.resolves({path: BUILD_CACHE_PATH, projectId: "my.project", deletedEntries: 42}); + + argv["_"] = ["cache", "clean"]; + argv["project"] = ""; // bare --project resolves the root project id + argv["force"] = true; + setLogLevel("verbose"); + await cache.handler(argv); + + t.is(yesnoStub.callCount, 0, "Does not prompt with --force"); + t.is(buildCacheCleanProject.callCount, 1, "Cleans project build cache"); + t.is(buildCacheCleanProject.firstCall.args[1], "my.project", "Cleans the resolved root project id"); + t.is(frameworkCacheCleanCache.callCount, 0, "Does not touch framework cache"); + t.is(buildCacheCleanCache.callCount, 0, "Does not run full build cache clean"); + + const allOutput = stderrWriteStub.args.map((a) => a[0]).join(""); + t.true(allOutput.includes("my.project"), "Names the project"); + t.true(allOutput.includes(path.join(TEST_UI5_DATA_DIR, BUILD_CACHE_PATH)), "Shows absolute build cache path"); + t.true(allOutput.includes("42 entries"), "Reports number of removed entries"); + t.true(allOutput.includes("Success:"), "Shows success summary"); +}); + +test.serial("ui5 cache clean --project: user cancels", async (t) => { + const {cache, argv, buildCacheGetProjectCacheInfo, buildCacheCleanProject, yesnoStub} = t.context; + + buildCacheGetProjectCacheInfo.resolves({path: BUILD_CACHE_PATH, projectId: "my.project"}); + yesnoStub.resolves(false); + + argv["_"] = ["cache", "clean"]; + argv["project"] = ""; // bare --project resolves the root project id + await cache.handler(argv); + + t.is(yesnoStub.callCount, 1, "Prompts for confirmation"); + t.is(buildCacheCleanProject.callCount, 0, "Does not clean when user cancels"); +}); + +test.serial("ui5 cache clean --project: reports parallel cleanup when result is empty", async (t) => { + const {cache, argv, stderrWriteStub, buildCacheGetProjectCacheInfo, + buildCacheCleanProject, yesnoStub} = t.context; + + buildCacheGetProjectCacheInfo.resolves({path: BUILD_CACHE_PATH, projectId: "my.project"}); + buildCacheCleanProject.resolves(null); + yesnoStub.resolves(true); + + argv["_"] = ["cache", "clean"]; + argv["project"] = ""; // bare --project resolves the root project id + setLogLevel("verbose"); + await cache.handler(argv); + + t.is(buildCacheCleanProject.callCount, 1, "Attempts cleanup"); + const allOutput = stderrWriteStub.args.map((a) => a[0]).join(""); + t.true(allOutput.includes(PARALLEL_CLEANUP_NOTICE), "Reports parallel cleanup for empty result"); + t.false(allOutput.includes("Success:"), "Does not claim success for empty result"); +}); + +test.serial("ui5 cache clean --project: non-verbose --force stays quiet", async (t) => { + const {cache, argv, stderrWriteStub, buildCacheGetProjectCacheInfo, buildCacheCleanProject} = t.context; + + buildCacheGetProjectCacheInfo.resolves({path: BUILD_CACHE_PATH, projectId: "my.project"}); + buildCacheCleanProject.resolves({path: BUILD_CACHE_PATH, projectId: "my.project", deletedEntries: 3}); + + argv["_"] = ["cache", "clean"]; + argv["project"] = ""; // bare --project resolves the root project id + argv["force"] = true; + await cache.handler(argv); + + t.is(buildCacheCleanProject.callCount, 1, "Cleans project build cache"); + t.is(stderrWriteStub.callCount, 0, "Writes nothing in non-verbose --force mode"); +}); + +test.serial("ui5 cache clean --project: resolves graph via package dependencies by default", async (t) => { + const {cache, argv, graphFromPackageDependencies, graphFromStaticFile, + buildCacheGetProjectCacheInfo} = t.context; + + buildCacheGetProjectCacheInfo.resolves(null); + + argv["_"] = ["cache", "clean"]; + argv["project"] = ""; // bare --project resolves the root project id + await cache.handler(argv); + + t.is(graphFromPackageDependencies.callCount, 1, "Resolves graph from package dependencies"); + t.is(graphFromStaticFile.callCount, 0, "Does not use static file resolution"); +}); + +test.serial("ui5 cache clean --project: uses static file when dependency-definition is given", async (t) => { + const {cache, argv, graphFromPackageDependencies, graphFromStaticFile, + buildCacheGetProjectCacheInfo} = t.context; + + buildCacheGetProjectCacheInfo.resolves(null); + + argv["_"] = ["cache", "clean"]; + argv["project"] = ""; // bare --project resolves the root project id + argv["dependencyDefinition"] = "/path/to/deps.yaml"; + await cache.handler(argv); + + t.is(graphFromStaticFile.callCount, 1, "Resolves graph from static file"); + t.is(graphFromStaticFile.firstCall.args[0].filePath, "/path/to/deps.yaml", "Passes dependency definition path"); + t.is(graphFromPackageDependencies.callCount, 0, "Does not use package dependency resolution"); +}); + +test.serial("ui5 cache clean --project : uses the given id without resolving the graph", async (t) => { + const {cache, argv, stderrWriteStub, graphFromPackageDependencies, graphFromStaticFile, + buildCacheGetProjectCacheInfo, buildCacheCleanProject, frameworkCacheCleanCache} = t.context; + + buildCacheGetProjectCacheInfo.resolves({path: BUILD_CACHE_PATH, projectId: "sap.ui.core"}); + buildCacheCleanProject.resolves({path: BUILD_CACHE_PATH, projectId: "sap.ui.core", deletedEntries: 7}); + + argv["_"] = ["cache", "clean"]; + argv["project"] = "sap.ui.core"; + argv["force"] = true; + setLogLevel("verbose"); + await cache.handler(argv); + + t.is(graphFromPackageDependencies.callCount, 0, "Does not resolve the project graph for an explicit id"); + t.is(graphFromStaticFile.callCount, 0, "Does not resolve the project graph for an explicit id"); + t.is(buildCacheGetProjectCacheInfo.firstCall.args[1], "sap.ui.core", "Looks up the given project id"); + t.is(buildCacheCleanProject.firstCall.args[1], "sap.ui.core", "Cleans the given project id"); + t.is(frameworkCacheCleanCache.callCount, 0, "Never touches framework cache in project mode"); + + const allOutput = stderrWriteStub.args.map((a) => a[0]).join(""); + t.true(allOutput.includes("sap.ui.core"), "Names the given project"); + t.true(allOutput.includes("7 entries"), "Reports number of removed entries"); + t.true(allOutput.includes("Success:"), "Shows success summary"); +}); + +test.serial("ui5 cache clean --project : nothing to clean for an unknown id", async (t) => { + const {cache, argv, stderrWriteStub, graphFromPackageDependencies, + buildCacheGetProjectCacheInfo, buildCacheCleanProject, yesnoStub} = t.context; + + buildCacheGetProjectCacheInfo.resolves(null); + + argv["_"] = ["cache", "clean"]; + argv["project"] = "does.not.exist"; + setLogLevel("verbose"); + await cache.handler(argv); + + t.is(graphFromPackageDependencies.callCount, 0, "Does not resolve the project graph for an explicit id"); + t.is(buildCacheGetProjectCacheInfo.firstCall.args[1], "does.not.exist", "Looks up the given project id"); + t.is(yesnoStub.callCount, 0, "Does not prompt when nothing to clean"); + t.is(buildCacheCleanProject.callCount, 0, "Does not clean when nothing to clean"); + + const allOutput = stderrWriteStub.args.map((a) => a[0]).join(""); + t.true(allOutput.includes("Nothing to clean"), "Prints nothing to clean"); +}); diff --git a/packages/project/lib/build/cache/BuildCacheStorage.js b/packages/project/lib/build/cache/BuildCacheStorage.js index 13fb50c491a..464070f2118 100644 --- a/packages/project/lib/build/cache/BuildCacheStorage.js +++ b/packages/project/lib/build/cache/BuildCacheStorage.js @@ -12,6 +12,12 @@ const CONTENT_COMPRESSION_THRESHOLD = 128; /** All live data table names */ const DATA_TABLES = ["content", "index_cache", "stage_metadata", "task_metadata", "result_metadata"]; +/** + * Data tables keyed by project_id. The content table is content-addressed and shared + * across projects, so it is not part of a project-scoped delete. + */ +const PROJECT_TABLES = ["index_cache", "stage_metadata", "task_metadata", "result_metadata"]; + /** * Unified SQLite-backed storage for the build cache * @@ -145,6 +151,11 @@ export default class BuildCacheStorage { `INSERT OR REPLACE INTO result_metadata (project_id, build_signature, stage_signature, data) VALUES (?, ?, ?, ?)` ), + + // Project-scoped deletion (one DELETE per project-keyed table) + deleteProjectRecords: Object.fromEntries(PROJECT_TABLES.map((table) => [ + table, this.#db.prepare(`DELETE FROM ${table} WHERE project_id = ?`) + ])), }; } @@ -535,6 +546,53 @@ export default class BuildCacheStorage { return false; } + /** + * Checks whether any project-keyed table holds records for the given project. + * + * The content table is content-addressed and shared across projects, so it is not + * considered here. + * + * @param {string} projectId Project identifier + * @returns {boolean} True if any project-keyed table has a row for the project + */ + hasProjectRecords(projectId) { + for (const table of PROJECT_TABLES) { + const {is_populated: isPopulated} = this.#db.prepare( + `SELECT EXISTS(SELECT 1 FROM ${table} WHERE project_id = ? LIMIT 1) as is_populated` + ).get(projectId); + if (isPopulated) { + return true; + } + } + return false; + } + + /** + * Deletes all cache entries for a single project across the project-keyed tables + * (index_cache, stage_metadata, task_metadata, result_metadata) in one transaction. + * + * The content table is left untouched: it is content-addressed and shared across + * projects. Orphaned blobs are reused on the next build and reclaimed by a full + * {@link dropAllRecords}. No VACUUM is run; freed pages return to the freelist. + * + * @param {string} projectId Project identifier + * @returns {number} Number of deleted rows across all project-keyed tables + */ + dropProjectRecords(projectId) { + this.#db.exec("BEGIN"); + try { + let deletedEntries = 0; + for (const table of PROJECT_TABLES) { + deletedEntries += this.#stmts.deleteProjectRecords[table].run(projectId).changes; + } + this.#db.exec("COMMIT"); + return deletedEntries; + } catch (err) { + this.#db.exec("ROLLBACK"); + throw err; + } + } + /** * Atomically drops all live tables and recreates fresh empty ones in a single * transaction. The operation completes in milliseconds regardless of data volume — diff --git a/packages/project/lib/build/cache/CacheManager.js b/packages/project/lib/build/cache/CacheManager.js index d1baec61eb0..c39173f6304 100644 --- a/packages/project/lib/build/cache/CacheManager.js +++ b/packages/project/lib/build/cache/CacheManager.js @@ -414,6 +414,48 @@ export default class CacheManager { }); } + /** + * Get build cache info for a single project in the current cache version. + * + * @public + * @static + * @param {string} ui5DataDir Resolved absolute path to UI5 data directory + * @param {string} projectId Project identifier (root package name) + * @returns {Promise<{path: string, projectId: string}|null>} Build cache info or null + */ + static getProjectCacheInfo(ui5DataDir, projectId) { + return CacheManager.#withStorage(ui5DataDir, null, (storage) => { + if (!storage.hasProjectRecords(projectId)) { + return null; + } + return {path: `buildCache/${CACHE_VERSION}`, projectId}; + }); + } + + /** + * Deletes all build cache entries for a single project across the project-keyed + * tables. The shared content-addressed store is left untouched; run + * {@link cleanCache} to reclaim orphaned content. + * + * @public + * @static + * @param {string} ui5DataDir Resolved absolute path to UI5 data directory + * @param {string} projectId Project identifier (root package name) + * @returns {Promise<{path: string, projectId: string, deletedEntries: number}|null>} Removal result or null + */ + static cleanProject(ui5DataDir, projectId) { + return CacheManager.#withStorage(ui5DataDir, null, (storage) => { + if (!storage.hasProjectRecords(projectId)) { + return null; + } + return { + path: `buildCache/${CACHE_VERSION}`, + projectId, + deletedEntries: storage.dropProjectRecords(projectId), + }; + }); + } + /** * Runs VACUUM to reclaim disk space from a previous {@link cleanCache} call. * Only runs if the database has freelist pages (i.e. cleanup was deferred). diff --git a/packages/project/test/lib/build/cache/BuildCacheStorage.js b/packages/project/test/lib/build/cache/BuildCacheStorage.js index 7a3447ff52f..505ec243130 100644 --- a/packages/project/test/lib/build/cache/BuildCacheStorage.js +++ b/packages/project/test/lib/build/cache/BuildCacheStorage.js @@ -449,6 +449,61 @@ test("hasRecords: Returns true when result metadata table has records", (t) => { t.true(t.context.storage.hasRecords()); }); +// ===== hasProjectRecords / dropProjectRecords ===== + +function seedProjectRecords(storage, projectId) { + storage.writeIndexCache(projectId, "build-sig", "source", {v: 1}); + storage.writeStageCache(projectId, "build-sig", "task/minify", "stage-sig", {v: 2}); + storage.writeTaskMetadata(projectId, "build-sig", "minify", "project", {v: 3}); + storage.writeResultMetadata(projectId, "build-sig", "result-sig", {v: 4}); +} + +test("hasProjectRecords: Returns false for a project without records", (t) => { + t.context.storage.writeIndexCache("project-a", "build-sig", "source", {v: 1}); + t.false(t.context.storage.hasProjectRecords("project-b")); +}); + +test("hasProjectRecords: Returns true when any project-keyed table has a row", (t) => { + t.context.storage.writeResultMetadata("project-a", "build-sig", "sig-a", {v: 1}); + t.true(t.context.storage.hasProjectRecords("project-a")); +}); + +test("hasProjectRecords: Ignores content table (not project-keyed)", (t) => { + t.context.storage.putContent("sha256-content", Buffer.from("data")); + t.false(t.context.storage.hasProjectRecords("project-a"), + "Shared content does not count as project records"); +}); + +test("dropProjectRecords: Removes all project-keyed rows and returns the count", (t) => { + seedProjectRecords(t.context.storage, "project-a"); + + const deleted = t.context.storage.dropProjectRecords("project-a"); + + t.is(deleted, 4, "Reports one deleted row per project-keyed table"); + t.false(t.context.storage.hasProjectRecords("project-a"), "Project has no records left"); +}); + +test("dropProjectRecords: Leaves other projects and shared content intact", (t) => { + seedProjectRecords(t.context.storage, "project-a"); + seedProjectRecords(t.context.storage, "project-b"); + t.context.storage.putContent("sha256-shared", Buffer.from("data")); + + t.context.storage.dropProjectRecords("project-a"); + + t.false(t.context.storage.hasProjectRecords("project-a"), "Target project cleared"); + t.true(t.context.storage.hasProjectRecords("project-b"), "Other project untouched"); + t.true(t.context.storage.hasContent("sha256-shared"), "Shared content untouched"); +}); + +test("dropProjectRecords: No-op returns 0 for an unknown project", (t) => { + seedProjectRecords(t.context.storage, "project-a"); + + const deleted = t.context.storage.dropProjectRecords("project-unknown"); + + t.is(deleted, 0, "Nothing deleted for an unknown project"); + t.true(t.context.storage.hasProjectRecords("project-a"), "Existing project untouched"); +}); + test("getDatabaseSize: Returns positive database size", (t) => { const size = t.context.storage.getDatabaseSize(); t.true(Number.isInteger(size)); diff --git a/packages/project/test/lib/build/cache/CacheManager.js b/packages/project/test/lib/build/cache/CacheManager.js index dd89c030446..0ea19e097ab 100644 --- a/packages/project/test/lib/build/cache/CacheManager.js +++ b/packages/project/test/lib/build/cache/CacheManager.js @@ -276,6 +276,77 @@ test.serial("cleanCache: returns null when db does not exist", async (t) => { t.is(result, null); }); +test.serial("getProjectCacheInfo: Returns null when db does not exist", async (t) => { + const testDir = getUniqueTestDir(); + const CacheManager = (await import("../../../../lib/build/cache/CacheManager.js")).default; + + const info = await CacheManager.getProjectCacheInfo(testDir, "project-x"); + t.is(info, null); +}); + +test.serial("getProjectCacheInfo: Returns null when project has no records", async (t) => { + const testDir = getUniqueTestDir(); + const CacheManager = (await import("../../../../lib/build/cache/CacheManager.js")).default; + const cm = new CacheManager(path.join(testDir, "buildCache")); + cm.writeIndexCache("project-a", "build-sig", "source", {value: 1}); + cm.close(); + + const info = await CacheManager.getProjectCacheInfo(testDir, "project-b"); + t.is(info, null); +}); + +test.serial("getProjectCacheInfo: Returns info when project has records", async (t) => { + const testDir = getUniqueTestDir(); + const CacheManager = (await import("../../../../lib/build/cache/CacheManager.js")).default; + const cm = new CacheManager(path.join(testDir, "buildCache")); + cm.writeIndexCache("project-x", "build-sig", "source", {value: 1}); + cm.close(); + + const info = await CacheManager.getProjectCacheInfo(testDir, "project-x"); + t.truthy(info); + t.is(info.projectId, "project-x"); + t.regex(info.path, /^buildCache\//); +}); + +test.serial("cleanProject: Removes only the project's records and returns the result", async (t) => { + const testDir = getUniqueTestDir(); + const CacheManager = (await import("../../../../lib/build/cache/CacheManager.js")).default; + const cm = new CacheManager(path.join(testDir, "buildCache")); + cm.writeIndexCache("project-x", "build-sig", "source", {value: 1}); + cm.writeResultMetadata("project-x", "build-sig", "result-sig", {value: 2}); + cm.writeIndexCache("project-y", "build-sig", "source", {value: 3}); + cm.close(); + + const result = await CacheManager.cleanProject(testDir, "project-x"); + t.truthy(result); + t.is(result.projectId, "project-x"); + t.is(result.deletedEntries, 2); + + t.is(await CacheManager.getProjectCacheInfo(testDir, "project-x"), null, + "Target project has no records left"); + t.truthy(await CacheManager.getProjectCacheInfo(testDir, "project-y"), + "Other project is untouched"); +}); + +test.serial("cleanProject: Returns null when project has no records", async (t) => { + const testDir = getUniqueTestDir(); + const CacheManager = (await import("../../../../lib/build/cache/CacheManager.js")).default; + const cm = new CacheManager(path.join(testDir, "buildCache")); + cm.writeIndexCache("project-a", "build-sig", "source", {value: 1}); + cm.close(); + + const result = await CacheManager.cleanProject(testDir, "project-b"); + t.is(result, null); +}); + +test.serial("cleanProject: Returns null when db does not exist", async (t) => { + const testDir = getUniqueTestDir(); + const CacheManager = (await import("../../../../lib/build/cache/CacheManager.js")).default; + + const result = await CacheManager.cleanProject(testDir, "project-x"); + t.is(result, null); +}); + test.serial("getAdditionalCacheInfo: returns empty array when no stale tables", async (t) => { const testDir = getUniqueTestDir(); const CacheManager = (await import("../../../../lib/build/cache/CacheManager.js")).default;