diff --git a/.github/workflows/validate.yaml b/.github/workflows/validate.yaml index 33f3598..0cd2285 100644 --- a/.github/workflows/validate.yaml +++ b/.github/workflows/validate.yaml @@ -1,39 +1,62 @@ -name: Validate - -on: - push: - branches: [main] - pull_request: - branches: [main] - -jobs: - shellcheck: - name: ShellCheck - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - name: Run ShellCheck - uses: ludeeus/action-shellcheck@2.0.0 - with: - scandir: .devcontainer/scripts - severity: warning - - compose: - name: Compose Config - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - name: Validate compose config - run: | - cp .devcontainer/.env.example .devcontainer/.env - docker compose -f .devcontainer/compose.yaml config --quiet - - build: - name: Devcontainer Build - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - name: Build devcontainer - uses: devcontainers/ci@v0.3 - with: - runCmd: echo "devcontainer smoke test passed" +name: Validate + +on: + push: + branches: [main] + pull_request: + branches: [main] + +jobs: + detect-changes: + name: Detect Changes + runs-on: ubuntu-latest + permissions: + pull-requests: read + outputs: + devcontainer: ${{ steps.filter.outputs.devcontainer }} + steps: + - uses: actions/checkout@v4 + - uses: dorny/paths-filter@v3 + id: filter + with: + filters: | + devcontainer: + - '.devcontainer/**' + - '.github/workflows/validate.yaml' + + shellcheck: + name: ShellCheck + needs: detect-changes + if: needs.detect-changes.outputs.devcontainer == 'true' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Run ShellCheck + uses: ludeeus/action-shellcheck@2.0.0 + with: + scandir: .devcontainer/scripts + severity: warning + + compose: + name: Compose Config + needs: detect-changes + if: needs.detect-changes.outputs.devcontainer == 'true' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Validate compose config + run: | + cp .devcontainer/.env.example .devcontainer/.env + docker compose -f .devcontainer/compose.yaml config --quiet + + build: + name: Devcontainer Build + needs: detect-changes + if: needs.detect-changes.outputs.devcontainer == 'true' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Build devcontainer + uses: devcontainers/ci@v0.3 + with: + runCmd: echo "devcontainer smoke test passed" diff --git a/packages/musher/package.json b/packages/musher/package.json index 629cbde..1fe6b10 100644 --- a/packages/musher/package.json +++ b/packages/musher/package.json @@ -19,9 +19,7 @@ "main": "./dist/index.cjs", "module": "./dist/index.js", "types": "./dist/index.d.ts", - "files": [ - "dist" - ], + "files": ["dist"], "scripts": { "build": "tsup", "check": "pnpm check:format && pnpm check:lint && pnpm check:types && pnpm check:test", @@ -55,11 +53,5 @@ "url": "git+https://github.com/musher-dev/typescript-sdk.git", "directory": "packages/musher" }, - "keywords": [ - "musher", - "bundle", - "sdk", - "ai", - "agent" - ] + "keywords": ["musher", "bundle", "sdk", "ai", "agent"] } diff --git a/packages/musher/src/cache.ts b/packages/musher/src/cache.ts index ea9450e..c568cd5 100644 --- a/packages/musher/src/cache.ts +++ b/packages/musher/src/cache.ts @@ -17,7 +17,7 @@ import { mkdir, readFile, readdir, rename, rm, stat, unlink, writeFile } from "n import { dirname, join } from "node:path"; import { Bundle } from "./bundle.js"; import { CacheError, IntegrityError } from "./errors.js"; -import type { BundleResolveOutput, CachedBundle } from "./types.js"; +import type { BundleResolveOutput, CacheEntry, CacheStats, CachedBundle } from "./types.js"; const JSON_EXT_RE = /\.json$/; @@ -119,17 +119,29 @@ export class BundleCache { // -- Freshness ---------------------------------------------------------------- - /** Check if a cached manifest is still fresh. */ - async isFresh(namespace: string, slug: string, version: string): Promise { + /** Read cache metadata for an entry. Returns null if missing or corrupt. */ + private async readMeta( + namespace: string, + slug: string, + version: string, + ): Promise { try { const raw = await readFile(this.metaPath(namespace, slug, version), "utf-8"); - const meta: CacheMeta = JSON.parse(raw); - const fetchedAt = new Date(meta.fetchedAt).getTime(); - const ttl = (meta.ttlSeconds ?? this.manifestTtlSeconds) * 1000; - return Date.now() - fetchedAt < ttl; + return JSON.parse(raw) as CacheMeta; } catch { + return null; + } + } + + /** Check if a cached manifest is still fresh. */ + async isFresh(namespace: string, slug: string, version: string): Promise { + const meta = await this.readMeta(namespace, slug, version); + if (!meta) { return false; } + const fetchedAt = new Date(meta.fetchedAt).getTime(); + const ttl = (meta.ttlSeconds ?? this.manifestTtlSeconds) * 1000; + return Date.now() - fetchedAt < ttl; } /** Load only the manifest JSON (no blob content). Returns null if not cached. */ @@ -250,6 +262,257 @@ export class BundleCache { } } + // -- Inspection & management -------------------------------------------------- + + /** Build a CacheEntry from a manifest file and its metadata. */ + private async buildCacheEntry( + namespace: string, + slug: string, + version: string, + ): Promise { + const meta = await this.readMeta(namespace, slug, version); + if (!meta) { + return null; + } + + const manifest = await this.loadManifest(namespace, slug, version); + const sizeBytes = manifest?.manifest?.layers?.reduce((sum, l) => sum + l.sizeBytes, 0) ?? 0; + + const fetchedAt = new Date(meta.fetchedAt).getTime(); + const ttl = (meta.ttlSeconds ?? this.manifestTtlSeconds) * 1000; + + return { + namespace, + slug, + version, + fetchedAt: meta.fetchedAt, + ttlSeconds: meta.ttlSeconds, + fresh: Date.now() - fetchedAt < ttl, + ociDigest: meta.ociDigest, + sizeBytes, + }; + } + + /** Collect cache entries from a single slug directory. */ + private async collectSlugEntries( + namespace: string, + slug: string, + slugDir: string, + entries: CacheEntry[], + ): Promise { + for (const file of await safeReaddir(slugDir)) { + if (!isManifestFile(file)) { + continue; + } + const version = file.replace(JSON_EXT_RE, ""); + const entry = await this.buildCacheEntry(namespace, slug, version); + if (entry) { + entries.push(entry); + } + } + } + + /** List all cached bundle entries for this registry host. */ + async list(): Promise { + try { + const entries: CacheEntry[] = []; + const hostManifests = join(this.cacheDir, "manifests", this.hostId); + + if (!existsSync(hostManifests)) { + return entries; + } + + for (const ns of await listSubdirs(hostManifests)) { + for (const slug of await listSubdirs(ns.path)) { + await this.collectSlugEntries(ns.name, slug.name, slug.path, entries); + } + } + + return entries; + } catch (error) { + throw new CacheError( + `Failed to list cache: ${error instanceof Error ? error.message : String(error)}`, + { cause: error instanceof Error ? error : undefined }, + ); + } + } + + /** Check if a bundle is cached and whether it is fresh. */ + async has( + namespace: string, + slug: string, + version?: string, + ): Promise<{ cached: boolean; fresh: boolean }> { + try { + if (version) { + const mPath = this.manifestPath(namespace, slug, version); + if (!existsSync(mPath)) { + return { cached: false, fresh: false }; + } + const fresh = await this.isFresh(namespace, slug, version); + return { cached: true, fresh }; + } + + const versions = await this.listVersionFiles(namespace, slug); + + if (versions.length === 0) { + return { cached: false, fresh: false }; + } + + for (const v of versions) { + if (await this.isFresh(namespace, slug, v)) { + return { cached: true, fresh: true }; + } + } + return { cached: true, fresh: false }; + } catch { + return { cached: false, fresh: false }; + } + } + + /** Remove cached data for a specific bundle. Returns count of manifests removed. */ + async remove(namespace: string, slug: string, version?: string): Promise { + try { + if (version) { + return await this.removeVersion(namespace, slug, version); + } + return await this.removeAllVersions(namespace, slug); + } catch (error) { + throw new CacheError( + `Failed to remove cache entry: ${error instanceof Error ? error.message : String(error)}`, + { cause: error instanceof Error ? error : undefined }, + ); + } + } + + private async removeVersion(namespace: string, slug: string, version: string): Promise { + const mPath = this.manifestPath(namespace, slug, version); + if (!existsSync(mPath)) { + return 0; + } + await safeRm(mPath); + await safeRm(this.metaPath(namespace, slug, version)); + return 1; + } + + private async removeAllVersions(namespace: string, slug: string): Promise { + let removed = 0; + const dir = this.manifestDir(namespace, slug); + for (const f of await safeReaddir(dir)) { + if (isManifestFile(f)) { + const v = f.replace(JSON_EXT_RE, ""); + await safeRm(join(dir, f)); + await safeRm(join(dir, `${v}.meta.json`)); + removed++; + } + } + // Also remove corresponding refs + const refDir = join(this.cacheDir, "refs", this.hostId, namespace, slug); + if (existsSync(refDir)) { + await rm(refDir, { recursive: true, force: true }); + } + return removed; + } + + /** Get aggregate cache statistics (across all hosts). */ + async stats(): Promise { + try { + let entryCount = 0; + let freshCount = 0; + let staleCount = 0; + let refCount = 0; + + await walkCacheTree(join(this.cacheDir, "manifests"), async (ns, slug, _slugDir, file) => { + if (!isManifestFile(file)) { + return; + } + entryCount++; + const version = file.replace(JSON_EXT_RE, ""); + if (await this.isFresh(ns, slug, version)) { + freshCount++; + } else { + staleCount++; + } + }); + + const { blobCount, blobSizeBytes } = await computeBlobStats(this.cacheDir); + + await walkCacheTree(join(this.cacheDir, "refs"), async (_ns, _slug, _slugDir, file) => { + if (file.endsWith(".json")) { + refCount++; + } + }); + + return { entryCount, freshCount, staleCount, blobSizeBytes, blobCount, refCount }; + } catch (error) { + throw new CacheError( + `Failed to compute cache stats: ${error instanceof Error ? error.message : String(error)}`, + { cause: error instanceof Error ? error : undefined }, + ); + } + } + + /** Mark entries as stale so the next access re-fetches. Returns count invalidated. */ + async invalidate(namespace: string, slug: string, version?: string): Promise { + try { + const versions = version ? [version] : await this.listVersionFiles(namespace, slug); + + let count = 0; + for (const v of versions) { + if (await this.invalidateVersion(namespace, slug, v)) { + count++; + } + } + + await this.invalidateRefs(namespace, slug); + return count; + } catch (error) { + throw new CacheError( + `Failed to invalidate cache: ${error instanceof Error ? error.message : String(error)}`, + { cause: error instanceof Error ? error : undefined }, + ); + } + } + + private async invalidateVersion( + namespace: string, + slug: string, + version: string, + ): Promise { + const meta = await this.readMeta(namespace, slug, version); + if (!meta) { + return false; + } + const updated: CacheMeta = { ...meta, fetchedAt: "1970-01-01T00:00:00.000Z" }; + const metPath = this.metaPath(namespace, slug, version); + await this.atomicWrite(metPath, Buffer.from(JSON.stringify(updated, null, 2))); + return true; + } + + private async invalidateRefs(namespace: string, slug: string): Promise { + const refDir = join(this.cacheDir, "refs", this.hostId, namespace, slug); + for (const f of await safeReaddir(refDir)) { + if (!f.endsWith(".json")) { + continue; + } + try { + const raw = await readFile(join(refDir, f), "utf-8"); + const entry: RefEntry = JSON.parse(raw); + entry.fetchedAt = "1970-01-01T00:00:00.000Z"; + await this.atomicWrite(join(refDir, f), Buffer.from(JSON.stringify(entry, null, 2))); + } catch { + /* skip corrupt */ + } + } + } + + /** List version strings from manifest files in a slug directory. */ + private async listVersionFiles(namespace: string, slug: string): Promise { + const dir = this.manifestDir(namespace, slug); + const files = await safeReaddir(dir); + return files.filter((f) => isManifestFile(f)).map((f) => f.replace(JSON_EXT_RE, "")); + } + // -- Cleanup ------------------------------------------------------------------ /** Remove expired cache entries and garbage-collect unreferenced blobs. */ @@ -417,6 +680,38 @@ export class BundleCache { // -- Helpers ------------------------------------------------------------------ +/** Check if a filename is a manifest JSON (not a .meta.json). */ +function isManifestFile(file: string): boolean { + return file.endsWith(".json") && !file.endsWith(".meta.json"); +} + +/** Compute total blob count and size on disk. */ +async function computeBlobStats( + cacheDir: string, +): Promise<{ blobCount: number; blobSizeBytes: number }> { + let blobCount = 0; + let blobSizeBytes = 0; + + const blobsRoot = join(cacheDir, "blobs", "sha256"); + if (!existsSync(blobsRoot)) { + return { blobCount, blobSizeBytes }; + } + + for (const prefix of await safeReaddir(blobsRoot)) { + const prefixDir = join(blobsRoot, prefix); + if (!(await isDir(prefixDir))) { + continue; + } + for (const digest of await safeReaddir(prefixDir)) { + blobCount++; + const s = await stat(join(prefixDir, digest)); + blobSizeBytes += s.size; + } + } + + return { blobCount, blobSizeBytes }; +} + function computeHostId(registryUrl: string): string { try { const url = new URL(registryUrl); diff --git a/packages/musher/src/client.ts b/packages/musher/src/client.ts index 05486e8..7070890 100644 --- a/packages/musher/src/client.ts +++ b/packages/musher/src/client.ts @@ -13,7 +13,13 @@ import { ApiError, ForbiddenError, IntegrityError, NotFoundError } from "./error import { HttpTransport } from "./http.js"; import { BundleRef } from "./ref.js"; import { BundlesResource } from "./resources/bundles.js"; -import type { BundleResolveOutput, PullBundleVersionOutput } from "./types.js"; +import type { + BundleResolveOutput, + CacheEntry, + CacheManager, + CacheStats, + PullBundleVersionOutput, +} from "./types.js"; let _loadDeprecationWarned = false; @@ -241,7 +247,23 @@ export class MusherClient { } /** Cache management utilities. */ - readonly cache = { + readonly cache: CacheManager = { + /** List all cached bundle entries for this registry. */ + list: (): Promise => this._cache.list(), + /** Check if a bundle is cached (and fresh). */ + has: ( + namespace: string, + slug: string, + version?: string, + ): Promise<{ cached: boolean; fresh: boolean }> => this._cache.has(namespace, slug, version), + /** Remove a specific bundle from the cache. Returns count of entries removed. */ + remove: (namespace: string, slug: string, version?: string): Promise => + this._cache.remove(namespace, slug, version), + /** Get aggregate cache statistics. */ + stats: (): Promise => this._cache.stats(), + /** Mark entries as stale so the next access re-fetches. Returns count invalidated. */ + invalidate: (namespace: string, slug: string, version?: string): Promise => + this._cache.invalidate(namespace, slug, version), /** Remove expired cache entries and garbage-collect unreferenced blobs. */ clean: (): Promise => this._cache.clean(), /** Remove all cached data. */ diff --git a/packages/musher/src/index.ts b/packages/musher/src/index.ts index 0875cb1..5442d38 100644 --- a/packages/musher/src/index.ts +++ b/packages/musher/src/index.ts @@ -88,6 +88,9 @@ export type { BundleResolveOutput, BundleVersionDetailOutput, BundleVersionSummaryOutput, + CacheEntry, + CacheManager, + CacheStats, CachedBundle, LoadedAsset, LoadedBundle, diff --git a/packages/musher/src/types.ts b/packages/musher/src/types.ts index 692bc90..da1eb84 100644 --- a/packages/musher/src/types.ts +++ b/packages/musher/src/types.ts @@ -107,3 +107,44 @@ export interface SelectionFilter { agentSpecs?: string[]; paths?: string[]; } + +// -- Cache management --------------------------------------------------------- + +/** A single cached bundle entry with metadata. */ +export interface CacheEntry { + namespace: string; + slug: string; + version: string; + fetchedAt: string; + ttlSeconds: number; + fresh: boolean; + ociDigest?: string | undefined; + /** Total size of blobs referenced by this manifest, in bytes. */ + sizeBytes: number; +} + +/** Aggregate cache statistics. */ +export interface CacheStats { + entryCount: number; + freshCount: number; + staleCount: number; + /** Total size of all blob files on disk, in bytes. */ + blobSizeBytes: number; + blobCount: number; + refCount: number; +} + +/** Shape of the client.cache management interface. */ +export interface CacheManager { + list(): Promise; + has( + namespace: string, + slug: string, + version?: string, + ): Promise<{ cached: boolean; fresh: boolean }>; + remove(namespace: string, slug: string, version?: string): Promise; + stats(): Promise; + invalidate(namespace: string, slug: string, version?: string): Promise; + clean(): Promise; + purge(): Promise; +} diff --git a/packages/musher/tests/cache.test.ts b/packages/musher/tests/cache.test.ts index d0d4208..b957116 100644 --- a/packages/musher/tests/cache.test.ts +++ b/packages/musher/tests/cache.test.ts @@ -306,6 +306,223 @@ describe("BundleCache", () => { }); }); + describe("list", () => { + it("returns empty array for empty cache", async () => { + const entries = await cache.list(); + expect(entries).toEqual([]); + }); + + it("lists cached entries with metadata", async () => { + const assets = new Map([["hello.txt", Buffer.from("Hello, World!")]]); + await cache.write(FIXTURE_MANIFEST, assets); + + const entries = await cache.list(); + expect(entries).toHaveLength(1); + expect(entries[0]).toMatchObject({ + namespace: "acme", + slug: "test-bundle", + version: "1.0.0", + fresh: true, + sizeBytes: 13, + ociDigest: "sha256:abc123", + ttlSeconds: 86400, + }); + expect(entries[0]?.fetchedAt).toBeDefined(); + }); + + it("lists multiple versions", async () => { + const assets = new Map([["hello.txt", Buffer.from("Hello, World!")]]); + await cache.write(FIXTURE_MANIFEST, assets); + + const manifest2: BundleResolveOutput = { + ...FIXTURE_MANIFEST, + version: "2.0.0", + }; + await cache.write(manifest2, assets); + + const entries = await cache.list(); + expect(entries).toHaveLength(2); + const versions = entries.map((e) => e.version).sort(); + expect(versions).toEqual(["1.0.0", "2.0.0"]); + }); + + it("only lists entries for the current host", async () => { + const cache2 = new BundleCache(tempDir, "https://staging.musher.dev", 86400, 300); + const assets = new Map([["hello.txt", Buffer.from("Hello, World!")]]); + await cache.write(FIXTURE_MANIFEST, assets); + await cache2.write(FIXTURE_MANIFEST, assets); + + const prodEntries = await cache.list(); + expect(prodEntries).toHaveLength(1); + + const stagingEntries = await cache2.list(); + expect(stagingEntries).toHaveLength(1); + }); + }); + + describe("has", () => { + it("returns false for missing bundle", async () => { + const result = await cache.has("acme", "nonexistent"); + expect(result).toEqual({ cached: false, fresh: false }); + }); + + it("returns cached + fresh for a fresh entry", async () => { + const assets = new Map([["hello.txt", Buffer.from("Hello, World!")]]); + await cache.write(FIXTURE_MANIFEST, assets); + + const result = await cache.has("acme", "test-bundle", "1.0.0"); + expect(result).toEqual({ cached: true, fresh: true }); + }); + + it("returns cached + stale for an expired entry", async () => { + const expiring = new BundleCache(tempDir, REGISTRY_URL, 0, 0); + const assets = new Map([["hello.txt", Buffer.from("Hello, World!")]]); + await expiring.write(FIXTURE_MANIFEST, assets); + + const result = await expiring.has("acme", "test-bundle", "1.0.0"); + expect(result).toEqual({ cached: true, fresh: false }); + }); + + it("checks any version when version is omitted", async () => { + const assets = new Map([["hello.txt", Buffer.from("Hello, World!")]]); + await cache.write(FIXTURE_MANIFEST, assets); + + const result = await cache.has("acme", "test-bundle"); + expect(result).toEqual({ cached: true, fresh: true }); + }); + }); + + describe("remove", () => { + it("removes a specific version", async () => { + const assets = new Map([["hello.txt", Buffer.from("Hello, World!")]]); + await cache.write(FIXTURE_MANIFEST, assets); + + const removed = await cache.remove("acme", "test-bundle", "1.0.0"); + expect(removed).toBe(1); + + const loaded = await cache.load("acme", "test-bundle", "1.0.0"); + expect(loaded).toBeNull(); + }); + + it("removes all versions when version is omitted", async () => { + const assets = new Map([["hello.txt", Buffer.from("Hello, World!")]]); + await cache.write(FIXTURE_MANIFEST, assets); + await cache.write({ ...FIXTURE_MANIFEST, version: "2.0.0" }, assets); + + const removed = await cache.remove("acme", "test-bundle"); + expect(removed).toBe(2); + + expect(await cache.load("acme", "test-bundle", "1.0.0")).toBeNull(); + expect(await cache.load("acme", "test-bundle", "2.0.0")).toBeNull(); + }); + + it("removes refs when removing all versions", async () => { + const assets = new Map([["hello.txt", Buffer.from("Hello, World!")]]); + await cache.write(FIXTURE_MANIFEST, assets); + await cache.cacheRef("acme", "test-bundle", "latest", "1.0.0"); + + await cache.remove("acme", "test-bundle"); + + const version = await cache.resolveRef("acme", "test-bundle", "latest"); + expect(version).toBeNull(); + }); + + it("returns 0 for nonexistent bundle", async () => { + const removed = await cache.remove("acme", "nonexistent", "1.0.0"); + expect(removed).toBe(0); + }); + }); + + describe("stats", () => { + it("returns zeros for empty cache", async () => { + const s = await cache.stats(); + expect(s.entryCount).toBe(0); + expect(s.freshCount).toBe(0); + expect(s.staleCount).toBe(0); + expect(s.blobCount).toBe(0); + expect(s.blobSizeBytes).toBe(0); + expect(s.refCount).toBe(0); + }); + + it("counts entries, blobs, and refs", async () => { + const assets = new Map([["hello.txt", Buffer.from("Hello, World!")]]); + await cache.write(FIXTURE_MANIFEST, assets); + await cache.cacheRef("acme", "test-bundle", "latest", "1.0.0"); + + const s = await cache.stats(); + expect(s.entryCount).toBe(1); + expect(s.freshCount).toBe(1); + expect(s.staleCount).toBe(0); + expect(s.blobCount).toBe(1); + expect(s.blobSizeBytes).toBe(13); + expect(s.refCount).toBe(1); + }); + + it("counts across all hosts", async () => { + const cache2 = new BundleCache(tempDir, "https://staging.musher.dev", 86400, 300); + const assets = new Map([["hello.txt", Buffer.from("Hello, World!")]]); + await cache.write(FIXTURE_MANIFEST, assets); + await cache2.write(FIXTURE_MANIFEST, assets); + + // Stats from either cache should see both hosts' manifests + const s = await cache.stats(); + expect(s.entryCount).toBe(2); + // Blobs are deduplicated + expect(s.blobCount).toBe(1); + }); + }); + + describe("invalidate", () => { + it("marks a specific version as stale", async () => { + const assets = new Map([["hello.txt", Buffer.from("Hello, World!")]]); + await cache.write(FIXTURE_MANIFEST, assets); + expect(await cache.isFresh("acme", "test-bundle", "1.0.0")).toBe(true); + + const count = await cache.invalidate("acme", "test-bundle", "1.0.0"); + expect(count).toBe(1); + expect(await cache.isFresh("acme", "test-bundle", "1.0.0")).toBe(false); + }); + + it("keeps data on disk after invalidation", async () => { + const assets = new Map([["hello.txt", Buffer.from("Hello, World!")]]); + await cache.write(FIXTURE_MANIFEST, assets); + + await cache.invalidate("acme", "test-bundle", "1.0.0"); + + // Data is still loadable (just stale) + const loaded = await cache.load("acme", "test-bundle", "1.0.0"); + expect(loaded).not.toBeNull(); + expect(loaded?.file("hello.txt")?.text()).toBe("Hello, World!"); + }); + + it("invalidates all versions when version is omitted", async () => { + const assets = new Map([["hello.txt", Buffer.from("Hello, World!")]]); + await cache.write(FIXTURE_MANIFEST, assets); + await cache.write({ ...FIXTURE_MANIFEST, version: "2.0.0" }, assets); + + const count = await cache.invalidate("acme", "test-bundle"); + expect(count).toBe(2); + expect(await cache.isFresh("acme", "test-bundle", "1.0.0")).toBe(false); + expect(await cache.isFresh("acme", "test-bundle", "2.0.0")).toBe(false); + }); + + it("invalidates refs for the bundle", async () => { + const assets = new Map([["hello.txt", Buffer.from("Hello, World!")]]); + await cache.write(FIXTURE_MANIFEST, assets); + await cache.cacheRef("acme", "test-bundle", "latest", "1.0.0"); + + await cache.invalidate("acme", "test-bundle"); + + const version = await cache.resolveRef("acme", "test-bundle", "latest"); + expect(version).toBeNull(); + }); + + it("returns 0 for nonexistent bundle", async () => { + const count = await cache.invalidate("acme", "nonexistent"); + expect(count).toBe(0); + }); + }); + describe("clean with blob GC", () => { it("removes expired manifests and garbage-collects orphaned blobs", async () => { // Create cache with 0-second TTL so entries expire immediately