Fix synchronisation setting compatibility recovery

This commit is contained in:
vorotamoroz
2026-09-08 17:16:01 +00:00
parent a5056ab157
commit 78c2ccc15a
15 changed files with 1073 additions and 237 deletions
+5 -13
View File
@@ -7,13 +7,9 @@ import {
} from "@vrtmrz/livesync-commonlib/compat/common/models/redflag.const";
import FetchEverything from "@/modules/features/SetupWizard/dialogs/FetchEverything.svelte";
import RebuildEverything from "@/modules/features/SetupWizard/dialogs/RebuildEverything.svelte";
import { extractObject } from "octagonal-wheels/object";
import { REMOTE_MINIO, REMOTE_P2P } from "@vrtmrz/livesync-commonlib/compat/common/models/setting.const";
import type { ObsidianLiveSyncSettings } from "@vrtmrz/livesync-commonlib/settings";
import {
RemotePreferredTweakStatuses,
TweakValuesShouldMatchedTemplate,
} from "@vrtmrz/livesync-commonlib/compat/common/models/tweak.definition";
import { assessTweakCompatibility, type ObsidianLiveSyncSettings } from "@vrtmrz/livesync-commonlib/settings";
import { RemotePreferredTweakStatuses } from "@vrtmrz/livesync-commonlib/compat/common/models/tweak.definition";
import type {
FetchEverythingResult,
RebuildEverythingResult,
@@ -301,12 +297,8 @@ export async function adjustSettingToRemote(
}
const remoteTweaks = remoteResult.values;
const necessary = extractObject(TweakValuesShouldMatchedTemplate, remoteTweaks);
// Check if any necessary tweak value is different from current config.
const differentItems = Object.entries(necessary).filter(([key, value]) => {
return config[key as keyof ObsidianLiveSyncSettings] !== value;
});
if (differentItems.length === 0) {
const assessment = assessTweakCompatibility(config, remoteTweaks);
if (assessment.alignment === "matched") {
log("Remote configuration matches local configuration. No changes applied.", LOG_LEVEL_NOTICE);
} else {
await host.services.UI.confirm.askSelectStringDialogue(
@@ -321,7 +313,7 @@ export async function adjustSettingToRemote(
config = {
...config,
...(Object.fromEntries(differentItems) as Partial<ObsidianLiveSyncSettings>),
...assessment.adoptPreferred.changes,
} satisfies ObsidianLiveSyncSettings;
await host.services.setting.applyExternalSettings(config, true);
log("Remote configuration applied.", LOG_LEVEL_NOTICE);
+22
View File
@@ -19,9 +19,11 @@ import {
flagHandlerToEventHandler,
} from "./redFlag";
import {
DEFAULT_SETTINGS,
TweakValuesRecommendedTemplate,
TweakValuesShouldMatchedTemplate,
TweakValuesTemplate,
type TweakValues,
} from "@vrtmrz/livesync-commonlib/compat/common/types";
import {
ExtraOnLocal,
@@ -1149,6 +1151,26 @@ describe("Red Flag Feature", () => {
});
describe("Remote configuration adjustment", () => {
it("compatibility: preserves the local filename-case value when the remote omits it", async () => {
const host = createHostMock();
const config = {
...DEFAULT_SETTINGS,
...TweakValuesShouldMatchedTemplate,
handleFilenameCaseSensitive: false,
};
const remote: TweakValues = { ...TweakValuesShouldMatchedTemplate };
delete remote.handleFilenameCaseSensitive;
host.mocks.tweakValue.fetchRemotePreferred.mockResolvedValueOnce(availableRemoteTweaks(remote));
await adjustSettingToRemote(host as any, createLoggerMock(), config);
expect(host.mocks.ui.confirm.askSelectStringDialogue).not.toHaveBeenCalled();
expect(host.mocks.setting.applyExternalSettings).toHaveBeenCalledWith(
expect.objectContaining({ handleFilenameCaseSensitive: false }),
true
);
});
it("keeps this device's E2EE settings when preparing to overwrite the remote", async () => {
const host = createHostMock();
Object.assign(host.mocks.setting.settings, TweakValuesShouldMatchedTemplate, {
@@ -1,4 +1,5 @@
import type { ObsidianLiveSyncSettings } from "@vrtmrz/livesync-commonlib/compat/common/types";
import { assessTweakCompatibility } from "@vrtmrz/livesync-commonlib/settings";
import { LOG_LEVEL_INFO, LOG_LEVEL_NOTICE, Logger } from "octagonal-wheels/common/logger";
import { skipIfDuplicated } from "octagonal-wheels/concurrency/lock";
import { balanceChunkPurgedDBs, purgeUnreferencedChunks } from "@vrtmrz/livesync-commonlib/compat/pouchdb/chunks";
@@ -15,7 +16,7 @@ import type { LiveSyncBaseCore } from "@/LiveSyncBaseCore";
type CentralCompatibilityRecoveryServices = Pick<
LiveSyncBaseCore["services"],
"API" | "appLifecycle" | "replicator" | "tweakValue"
"API" | "appLifecycle" | "replicator" | "setting" | "tweakValue"
>;
/** Collaborators for applying a compatibility decision to its failed publication. */
@@ -145,6 +146,15 @@ Even if you choose to clean up, you will see this option again if you exit Obsid
recovery.reason === CENTRAL_COMPATIBILITY_REJECTION_REASONS.TWEAK_MISMATCH &&
recovery.preferredTweakValue
) {
const isCurrent = await context.services.replicator.runWithActiveReplicatorContext(
(activeContext) => activeContext === failedContext
);
// Compare in memory only: these snapshots can contain connection credentials.
if (!isCurrent || JSON.stringify(setting) !== JSON.stringify(context.services.setting.currentSettings())) {
return false;
}
const assessment =
recovery.tweakAssessment ?? assessTweakCompatibility(setting, recovery.preferredTweakValue);
await context.services.tweakValue.askResolvingMismatched(
recovery.preferredTweakValue,
async (effectiveSetting) => {
@@ -156,7 +166,8 @@ Even if you choose to clean up, you will see this option again if you exit Obsid
updated = true;
});
return updated;
}
},
assessment
);
return false;
}
@@ -1,5 +1,6 @@
import { describe, expect, it, vi } from "vitest";
import type { ObsidianLiveSyncSettings } from "@vrtmrz/livesync-commonlib/compat/common/types";
import { assessTweakCompatibility } from "@vrtmrz/livesync-commonlib/settings";
import { defaultLogger, LOG_LEVEL_INFO, LOG_LEVEL_NOTICE, setGlobalLogFunction } from "octagonal-wheels/common/logger";
import {
CENTRAL_COMPATIBILITY_REJECTION_REASONS,
@@ -23,6 +24,72 @@ import { LiveSyncCouchDBReplicator } from "@vrtmrz/livesync-commonlib/compat/rep
import { createCentralCompatibilityRecovery } from "./centralCompatibilityRecovery";
describe("central compatibility recovery", () => {
it("passes the failed attempt's exact tweak assessment to mismatch resolution", async () => {
const setting = { customChunkSize: 0 };
const preferredTweakValue = { customChunkSize: 60 };
const tweakAssessment = assessTweakCompatibility(setting, preferredTweakValue);
const failedContext = { provider: {}, replicator: {} };
const askResolvingMismatched = vi.fn(async (..._args: unknown[]) => "CHECKAGAIN");
const recovery = createCentralCompatibilityRecovery({
services: {
setting: { currentSettings: () => setting },
replicator: {
runWithActiveReplicatorContext: async (task: (context: unknown) => unknown) => task(failedContext),
},
tweakValue: { askResolvingMismatched },
},
} as never);
const result = await recovery.handleReplicationFailure({
context: failedContext,
setting,
outcome: replicationFailed(new Error("mismatched"), {
reason: CENTRAL_COMPATIBILITY_REJECTION_REASONS.TWEAK_MISMATCH,
preferredTweakValue,
tweakAssessment,
}),
progressPresentation: REPLICATION_PROGRESS_PRESENTATIONS.QUIET,
interaction: USER_INITIATED_REPLICATION_AUTHORITY,
} as never);
expect(askResolvingMismatched.mock.calls[0][2]).toBe(tweakAssessment);
expect(result).toBe(false);
});
it.each(["settings", "publication"])(
"discards a mismatch after its %s changed before recovery",
async (changed) => {
const setting = { customChunkSize: 0, couchDB_DBNAME: "original" };
const failedContext = { provider: {}, replicator: {} };
const currentContext = changed === "publication" ? { provider: {}, replicator: {} } : failedContext;
const currentSetting = changed === "settings" ? { ...setting, couchDB_DBNAME: "replacement" } : setting;
const askResolvingMismatched = vi.fn(async () => "CHECKAGAIN");
const recovery = createCentralCompatibilityRecovery({
services: {
setting: { currentSettings: () => currentSetting },
replicator: {
runWithActiveReplicatorContext: async (task: (context: unknown) => unknown) =>
task(currentContext),
},
tweakValue: { askResolvingMismatched },
},
} as never);
await recovery.handleReplicationFailure({
context: failedContext,
setting,
outcome: replicationFailed(new Error("mismatched"), {
reason: CENTRAL_COMPATIBILITY_REJECTION_REASONS.TWEAK_MISMATCH,
preferredTweakValue: { customChunkSize: 60 },
}),
progressPresentation: REPLICATION_PROGRESS_PRESENTATIONS.QUIET,
interaction: USER_INITIATED_REPLICATION_AUTHORITY,
} as never);
expect(askResolvingMismatched).not.toHaveBeenCalled();
}
);
it("characterises unattended central failure handling as one INFO log without a NOTICE", async () => {
const log = vi.fn((_message: unknown, _level?: number, _key?: string) => undefined);
setGlobalLogFunction(log);
@@ -69,6 +136,7 @@ describe("central compatibility recovery", () => {
};
const failedContext = { provider: {}, replicator: failedReplicator };
const replacementContext = { provider: {}, replicator: replacementReplicator };
let activeContext = failedContext;
const preferredTweakValue = { customChunkSize: 60 };
const outcome = replicationFailed(new Error("mismatched"), {
reason: CENTRAL_COMPATIBILITY_REJECTION_REASONS.TWEAK_MISMATCH,
@@ -81,9 +149,10 @@ describe("central compatibility recovery", () => {
services: {
appLifecycle: {},
API: {},
setting: { currentSettings: () => ({}) },
replicator: {
runWithActiveReplicatorContext: vi.fn(async (task: (context: unknown) => unknown) =>
task(replacementContext)
task(activeContext)
),
},
tweakValue: { askResolvingMismatched },
@@ -118,10 +187,15 @@ describe("central compatibility recovery", () => {
progressPresentation: REPLICATION_PROGRESS_PRESENTATIONS.QUIET,
interaction: USER_INITIATED_REPLICATION_AUTHORITY,
} as never);
expect(askResolvingMismatched).toHaveBeenCalledWith(preferredTweakValue, expect.any(Function));
expect(askResolvingMismatched).toHaveBeenCalledWith(
preferredTweakValue,
expect.any(Function),
assessTweakCompatibility({}, preferredTweakValue)
);
const updatePreferredRemote = askResolvingMismatched.mock.calls[0][1] as (
setting: Record<string, unknown>
) => Promise<boolean>;
activeContext = replacementContext;
await expect(updatePreferredRemote({ customChunkSize: 64 })).resolves.toBe(false);
expect(failedSetPreferred).not.toHaveBeenCalled();
expect(replacementSetPreferred).not.toHaveBeenCalled();
@@ -143,6 +217,7 @@ describe("central compatibility recovery", () => {
services: {
appLifecycle: {},
API: {},
setting: { currentSettings: () => ({}) },
replicator: {
runWithActiveReplicatorContext: vi.fn(async (task: (context: unknown) => unknown) =>
task(failedContext)
+1
View File
@@ -86,6 +86,7 @@ export function useReplicationFeature<TContext extends ServiceContext, TCommands
API: services.API,
appLifecycle: services.appLifecycle,
replicator: services.replicator,
setting: services.setting,
tweakValue: services.tweakValue,
},
});