fix: make public cache staging repairable

STO-03: reject at composition any policy that enables Vary variants while
stripping vary from the stored response allowlist, since every stored variant
would collide on the same cache key.

STO-04: extract one verifyReleaseCandidate authority shared by the stage fast
path and activation. A matching release marker is a claim, not evidence, so a
restage now re-verifies each entry, deletes only the owned candidate on a
mismatch and refetches. Abort or an unreadable candidate is never stage success
and never moves the active pointer.

STO-05: split the availability guard. Staging keeps the fetcher requirement
with ONLINE_ONLY recovery; activation, rollback and cleanup need only cache
storage and the mutation lock, so an offline rollback or quota-recovery cleanup
is no longer reported UNSUPPORTED.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
DongHyeonka
2026-08-13 23:28:22 +09:00
co-authored by Claude Opus 5
parent ba79060a83
commit b893d95b36
5 changed files with 312 additions and 42 deletions
+154
View File
@@ -6,6 +6,7 @@ import type {
} from "../../src/application/ports/browser-file-storage/cache-storage-ports.ts";
import {
createDefaultPublicCachePolicy,
resolvePublicCachePolicy,
type PublicCacheRuntimePolicy,
} from "../../src/adapters/cache-storage/public-cache-policy.ts";
import {
@@ -140,6 +141,159 @@ async function manifestFor(
}
describe("public response Cache Storage adapter", () => {
it("rejects a policy that enables variants but strips Vary", () => {
const base = createDefaultPublicCachePolicy(
"https://assets.example.test",
);
// STO-03. Enabling variants while dropping Vary from the response
// allowlist makes every stored variant collide on the same key.
expect(() =>
resolvePublicCachePolicy({
...base,
allowedRequestHeaderNames: ["accept", "accept-language"],
allowedVaryHeaderNames: ["accept-language"],
allowedResponseHeaderNames: base.allowedResponseHeaderNames.filter(
(name) => name !== "vary",
),
}),
).toThrow(TypeError);
// The same policy with Vary preserved is accepted.
expect(() =>
resolvePublicCachePolicy({
...base,
allowedRequestHeaderNames: ["accept", "accept-language"],
allowedVaryHeaderNames: ["accept-language"],
}),
).not.toThrow();
});
it("restages an evicted entry even when the release marker remains", async () => {
const policy = createDefaultPublicCachePolicy(
"https://assets.example.test",
);
const bytes = new Uint8Array([9, 9, 9, 9]);
const asset: PublicCacheAsset = {
absoluteUrl: "https://assets.example.test/evicted.js",
expectedByteLength: bytes.byteLength,
expectedContentType: "application/javascript",
integrity: {
algorithm: "SHA-256",
digestHex: await digestHex(bytes),
},
};
const cacheStorage = new MemoryCacheStorage();
let fetches = 0;
const adapter = createPublicResponseCacheAdapter({
cacheStorage: cacheStorage as unknown as CacheStorage,
crypto: globalThis.crypto,
mutationLock: immediateLock,
policy,
fetcher: async () => {
fetches += 1;
return new Response(bytes, {
headers: {
"cache-control": "public",
"content-type": "application/javascript",
},
});
},
});
const manifest = await manifestFor("evicted-release", [asset], policy);
expect(await adapter.admin.stageRelease(manifest)).toMatchObject({
ok: true,
});
expect(fetches).toBe(1);
// The browser evicts the payload but leaves the marker behind.
const cacheName = [...cacheStorage.caches.keys()].find((name) =>
name.includes("evicted-release"),
);
if (!cacheName) throw new Error("staged cache missing");
const cache = cacheStorage.caches.get(cacheName)!;
const payloadIndex = cache.responses.findIndex(
(entry) => entry.request.url === asset.absoluteUrl,
);
expect(payloadIndex).toBeGreaterThanOrEqual(0);
cache.responses.splice(payloadIndex, 1);
// A marker is a claim, not evidence: restaging must repair.
expect(await adapter.admin.stageRelease(manifest)).toMatchObject({
ok: true,
});
expect(fetches).toBe(2);
expect(
await adapter.admin.activateRelease(
manifest.releaseRegistryId,
manifest.manifestDigestHex,
),
).toMatchObject({ ok: true });
});
it("activates a verified prestaged release without a fetcher", async () => {
const policy = createDefaultPublicCachePolicy(
"https://assets.example.test",
);
const bytes = new Uint8Array([4, 4, 4, 4]);
const asset: PublicCacheAsset = {
absoluteUrl: "https://assets.example.test/offline.js",
expectedByteLength: bytes.byteLength,
expectedContentType: "application/javascript",
integrity: {
algorithm: "SHA-256",
digestHex: await digestHex(bytes),
},
};
const cacheStorage = new MemoryCacheStorage();
const online = createPublicResponseCacheAdapter({
cacheStorage: cacheStorage as unknown as CacheStorage,
crypto: globalThis.crypto,
mutationLock: immediateLock,
policy,
fetcher: async () =>
new Response(bytes, {
headers: {
"cache-control": "public",
"content-type": "application/javascript",
},
}),
});
const manifest = await manifestFor("offline-release", [asset], policy);
expect(await online.admin.stageRelease(manifest)).toMatchObject({
ok: true,
});
// STO-05. Activation and cleanup perform no network I/O, so a missing
// fetcher must not make an offline rollback UNSUPPORTED.
const offline = createPublicResponseCacheAdapter({
cacheStorage: cacheStorage as unknown as CacheStorage,
crypto: globalThis.crypto,
mutationLock: immediateLock,
policy,
});
expect(
await offline.admin.activateRelease(
manifest.releaseRegistryId,
manifest.manifestDigestHex,
),
).toMatchObject({ ok: true });
});
it("cleans exact owned caches without a fetcher", async () => {
const policy = createDefaultPublicCachePolicy(
"https://assets.example.test",
);
const cacheStorage = new MemoryCacheStorage();
const offline = createPublicResponseCacheAdapter({
cacheStorage: cacheStorage as unknown as CacheStorage,
crypto: globalThis.crypto,
mutationLock: immediateLock,
policy,
});
expect(await offline.admin.cleanupOwned()).toMatchObject({
ok: true,
});
});
it("stages, re-verifies and atomically activates an exact release", async () => {
const cacheStorage = new MemoryCacheStorage();
const bytes = new Uint8Array([1, 2, 3, 4]);