Merge main into CouchDB cache compatibility changes

This commit is contained in:
vorotamoroz committed 2026-11-05 09:48:00 +00:00
commit c306146dcd
78 files changed
+6177 -929

No files matched your search

+15
View File
@@ -0,0 +1,15 @@
import type { RemoteConnectionProbe } from "@vrtmrz/livesync-commonlib/replication";
import { withOwnedRemoteResource } from "./ownedRemoteResource";
/** Select initial settings from a read-only declaration before opening the local database. */
export async function resolveInitialPathIdentity(
probe: RemoteConnectionProbe | undefined,
newEnvironmentVersion: number
): Promise<0 | 1> {
if (!probe) throw new Error("The remote path identity cannot be inspected.");
return await withOwnedRemoteResource(probe, async (ownedProbe) => {
if (!ownedProbe.inspectPathIdentity) throw new Error("The remote path identity cannot be inspected.");
const result = await ownedProbe.inspectPathIdentity();
return result.status === "empty" ? (newEnvironmentVersion === 1 ? 1 : 0) : result.pathNormalizationVersion;
});
}
@@ -0,0 +1,39 @@
import { describe, expect, it, vi } from "vitest";
import type { PathIdentityInspection, RemoteConnectionProbe } from "@vrtmrz/livesync-commonlib/replication";
import { resolveInitialPathIdentity } from "./initialPathIdentity";
function makeProbe(result: PathIdentityInspection) {
return {
check: vi.fn(),
getStatus: vi.fn(),
inspectPathIdentity: vi.fn(async () => result),
dispose: vi.fn(async () => undefined),
} satisfies RemoteConnectionProbe;
}
describe("resolveInitialPathIdentity", () => {
it.each([0, 1] as const)("adopts existing version %s instead of the new-environment default", async (version) => {
const probe = makeProbe({ status: "configured", pathNormalizationVersion: version });
await expect(resolveInitialPathIdentity(probe, 1 - version)).resolves.toBe(version);
expect(probe.dispose).toHaveBeenCalledOnce();
});
it.each([0, 1] as const)("retains version %s only for a confirmed empty remote", async (version) => {
const probe = makeProbe({ status: "empty" });
await expect(resolveInitialPathIdentity(probe, version)).resolves.toBe(version);
expect(probe.dispose).toHaveBeenCalledOnce();
});
it("preserves inspection failures and disposes the probe", async () => {
const probe = makeProbe({ status: "empty" });
const error = new Error("remote unavailable");
probe.inspectPathIdentity.mockRejectedValue(error);
await expect(resolveInitialPathIdentity(probe, 1)).rejects.toBe(error);
expect(probe.dispose).toHaveBeenCalledOnce();
});
it("refuses an unavailable inspection capability without guessing", async () => {
const probe = makeProbe({ status: "empty" });
const { inspectPathIdentity: _inspect, ...unsupported } = probe;
await expect(resolveInitialPathIdentity(unsupported, 1)).rejects.toThrow("cannot be inspected");
expect(probe.dispose).toHaveBeenCalledOnce();
await expect(resolveInitialPathIdentity(undefined, 1)).rejects.toThrow("cannot be inspected");
});
});
@@ -7,6 +7,9 @@
* remove it from this map in the same change.
*/
export const liveSyncProvisionalEnglishMessages = {
"File identity": "File identity",
"Version 0 preserves legacy path identity; version 1 uses Unicode NFC. Physical file names stay unchanged. Update every device and stop synchronisation, then Rebuild from a complete authoritative Vault and Fetch on the other devices. With P2P, Rebuild prepares only the source device's local database. Resolve colliding file names first.":
"Version 0 preserves legacy path identity; version 1 uses Unicode NFC. Physical file names stay unchanged. Update every device and stop synchronisation, then Rebuild from a complete authoritative Vault and Fetch on the other devices. With P2P, Rebuild prepares only the source device's local database. Resolve colliding file names first.",
"Synchronisation protection": "Synchronisation protection",
"Synchronising your Vault": "Synchronising your Vault",
"Receiving changes from the server…": "Receiving changes from the server…",
+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
+10
View File
@@ -0,0 +1,10 @@
import { createNewVaultSettings as createCommonlibNewVaultSettings } from "@vrtmrz/livesync-commonlib/settings";
import type { ObsidianLiveSyncSettings } from "@vrtmrz/livesync-commonlib/compat/common/types";
/** Create settings for a new LiveSync environment. */
export function createNewVaultSettings(): ObsidianLiveSyncSettings {
return {
...createCommonlibNewVaultSettings(),
pathNormalizationVersion: 1,
};
}
+39
View File
@@ -0,0 +1,39 @@
import { describe, expect, it } from "vitest";
import {
NEW_VAULT_SETTINGS,
SETTINGS_SCHEMA_DEFAULTS,
createNewVaultSettings as createCommonlibNewVaultSettings,
} from "@vrtmrz/livesync-commonlib/settings";
import { createNewVaultSettings } from "./newVaultSettings.ts";
describe("createNewVaultSettings", () => {
it("selects NFC path identity only for new host settings", () => {
expect(SETTINGS_SCHEMA_DEFAULTS.pathNormalizationVersion).toBe(0);
expect(NEW_VAULT_SETTINGS.pathNormalizationVersion).toBe(0);
expect(createCommonlibNewVaultSettings().pathNormalizationVersion).toBe(0);
expect(createNewVaultSettings().pathNormalizationVersion).toBe(1);
});
it("keeps remote configurations independent of shared and other new-Vault settings", () => {
const first = createNewVaultSettings();
const second = createNewVaultSettings();
const shared = createCommonlibNewVaultSettings();
expect(first.remoteConfigurations).not.toBe(second.remoteConfigurations);
expect(first.remoteConfigurations).not.toBe(shared.remoteConfigurations);
expect(first.pluginSyncExtendedSetting).not.toBe(second.pluginSyncExtendedSetting);
expect(first.pluginSyncExtendedSetting).not.toBe(shared.pluginSyncExtendedSetting);
first.remoteConfigurations.example = {
id: "example",
name: "Example",
uri: "sls+http://localhost/",
isEncrypted: false,
};
expect(second.remoteConfigurations).toEqual({});
expect(shared.remoteConfigurations).toEqual({});
expect(NEW_VAULT_SETTINGS.remoteConfigurations).toEqual({});
expect(NEW_VAULT_SETTINGS.pluginSyncExtendedSetting).toEqual({});
});
});
+42 -1
View File
@@ -1,5 +1,5 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { LOG_LEVEL_NOTICE, REMOTE_COUCHDB, REMOTE_MINIO } from "@vrtmrz/livesync-commonlib/compat/common/types";
import { LOG_LEVEL_NOTICE, REMOTE_COUCHDB, REMOTE_MINIO, VERSIONING_DOCID } from "@vrtmrz/livesync-commonlib/compat/common/types";
import type { ObsidianLiveSyncSettings } from "@vrtmrz/livesync-commonlib/compat/common/types";
import { createNewVaultSettings } from "@vrtmrz/livesync-commonlib/settings";
@@ -21,6 +21,7 @@ const mocks = vi.hoisted(() => ({
objectStorage: [] as Array<{
host: unknown;
tryConnectRemote: ReturnType<typeof vi.fn>;
inspectRemotePathIdentity: ReturnType<typeof vi.fn>;
getRemoteStatus: ReturnType<typeof vi.fn>;
getRemotePreferredTweakValues: ReturnType<typeof vi.fn>;
getReplicationPBKDF2Salt: ReturnType<typeof vi.fn>;
@@ -64,6 +65,7 @@ vi.mock("@vrtmrz/livesync-commonlib/compat/replication/journal/LiveSyncJournalRe
LiveSyncJournalReplicator: class {
host: unknown;
tryConnectRemote = vi.fn();
inspectRemotePathIdentity = vi.fn();
getRemoteStatus = vi.fn();
getRemotePreferredTweakValues = vi.fn();
getReplicationPBKDF2Salt = vi.fn();
@@ -135,6 +137,45 @@ describe("replicator probe factories", () => {
expect(databaseClose).not.toHaveBeenCalled();
});
it("inspects CouchDB identity without encryption setup or remote writes and closes on failure", async () => {
const source = { ...createSettings(), encrypt: true };
const probe = await createCouchDBConnectionProbeFactory({} as never)(source);
const replicator = mocks.couchDB[0];
const get = vi.fn(async () => ({ _id: VERSIONING_DOCID, type: "versioninfo", version: 13, used_features: ["nfc-path-identity-v1"] }));
const close = vi.fn(async () => undefined);
replicator.connectRemoteCouchDBWithSetting.mockResolvedValue({ db: { get }, close });
await expect(probe.inspectPathIdentity!()).resolves.toEqual({
status: "configured",
pathNormalizationVersion: 1,
});
expect(replicator.connectRemoteCouchDBWithSetting).toHaveBeenCalledWith(
{ ...source, encrypt: false },
false,
false,
false
);
expect(close).toHaveBeenCalledOnce();
get.mockRejectedValueOnce(new Error("read unavailable"));
await expect(probe.inspectPathIdentity!()).rejects.toThrow("read unavailable");
expect(close).toHaveBeenCalledTimes(2);
expect(replicator.getReplicationPBKDF2Salt).not.toHaveBeenCalled();
});
it("delegates Journal identity inspection to the owned settings snapshot", async () => {
const source = createSettings();
const snapshot = { ...source };
const probe = await createObjectStorageConnectionProbeFactory({} as never)(source);
const replicator = mocks.objectStorage[0];
replicator.inspectRemotePathIdentity.mockResolvedValue({ status: "empty" });
source.bucket = "changed";
await expect(probe.inspectPathIdentity!()).resolves.toEqual({ status: "empty" });
expect(replicator.inspectRemotePathIdentity).toHaveBeenCalledWith(snapshot);
expect(replicator.tryConnectRemote).not.toHaveBeenCalled();
expect(replicator.getReplicationPBKDF2Salt).not.toHaveBeenCalled();
await probe.dispose();
expect(replicator.closeReplication).toHaveBeenCalledOnce();
});
it("maps a CouchDB connection error string and delegates status to the same snapshot", async () => {
const source = createSettings();
const snapshot = { ...source };
@@ -8,6 +8,7 @@ import {
LiveSyncCouchDBReplicator,
type LiveSyncCouchDBReplicatorEnv,
} from "@vrtmrz/livesync-commonlib/compat/replication/couchdb/LiveSyncReplicator";
import { inspectDatabasePathIdentity } from "@vrtmrz/livesync-commonlib/replication";
import { LOG_LEVEL_NOTICE, Logger } from "@vrtmrz/livesync-commonlib/compat/common/logger";
import { LiveSyncJournalReplicator } from "@vrtmrz/livesync-commonlib/compat/replication/journal/LiveSyncJournalReplicator";
import { createReplicatorDisposer, snapshotRemoteSettings } from "./shared";
@@ -51,6 +52,20 @@ function createCouchDBConnectionProbe(
await connection.close();
}
},
inspectPathIdentity: async () => {
const connection = await replicator.connectRemoteCouchDBWithSetting(
{ ...snapshot, encrypt: false },
replicator.isMobile(),
false,
false
);
if (typeof connection === "string") throw new Error(connection);
try {
return await inspectDatabasePathIdentity(connection.db);
} finally {
await connection.close();
}
},
getStatus: () => replicator.getRemoteStatus(snapshot),
dispose,
};
@@ -70,6 +85,7 @@ function createObjectStorageConnectionProbe(
return { ok: false, reason: error };
}
},
inspectPathIdentity: () => replicator.inspectRemotePathIdentity(snapshot),
getStatus: () => replicator.getRemoteStatus(snapshot),
dispose,
};