Add CouchDB cache compatibility controls and native revalidation

This commit is contained in:
vorotamoroz committed 2026-11-03 01:04:36 +00:00
1 parent 7166de3291
commit 667d6bed1f
9 files changed
+283 -11

No files matched your search

@@ -82,6 +82,7 @@ export function getCouchDBReplicatorConfigurationIdentity(settings: RemoteDBSett
authentication,
projectHeaders(settings.couchDB_CustomHeaders),
settings.useRequestAPI,
settings.useCouchDBCacheControlHeadersOnly === true,
settings.disableRequestURI,
projectRemoteSecurity(settings),
usesEncryptedInternalMetadata(settings),
@@ -7,6 +7,17 @@ import {
} from "./replicatorConfigurationIdentity";
describe("active Replicator configuration identity", () => {
it("treats an absent headers-only CouchDB cache switch as off and leaves Object Storage unaffected", () => {
const absent = configuredSettings();
Reflect.deleteProperty(absent, "useCouchDBCacheControlHeadersOnly");
expect(getCouchDBReplicatorConfigurationIdentity({ ...absent, useCouchDBCacheControlHeadersOnly: false })).toBe(
getCouchDBReplicatorConfigurationIdentity(absent)
);
expect(getObjectStorageReplicatorConfigurationIdentity({ ...absent, useCouchDBCacheControlHeadersOnly: true })).toBe(
getObjectStorageReplicatorConfigurationIdentity(absent)
);
});
it("replaces a connection when the path identity mode changes", () => {
const previous = configuredSettings({ pathNormalizationVersion: 0 });
const next = { ...previous, pathNormalizationVersion: 1 as const };
@@ -73,6 +84,7 @@ describe("active Replicator configuration identity", () => {
["couchDB_PASSWORD", "secret-b"],
["couchDB_CustomHeaders", "X-First: changed"],
["useRequestAPI", true],
["useCouchDBCacheControlHeadersOnly", true],
["disableRequestURI", true],
["encrypt", false],
["passphrase", "encryption-b"],
@@ -28,6 +28,7 @@ export function panePowerUsers(
onUpdate: this.onlyOnCouchDB,
});
new Setting(paneEl).autoWireToggle("useTimeouts", { onUpdate: this.onlyOnCouchDB });
new Setting(paneEl).autoWireToggle("useCouchDBCacheControlHeadersOnly", { onUpdate: this.onlyOnCouchDB });
});
void addPanel(paneEl, $msg("Configuration Encryption")).then((paneEl) => {
const passphrase_options: Record<ConfigPassphraseStore, string> = {
+12 -3
View File
@@ -220,8 +220,9 @@ export class ObsidianAPIService extends InjectableAPIService<ObsidianServiceCont
delete transformedHeaders["content-length"];
delete transformedHeaders["Content-Length"];
const cacheMode = opts?.cache ?? (req instanceof Request ? req.cache : undefined);
if (cacheMode === "no-store" && ["GET", "HEAD"].includes(method.toUpperCase())) {
if (["no-cache", "no-store"].includes(cacheMode ?? "") && ["GET", "HEAD"].includes(method.toUpperCase())) {
// requestUrl has no cache option; no-store alone can reuse an existing cached response.
const enforceNoStore = cacheMode === "no-store";
const cacheControlKeys = Object.keys(transformedHeaders).filter(
(key) => key.toLowerCase() === "cache-control"
);
@@ -229,11 +230,19 @@ export class ObsidianAPIService extends InjectableAPIService<ObsidianServiceCont
.flatMap((key) => splitHeaderDirectives(transformedHeaders[key]))
.filter((directive) => {
const name = getDirectiveName(directive);
return !cacheReuseDirectives.has(name) && name !== "no-cache" && name !== "no-store";
return (
!cacheReuseDirectives.has(name) &&
name !== "no-cache" &&
(!enforceNoStore || name !== "no-store")
);
});
const cacheControlKey = cacheControlKeys[0] ?? "cache-control";
for (const key of cacheControlKeys) delete transformedHeaders[key];
transformedHeaders[cacheControlKey] = [...cacheControlDirectives, "no-cache", "no-store"].join(", ");
transformedHeaders[cacheControlKey] = [
...cacheControlDirectives,
"no-cache",
...(enforceNoStore ? ["no-store"] : []),
].join(", ");
const pragmaKeys = Object.keys(transformedHeaders).filter((key) => key.toLowerCase() === "pragma");
const pragmaDirectives = pragmaKeys
@@ -101,6 +101,35 @@ describe("ObsidianAPIService.nativeFetch cache control", () => {
expect(mocks.requestUrl.mock.calls[0][0]).toMatchObject({ url, method });
});
it.each(["GET", "HEAD", "get", "head"])("revalidates an explicit no-cache %s request", async (method) => {
const headers = {
"Cache-Control": 'public, max-age=3600, no-transform, custom="a,b"',
"cAcHe-CoNtRoL": 'max-stale=30, min-fresh=5, only-if-cached, custom-two="max-age=1, only-if-cached"',
Pragma: "cache",
};
const originalHeaders = { ...headers };
await createService({}).nativeFetch(url, { method, cache: "no-cache", headers });
expect(sentHeaders().get("cache-control")).toBe(
'public, no-transform, custom="a,b", custom-two="max-age=1, only-if-cached", no-cache'
);
expect(sentHeaders().get("pragma")).toBe("cache, no-cache");
expect(headers).toEqual(originalHeaders);
expect(mocks.requestUrl.mock.calls[0][0]).toMatchObject({ url, method });
});
it("preserves an explicit no-store directive when enforcing no-cache", async () => {
const headers = { "Cache-Control": "no-store, max-age=3600", Pragma: "legacy" };
const originalHeaders = { ...headers };
await createService({}).nativeFetch(url, { cache: "no-cache", headers });
expect(sentHeaders().get("cache-control")).toBe("no-store, no-cache");
expect(sentHeaders().get("pragma")).toBe("legacy, no-cache");
expect(headers).toEqual(originalHeaders);
});
it("preserves non-conflicting cache directives when enforcing no-store", async () => {
const headers = {
"Cache-Control": 'max-age=3600, no-transform, custom="a,b"',
@@ -140,6 +169,30 @@ describe("ObsidianAPIService.nativeFetch cache control", () => {
expect(request.headers.has("cache-control")).toBe(false);
});
it("uses a Request no-cache mode when the options do not override it", async () => {
const request = new Request(url, {
method: "HEAD",
cache: "no-cache",
headers: { "Cache-Control": "max-age=60", "X-Fixture": "request" },
});
const headers = new Headers({ "X-Other": "options" });
await createService({}).nativeFetch(request, { headers });
expect(sentHeaders().get("cache-control")).toBe("no-cache");
expect(sentHeaders().get("x-fixture")).toBe("request");
expect(sentHeaders().get("x-other")).toBe("options");
expect(request.headers.get("cache-control")).toBe("max-age=60");
expect(headers.has("cache-control")).toBe(false);
});
it("lets an explicit default cache mode override a no-cache Request", async () => {
await createService({}).nativeFetch(new Request(url, { cache: "no-cache" }), { cache: "default" });
expect(sentHeaders().has("cache-control")).toBe(false);
expect(sentHeaders().has("pragma")).toBe(false);
});
it("lets the options request no-store for an otherwise cacheable Request", async () => {
await createService({}).nativeFetch(new Request(url, { cache: "force-cache" }), { cache: "no-store" });
@@ -153,7 +206,7 @@ describe("ObsidianAPIService.nativeFetch cache control", () => {
expect(sentHeaders().has("pragma")).toBe(false);
});
it.each([undefined, "default", "no-cache", "reload", "force-cache"] as const)(
it.each([undefined, "default", "reload", "force-cache"] as const)(
"preserves caller headers for cache mode %s",
async (cache) => {
const headers = { "Cache-Control": "max-age=60", Pragma: "fixture" };
@@ -171,6 +224,14 @@ describe("ObsidianAPIService.nativeFetch cache control", () => {
expect(sentHeaders().has("pragma")).toBe(false);
});
it.each(["POST", "PUT", "DELETE"])("preserves a no-cache %s request", async (method) => {
const headers = { "Cache-Control": "max-age=60, no-transform", Pragma: "fixture" };
await createService({}).nativeFetch(url, { method, cache: "no-cache", headers });
expect(mocks.requestUrl.mock.calls[0][0]).toMatchObject({ url, method, headers });
});
it.each(["POST", "PUT", "DELETE"])("preserves a no-store %s request", async (method) => {
const headers = { "Cache-Control": "no-transform" };