Merge CouchDB cache compatibility changes into Journal recovery

This commit is contained in:
vorotamoroz committed 2026-11-05 09:54:55 +00:00
commit 0ded0bcea5
16 files changed
+735 -85

No files matched your search

+1 -1
View File
@@ -3167,7 +3167,7 @@ export const allMessages: Readonly<Record<string, Readonly<Record<string, string
"zh-tw": "若啟用「自動啟動 P2P 連線」,外掛啟動時會自動建立 P2P 連線。",
},
"If disabled(toggled), chunks will be split on the UI thread (Previous behaviour).": {
def: "If disabled(toggled), chunks will be split on the UI thread (Previous behaviour).",
def: "When enabled, chunks are split on the UI thread (the previous behaviour).",
es: "Si se desactiva, los chunks se dividen en el hilo de UI (comportamiento anterior)",
fr: "Si désactivé, les fragments seront découpés sur le thread UI (comportement précédent).",
he: "אם מכובה, נתחים יפוצלו בשרשור ממשק המשתמש (התנהגות קודמת).",
+1 -1
View File
@@ -368,7 +368,7 @@
"I understand the risks and will proceed without a backup.": "I understand the risks and will proceed without a backup.",
"I Understand, Overwrite Server": "I Understand, Overwrite Server",
"If \"Auto Start P2P Connection\" is enabled, the P2P connection will be started automatically when the plug-in launches.": "If \"Auto Start P2P Connection\" is enabled, the P2P connection will be started automatically when the plug-in launches.",
"If disabled(toggled), chunks will be split on the UI thread (Previous behaviour).": "If disabled(toggled), chunks will be split on the UI thread (Previous behaviour).",
"If disabled(toggled), chunks will be split on the UI thread (Previous behaviour).": "When enabled, chunks are split on the UI thread (the previous behaviour).",
"If enabled per-filed efficient customization sync will be used. We need a small migration when enabling this. And all devices should be updated to v0.23.18. Once we enabled this, we lost a compatibility with old versions.": "If enabled per-filed efficient customization sync will be used. We need a small migration when enabling this. And all devices should be updated to v0.23.18. Once we enabled this, we lost a compatibility with old versions.",
"If enabled, changes will be automatically broadcasted to all connected peers. Notified peers will start fetching the changes.": "If enabled, changes will be automatically broadcasted to all connected peers. Notified peers will start fetching the changes.",
"If enabled, chunks will be split into no more than 100 items. However, dedupe is slightly weaker.": "If enabled, chunks will be split into no more than 100 items. However, dedupe is slightly weaker.",
+1 -2
View File
@@ -437,8 +437,7 @@ If "Auto Start P2P Connection" is enabled, the P2P connection will be started au
If "Auto Start P2P Connection" is enabled, the P2P connection will be
started automatically when the plug-in launches.
If disabled(toggled), chunks will be split on the UI thread (Previous behaviour).:
If disabled(toggled), chunks will be split on the UI thread (Previous
behaviour).
When enabled, chunks are split on the UI thread (the previous behaviour).
If enabled per-filed efficient customization sync will be used. We need a small migration when enabling this. And all devices should be updated to v0.23.18. Once we enabled this, we lost a compatibility with old versions.:
If enabled per-filed efficient customization sync will be used. We need a
small migration when enabling this. And all devices should be updated to
@@ -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> = {
+2 -67
View File
@@ -6,6 +6,7 @@ import { ObsidianConfirm } from "./ObsidianConfirm";
import type { Confirm } from "@vrtmrz/livesync-commonlib/compat/interfaces/Confirm";
import { requestUrl, type RequestUrlParam } from "@/deps";
import { compatGlobal } from "@vrtmrz/livesync-commonlib/compat/common/coreEnvFunctions";
import { createNativeRequestHeaders } from "./nativeRequestHeaders";
// All Services will be migrated to be based on Plain Services, not Injectable Services.
// This is a migration step.
@@ -16,43 +17,6 @@ declare module "obsidian" {
}
}
const cacheReuseDirectives = new Set(["max-age", "max-stale", "min-fresh", "only-if-cached"]);
function splitHeaderDirectives(value: string): string[] {
const directives: string[] = [];
let start = 0;
let inQuotes = false;
let escaped = false;
for (let index = 0; index < value.length; index++) {
const character = value[index];
if (inQuotes && escaped) {
escaped = false;
continue;
}
if (inQuotes && character === "\\") {
escaped = true;
continue;
}
if (character === '"') {
inQuotes = !inQuotes;
} else if (character === "," && !inQuotes) {
const directive = value.slice(start, index).trim();
if (directive) directives.push(directive);
start = index + 1;
}
}
const lastDirective = value.slice(start).trim();
if (lastDirective) directives.push(lastDirective);
return directives;
}
function getDirectiveName(directive: string): string {
const equalsIndex = directive.indexOf("=");
return (equalsIndex === -1 ? directive : directive.slice(0, equalsIndex)).trim().toLowerCase();
}
export class ObsidianAPIService extends InjectableAPIService<ObsidianServiceContext> {
_customHandler: ObsHttpHandler | undefined;
_confirmInstance: Confirm;
@@ -212,37 +176,8 @@ export class ObsidianAPIService extends InjectableAPIService<ObsidianServiceCont
}
}
}
const transformedHeaders = { ...optHeaders };
// Delete headers that should not be sent by native fetch,
// they are controlled by the browser and may cause CORS preflight failure if sent manually.
delete transformedHeaders["host"];
delete transformedHeaders["Host"];
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())) {
// requestUrl has no cache option; no-store alone can reuse an existing cached response.
const cacheControlKeys = Object.keys(transformedHeaders).filter(
(key) => key.toLowerCase() === "cache-control"
);
const cacheControlDirectives = cacheControlKeys
.flatMap((key) => splitHeaderDirectives(transformedHeaders[key]))
.filter((directive) => {
const name = getDirectiveName(directive);
return !cacheReuseDirectives.has(name) && name !== "no-cache" && 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(", ");
const pragmaKeys = Object.keys(transformedHeaders).filter((key) => key.toLowerCase() === "pragma");
const pragmaDirectives = pragmaKeys
.flatMap((key) => splitHeaderDirectives(transformedHeaders[key]))
.filter((directive) => getDirectiveName(directive) !== "no-cache");
const pragmaKey = pragmaKeys[0] ?? "pragma";
for (const key of pragmaKeys) delete transformedHeaders[key];
transformedHeaders[pragmaKey] = [...pragmaDirectives, "no-cache"].join(", ");
}
const transformedHeaders = createNativeRequestHeaders(optHeaders, method, cacheMode);
const contentType =
transformedHeaders["content-type"] ?? transformedHeaders["Content-Type"] ?? "application/json";
const requestParam: RequestUrlParam = {
@@ -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" };
@@ -0,0 +1,100 @@
const cacheReuseDirectives = new Set(["max-age", "max-stale", "min-fresh", "only-if-cached"]);
function splitHeaderDirectives(value: string): string[] {
const directives: string[] = [];
let start = 0;
let inQuotes = false;
let escaped = false;
for (let index = 0; index < value.length; index++) {
const character = value[index];
if (inQuotes && escaped) {
escaped = false;
continue;
}
if (inQuotes && character === "\\") {
escaped = true;
continue;
}
if (character === '"') {
inQuotes = !inQuotes;
} else if (character === "," && !inQuotes) {
const directive = value.slice(start, index).trim();
if (directive) directives.push(directive);
start = index + 1;
}
}
const lastDirective = value.slice(start).trim();
if (lastDirective) directives.push(lastDirective);
return directives;
}
function getDirectiveName(directive: string): string {
const equalsIndex = directive.indexOf("=");
return (equalsIndex === -1 ? directive : directive.slice(0, equalsIndex)).trim().toLowerCase();
}
/**
* Whether native GET or HEAD requests must revalidate an explicitly bypassed cache.
*
* @param method - HTTP method, compared without regard to letter case.
* @param cacheMode - Effective Request or options cache mode.
* @returns True only for GET/HEAD with `no-cache` or `no-store`.
*/
export function shouldRevalidateNativeRequest(method: string, cacheMode: RequestCache | undefined): boolean {
return (cacheMode === "no-cache" || cacheMode === "no-store") && ["GET", "HEAD"].includes(method.toUpperCase());
}
function shouldRetainCacheControlDirective(directive: string, enforceNoStore: boolean): boolean {
const name = getDirectiveName(directive);
return !cacheReuseDirectives.has(name) && name !== "no-cache" && (!enforceNoStore || name !== "no-store");
}
/**
* Copy request headers and apply the native fetch adapter's header/cache policy.
*
* @param headers - Merged input headers; this function does not mutate them.
* @param method - HTTP method used to decide whether cache revalidation applies.
* @param cacheMode - Effective Request or options cache mode.
* @returns Fresh headers with the adapter's native-controlled headers removed.
* Revalidated GET/HEAD requests retain non-conflicting directives and receive
* `no-cache`; `no-store` also adds that directive. Existing header spelling is retained.
*/
export function createNativeRequestHeaders(
headers: Readonly<Record<string, string>>,
method: string,
cacheMode: RequestCache | undefined
): Record<string, string> {
const transformedHeaders = { ...headers };
// Delete headers that should not be sent by native fetch,
// they are controlled by the browser and may cause CORS preflight failure if sent manually.
delete transformedHeaders["host"];
delete transformedHeaders["Host"];
delete transformedHeaders["content-length"];
delete transformedHeaders["Content-Length"];
if (shouldRevalidateNativeRequest(method, cacheMode)) {
// 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");
const cacheControlDirectives = cacheControlKeys
.flatMap((key) => splitHeaderDirectives(transformedHeaders[key]))
.filter((directive) => shouldRetainCacheControlDirective(directive, enforceNoStore));
const cacheControlKey = cacheControlKeys[0] ?? "cache-control";
for (const key of cacheControlKeys) delete transformedHeaders[key];
transformedHeaders[cacheControlKey] = [
...cacheControlDirectives,
"no-cache",
...(enforceNoStore ? ["no-store"] : []),
].join(", ");
const pragmaKeys = Object.keys(transformedHeaders).filter((key) => key.toLowerCase() === "pragma");
const pragmaDirectives = pragmaKeys
.flatMap((key) => splitHeaderDirectives(transformedHeaders[key]))
.filter((directive) => getDirectiveName(directive) !== "no-cache");
const pragmaKey = pragmaKeys[0] ?? "pragma";
for (const key of pragmaKeys) delete transformedHeaders[key];
transformedHeaders[pragmaKey] = [...pragmaDirectives, "no-cache"].join(", ");
}
return transformedHeaders;
}
@@ -0,0 +1,98 @@
import { describe, expect, it } from "vitest";
import { createNativeRequestHeaders, shouldRevalidateNativeRequest } from "./nativeRequestHeaders";
describe("native cache revalidation decision", () => {
it.each(["GET", "HEAD", "get", "head"])("revalidates explicit no-cache/no-store for %s", (method) => {
expect(shouldRevalidateNativeRequest(method, "no-cache")).toBe(true);
expect(shouldRevalidateNativeRequest(method, "no-store")).toBe(true);
});
it.each([undefined, "default", "reload", "force-cache", "only-if-cached"] as const)(
"does not strengthen cache mode %s",
(mode) => {
expect(shouldRevalidateNativeRequest("GET", mode)).toBe(false);
expect(shouldRevalidateNativeRequest("HEAD", mode)).toBe(false);
}
);
it.each(["POST", "PUT", "PATCH", "DELETE", "OPTIONS"])("does not revalidate %s", (method) => {
expect(shouldRevalidateNativeRequest(method, "no-cache")).toBe(false);
expect(shouldRevalidateNativeRequest(method, "no-store")).toBe(false);
});
});
describe("native request header creation", () => {
it("copies input headers and removes the existing native-controlled header forms", () => {
const input = Object.freeze({
host: "first.test",
Host: "second.test",
"content-length": "1",
"Content-Length": "2",
Authorization: "Bearer fixture",
"Cache-Control": "max-age=60",
});
const result = createNativeRequestHeaders(input, "GET", "default");
expect(result).toEqual({ Authorization: "Bearer fixture", "Cache-Control": "max-age=60" });
expect(result).not.toBe(input);
expect(input.host).toBe("first.test");
expect(input["Content-Length"]).toBe("2");
});
it.each(["GET", "HEAD"])("creates no-store revalidation headers for %s", (method) => {
expect(createNativeRequestHeaders({}, method, "no-store")).toEqual({
"cache-control": "no-cache, no-store",
pragma: "no-cache",
});
});
it("preserves an existing no-store directive when enforcing no-cache", () => {
expect(
createNativeRequestHeaders(
{ "Cache-Control": "no-store, max-age=3600", Pragma: "legacy" },
"GET",
"no-cache"
)
).toEqual({ "Cache-Control": "no-store, no-cache", Pragma: "legacy, no-cache" });
});
it("combines differently cased header fields and retains non-conflicting directives", () => {
const input = Object.freeze({
"Cache-Control": 'Max-Age=99, no-transform, custom="a,b", No-Cache',
"cache-control": "max-stale=5, min-fresh=6, only-if-cached, NO-STORE, private",
Pragma: 'legacy="a,b", No-Cache',
pragma: "extension, no-cache",
});
expect(createNativeRequestHeaders(input, "get", "no-store")).toEqual({
"Cache-Control": 'no-transform, custom="a,b", private, no-cache, no-store',
Pragma: 'legacy="a,b", extension, no-cache',
});
expect(input["cache-control"]).toContain("max-stale=5");
});
it("does not split directive commas inside escaped quoted strings", () => {
const custom = String.raw`custom="a\"b,c"`;
expect(createNativeRequestHeaders({ "cache-control": `${custom}, max-age=1` }, "HEAD", "no-cache")).toEqual({
"cache-control": `${custom}, no-cache`,
pragma: "no-cache",
});
});
it.each([
["", "no-cache"],
[", , ,", "no-cache"],
[" , no-transform , , ", "no-transform, no-cache"],
])("ignores empty header-list members in %j", (value, expected) => {
const headers = Object.freeze({ "Cache-Control": value, Pragma: " , , " });
expect(createNativeRequestHeaders(headers, "GET", "no-cache")).toEqual({
"Cache-Control": expected,
Pragma: "no-cache",
});
expect(headers["Cache-Control"]).toBe(value);
});
it("retains cache headers on a non-revalidated method", () => {
const headers = { "cache-control": "max-age=99", Pragma: "legacy" };
expect(createNativeRequestHeaders(headers, "POST", "no-store")).toEqual(headers);
});
});