chore: sync the frontend template from 4dc033c to 8157ad4
The product was materialized from the template at `4dc033c` and has stayed on it through 43 template commits, so it was missing all three rounds of adapter remediation — including files it never had, such as the shared `abortable-operation` primitive and the `exact-snapshot` decoder that later fixes are written against. Taking only the newest round was not possible for that reason: the delta is coherent only as a whole. The product had not touched `src/adapters` at all since materialization, so the 140-file delta applied with a three-way merge and no conflicts. `package.json` was the single overlap and merged cleanly: the product owns `name`, the template contributed `check:adapter-inventory`, `check:remediation-ledger` and the image-resolve-signal type fixture. All 24 product-owned files — README, index.html, CI workflow, i18n catalog, home page, generated schemas, evidence scripts, component and visual snapshots — are byte-identical to `main`. `template.lock.json` now pins the synced revision and tree. Verified in this repository, not inherited from the template: six type projects, lint, nine gates (adapter inventory, remediation ledger, registries, diagnostics, realtime boundaries, architecture, browser file/storage boundaries, optional recipes, documentation), the production build, and 2,054 of 2,073 tests. The 19 failures are all in `tests/unit/ci-artifact-contract.test.ts` and are the same pre-existing sandbox RLIMIT, EMFILE, umask and `/tmp` permission behaviour the template records; four suites that failed once under parallel load pass in isolation. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
002ba3624e
commit
4bff9ca151
@@ -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]);
|
||||
@@ -374,6 +528,150 @@ describe("public response Cache Storage adapter", () => {
|
||||
await expect(cacheStorage.keys()).resolves.toEqual([]);
|
||||
});
|
||||
|
||||
/**
|
||||
* NS-08. Handing the signal to each `Request` only asked a cooperative fetch
|
||||
* to stop. A stream that ignored it held the mutation lock forever, and work
|
||||
* that finished after the abort still wrote its asset and its marker.
|
||||
*/
|
||||
it("does not wait for a non-cooperative fetch after the caller aborts", async () => {
|
||||
const cacheStorage = new MemoryCacheStorage();
|
||||
const policy = createDefaultPublicCachePolicy(
|
||||
"https://assets.example.test",
|
||||
);
|
||||
const bytes = new Uint8Array([1, 2, 3, 4]);
|
||||
const asset: PublicCacheAsset = {
|
||||
absoluteUrl: "https://assets.example.test/never-settles.js",
|
||||
expectedByteLength: bytes.byteLength,
|
||||
expectedContentType: "application/javascript",
|
||||
integrity: { algorithm: "SHA-256", digestHex: await digestHex(bytes) },
|
||||
};
|
||||
const manifest = await manifestFor("never-settles", [asset], policy);
|
||||
const fetchStarted = deferred<void>();
|
||||
let locksHeld = 0;
|
||||
const adapter = createPublicResponseCacheAdapter({
|
||||
cacheStorage: cacheStorage as unknown as CacheStorage,
|
||||
crypto: globalThis.crypto,
|
||||
mutationLock: {
|
||||
async run(signal, operation) {
|
||||
locksHeld += 1;
|
||||
try {
|
||||
return await operation();
|
||||
} finally {
|
||||
locksHeld -= 1;
|
||||
}
|
||||
},
|
||||
},
|
||||
policy,
|
||||
fetcher: () => {
|
||||
fetchStarted.resolve(undefined);
|
||||
// Ignores the signal entirely.
|
||||
return new Promise<Response>(() => {});
|
||||
},
|
||||
});
|
||||
const controller = new AbortController();
|
||||
|
||||
const staging = adapter.admin.stageRelease(manifest, {
|
||||
signal: controller.signal,
|
||||
});
|
||||
await fetchStarted.promise;
|
||||
controller.abort();
|
||||
|
||||
await expect(staging).resolves.toMatchObject({
|
||||
ok: false,
|
||||
error: { code: "ABORTED" },
|
||||
});
|
||||
expect(locksHeld).toBe(0);
|
||||
await expect(cacheStorage.keys()).resolves.toEqual([]);
|
||||
});
|
||||
|
||||
it("writes neither asset nor marker when a digest completes after the abort", async () => {
|
||||
const cacheStorage = new MemoryCacheStorage();
|
||||
const policy = createDefaultPublicCachePolicy(
|
||||
"https://assets.example.test",
|
||||
);
|
||||
const bytes = new Uint8Array([9, 9, 9, 9]);
|
||||
const asset: PublicCacheAsset = {
|
||||
absoluteUrl: "https://assets.example.test/late-digest.js",
|
||||
expectedByteLength: bytes.byteLength,
|
||||
expectedContentType: "application/javascript",
|
||||
integrity: { algorithm: "SHA-256", digestHex: await digestHex(bytes) },
|
||||
};
|
||||
const manifest = await manifestFor("late-digest", [asset], policy);
|
||||
const digestStarted = deferred<void>();
|
||||
const releaseDigest = deferred<void>();
|
||||
let delayFirstDigest = true;
|
||||
const realCrypto = globalThis.crypto;
|
||||
const delayedCrypto = {
|
||||
subtle: {
|
||||
async digest(
|
||||
algorithm: AlgorithmIdentifier,
|
||||
data: BufferSource,
|
||||
): Promise<ArrayBuffer> {
|
||||
if (delayFirstDigest) {
|
||||
delayFirstDigest = false;
|
||||
digestStarted.resolve(undefined);
|
||||
await releaseDigest.promise;
|
||||
}
|
||||
return await realCrypto.subtle.digest(algorithm, data);
|
||||
},
|
||||
},
|
||||
} as unknown as Crypto;
|
||||
let puts = 0;
|
||||
const adapter = createPublicResponseCacheAdapter({
|
||||
cacheStorage: new Proxy(cacheStorage, {
|
||||
get(target, key, receiver) {
|
||||
if (key === "open") {
|
||||
return async (name: string) => {
|
||||
const cache = await target.open(name);
|
||||
return new Proxy(cache, {
|
||||
get(cacheTarget, cacheKey, cacheReceiver) {
|
||||
if (cacheKey === "put") {
|
||||
return async (...args: readonly unknown[]) => {
|
||||
puts += 1;
|
||||
return await (
|
||||
cacheTarget.put as (
|
||||
...values: readonly unknown[]
|
||||
) => Promise<void>
|
||||
)(...args);
|
||||
};
|
||||
}
|
||||
return Reflect.get(cacheTarget, cacheKey, cacheReceiver);
|
||||
},
|
||||
});
|
||||
};
|
||||
}
|
||||
return Reflect.get(target, key, receiver);
|
||||
},
|
||||
}) as unknown as CacheStorage,
|
||||
crypto: delayedCrypto,
|
||||
mutationLock: immediateLock,
|
||||
policy,
|
||||
fetcher: async () =>
|
||||
new Response(bytes, {
|
||||
headers: {
|
||||
"cache-control": "public, max-age=60",
|
||||
"content-type": "application/javascript",
|
||||
},
|
||||
}),
|
||||
});
|
||||
const controller = new AbortController();
|
||||
|
||||
const staging = adapter.admin.stageRelease(manifest, {
|
||||
signal: controller.signal,
|
||||
});
|
||||
await digestStarted.promise;
|
||||
controller.abort();
|
||||
releaseDigest.resolve(undefined);
|
||||
|
||||
await expect(staging).resolves.toMatchObject({
|
||||
ok: false,
|
||||
error: { code: "ABORTED" },
|
||||
});
|
||||
// Neither the asset nor the activation marker may be written by work the
|
||||
// abort already disowned.
|
||||
expect(puts).toBe(0);
|
||||
});
|
||||
|
||||
it("keeps the original activation signal while waiting for the mutation lock", async () => {
|
||||
const cacheStorage = new MemoryCacheStorage();
|
||||
const policy = createDefaultPublicCachePolicy(
|
||||
@@ -1215,3 +1513,199 @@ describe("public response Cache Storage adapter", () => {
|
||||
expect(await cacheStorage.keys()).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* STO-RR-04 / STO-RR-05. A release cache that is currently serving traffic is
|
||||
* the last thing a repair may destroy. A transient marker read failure is not
|
||||
* evidence of damage, and a repair that has not yet fetched anything has not
|
||||
* yet earned the right to delete what still works.
|
||||
*/
|
||||
describe("public response cache repair is failure-atomic", () => {
|
||||
async function stagedRelease(releaseRegistryId: string) {
|
||||
const policy = createDefaultPublicCachePolicy(
|
||||
"https://assets.example.test",
|
||||
);
|
||||
const firstBytes = new Uint8Array([1, 1, 1, 1]);
|
||||
const secondBytes = new Uint8Array([2, 2, 2, 2]);
|
||||
const assets: readonly PublicCacheAsset[] = [
|
||||
{
|
||||
absoluteUrl: "https://assets.example.test/first.js",
|
||||
expectedByteLength: firstBytes.byteLength,
|
||||
expectedContentType: "application/javascript",
|
||||
integrity: {
|
||||
algorithm: "SHA-256",
|
||||
digestHex: await digestHex(firstBytes),
|
||||
},
|
||||
},
|
||||
{
|
||||
absoluteUrl: "https://assets.example.test/second.js",
|
||||
expectedByteLength: secondBytes.byteLength,
|
||||
expectedContentType: "application/javascript",
|
||||
integrity: {
|
||||
algorithm: "SHA-256",
|
||||
digestHex: await digestHex(secondBytes),
|
||||
},
|
||||
},
|
||||
];
|
||||
const bodies = new Map<string, Uint8Array>([
|
||||
[assets[0]!.absoluteUrl, firstBytes],
|
||||
[assets[1]!.absoluteUrl, secondBytes],
|
||||
]);
|
||||
const cacheStorage = new MemoryCacheStorage();
|
||||
const fetchLog: string[] = [];
|
||||
let failFrom: string | null = null;
|
||||
const adapter = createPublicResponseCacheAdapter({
|
||||
cacheStorage: cacheStorage as unknown as CacheStorage,
|
||||
crypto: globalThis.crypto,
|
||||
mutationLock: immediateLock,
|
||||
policy,
|
||||
fetcher: async (request: Request) => {
|
||||
fetchLog.push(request.url);
|
||||
if (failFrom !== null && request.url === failFrom) {
|
||||
throw new TypeError("network is down");
|
||||
}
|
||||
const body = bodies.get(request.url);
|
||||
if (!body) throw new TypeError(`unknown asset ${request.url}`);
|
||||
return new Response(Uint8Array.from(body), {
|
||||
headers: {
|
||||
"cache-control": "public",
|
||||
"content-type": "application/javascript",
|
||||
},
|
||||
});
|
||||
},
|
||||
});
|
||||
const manifest = await manifestFor(releaseRegistryId, assets, policy);
|
||||
expect(await adapter.admin.stageRelease(manifest)).toMatchObject({
|
||||
ok: true,
|
||||
});
|
||||
expect(
|
||||
await adapter.admin.activateRelease(
|
||||
manifest.releaseRegistryId,
|
||||
manifest.manifestDigestHex,
|
||||
),
|
||||
).toMatchObject({ ok: true });
|
||||
const cacheName = [...cacheStorage.caches.keys()].find((name) =>
|
||||
name.includes(releaseRegistryId),
|
||||
);
|
||||
if (!cacheName) throw new Error("staged cache missing");
|
||||
|
||||
return {
|
||||
adapter,
|
||||
assets,
|
||||
cacheName,
|
||||
cacheStorage,
|
||||
fetchLog,
|
||||
manifest,
|
||||
setFailure(url: string | null) {
|
||||
failFrom = url;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
it("does not delete an active candidate when the marker read fails transiently", async () => {
|
||||
const release = await stagedRelease("transient-marker");
|
||||
const cache = release.cacheStorage.caches.get(release.cacheName)!;
|
||||
const realMatch = cache.match.bind(cache);
|
||||
let markerReads = 0;
|
||||
const assetUrls = new Set(release.assets.map((asset) => asset.absoluteUrl));
|
||||
cache.match = async (request: RequestInfo | URL) => {
|
||||
const url =
|
||||
request instanceof Request ? request.url : String(request);
|
||||
if (!assetUrls.has(url)) {
|
||||
markerReads += 1;
|
||||
throw new DOMException("Storage is busy", "InvalidStateError");
|
||||
}
|
||||
return await realMatch(request);
|
||||
};
|
||||
|
||||
const restaged = await release.adapter.admin.stageRelease(release.manifest);
|
||||
|
||||
expect(markerReads).toBeGreaterThan(0);
|
||||
expect(restaged.ok).toBe(false);
|
||||
expect(release.cacheStorage.caches.has(release.cacheName)).toBe(true);
|
||||
cache.match = realMatch;
|
||||
expect(
|
||||
await release.adapter.responses.matchActiveExact({
|
||||
absoluteUrl: release.assets[0]!.absoluteUrl,
|
||||
}),
|
||||
).toMatchObject({ ok: true });
|
||||
});
|
||||
|
||||
it("keeps every healthy asset when one repair fetch fails", async () => {
|
||||
const release = await stagedRelease("partial-repair");
|
||||
const cache = release.cacheStorage.caches.get(release.cacheName)!;
|
||||
// Corrupt only the first asset's stored bytes.
|
||||
const corrupted = cache.responses.findIndex(
|
||||
(entry) => entry.request.url === release.assets[0]!.absoluteUrl,
|
||||
);
|
||||
expect(corrupted).toBeGreaterThanOrEqual(0);
|
||||
cache.responses.splice(corrupted, 1);
|
||||
|
||||
release.setFailure(release.assets[0]!.absoluteUrl);
|
||||
const restaged = await release.adapter.admin.stageRelease(release.manifest);
|
||||
expect(restaged.ok).toBe(false);
|
||||
|
||||
// The cache still exists and the healthy asset is still served.
|
||||
expect(release.cacheStorage.caches.has(release.cacheName)).toBe(true);
|
||||
expect(
|
||||
await release.adapter.responses.matchActiveExact({
|
||||
absoluteUrl: release.assets[1]!.absoluteUrl,
|
||||
}),
|
||||
).toMatchObject({ ok: true });
|
||||
});
|
||||
|
||||
it("still removes a candidate this call created when staging fails", async () => {
|
||||
const policy = createDefaultPublicCachePolicy(
|
||||
"https://assets.example.test",
|
||||
);
|
||||
const bytes = new Uint8Array([7, 7, 7, 7]);
|
||||
const asset: PublicCacheAsset = {
|
||||
absoluteUrl: "https://assets.example.test/fresh.js",
|
||||
expectedByteLength: bytes.byteLength,
|
||||
expectedContentType: "application/javascript",
|
||||
integrity: {
|
||||
algorithm: "SHA-256",
|
||||
digestHex: await digestHex(bytes),
|
||||
},
|
||||
};
|
||||
const cacheStorage = new MemoryCacheStorage();
|
||||
const adapter = createPublicResponseCacheAdapter({
|
||||
cacheStorage: cacheStorage as unknown as CacheStorage,
|
||||
crypto: globalThis.crypto,
|
||||
mutationLock: immediateLock,
|
||||
policy,
|
||||
fetcher: async () => {
|
||||
throw new TypeError("network is down");
|
||||
},
|
||||
});
|
||||
const manifest = await manifestFor("fresh-release", [asset], policy);
|
||||
|
||||
expect(await adapter.admin.stageRelease(manifest)).toMatchObject({
|
||||
ok: false,
|
||||
});
|
||||
expect(await cacheStorage.keys()).toEqual([]);
|
||||
});
|
||||
|
||||
it("repairs an evicted asset in place and keeps the release usable", async () => {
|
||||
const release = await stagedRelease("in-place-repair");
|
||||
const cache = release.cacheStorage.caches.get(release.cacheName)!;
|
||||
const evicted = cache.responses.findIndex(
|
||||
(entry) => entry.request.url === release.assets[1]!.absoluteUrl,
|
||||
);
|
||||
cache.responses.splice(evicted, 1);
|
||||
|
||||
expect(
|
||||
await release.adapter.admin.stageRelease(release.manifest),
|
||||
).toMatchObject({ ok: true });
|
||||
expect(
|
||||
await release.adapter.responses.matchActiveExact({
|
||||
absoluteUrl: release.assets[1]!.absoluteUrl,
|
||||
}),
|
||||
).toMatchObject({ ok: true });
|
||||
expect(
|
||||
await release.adapter.responses.matchActiveExact({
|
||||
absoluteUrl: release.assets[0]!.absoluteUrl,
|
||||
}),
|
||||
).toMatchObject({ ok: true });
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user