mirror of
https://github.com/vrtmrz/obsidian-livesync.git
synced 2026-09-21 18:17:05 +00:00
Complete central provider, resource, scheduling, and recovery contracts
This commit is contained in:
@@ -21,7 +21,23 @@ import type { InjectableServiceHub } from "@vrtmrz/livesync-commonlib/compat/ser
|
||||
import type { LiveSyncCore } from "@/main.ts";
|
||||
import { REMOTE_P2P } from "@vrtmrz/livesync-commonlib/compat/common/models/setting.const";
|
||||
import { withOwnedRemoteResource } from "@/common/ownedRemoteResource";
|
||||
import { REMOTE_RESOURCE_KINDS } from "@vrtmrz/livesync-commonlib/replication";
|
||||
import {
|
||||
CENTRAL_COMPATIBILITY_REJECTION_REASONS,
|
||||
REMOTE_RESOURCE_KINDS,
|
||||
type ReplicationAttemptFailure,
|
||||
type ReplicatorInstance,
|
||||
} from "@vrtmrz/livesync-commonlib/replication";
|
||||
|
||||
interface PreferredRemoteTweakWriter extends ReplicatorInstance {
|
||||
setPreferredRemoteTweakSettings(setting: ObsidianLiveSyncSettings): Promise<void>;
|
||||
}
|
||||
|
||||
function canSetPreferredRemoteTweakSettings(replicator: ReplicatorInstance): replicator is PreferredRemoteTweakWriter {
|
||||
return (
|
||||
"setPreferredRemoteTweakSettings" in replicator &&
|
||||
typeof replicator.setPreferredRemoteTweakSettings === "function"
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Localised counterpart of Commonlib's `confName()`, which takes no translator.
|
||||
@@ -114,11 +130,27 @@ export class ModuleResolvingMismatchedTweaks extends AbstractModule {
|
||||
});
|
||||
}
|
||||
|
||||
async _anyAfterConnectCheckFailed(): Promise<boolean | "CHECKAGAIN" | undefined> {
|
||||
if (!this.core.replicator.tweakSettingsMismatched && !this.core.replicator.preferredTweakValue) return false;
|
||||
const preferred = this.core.replicator.preferredTweakValue;
|
||||
if (!preferred) return false;
|
||||
const ret = await this.services.tweakValue.askResolvingMismatched(preferred);
|
||||
async _anyAfterConnectCheckFailed(failure: ReplicationAttemptFailure): Promise<boolean | "CHECKAGAIN" | undefined> {
|
||||
const recovery = failure.outcome.recoveryHint;
|
||||
if (
|
||||
recovery?.reason !== CENTRAL_COMPATIBILITY_REJECTION_REASONS.TWEAK_MISMATCH ||
|
||||
!recovery.preferredTweakValue
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
const ret = await this.services.tweakValue.askResolvingMismatched(
|
||||
{ ...recovery.preferredTweakValue },
|
||||
async (setting) => {
|
||||
let updated = false;
|
||||
await this.services.replicator.runWithActiveReplicatorContext(async (activeContext) => {
|
||||
if (activeContext !== failure.context) return;
|
||||
if (!canSetPreferredRemoteTweakSettings(activeContext.replicator)) return;
|
||||
await activeContext.replicator.setPreferredRemoteTweakSettings({ ...setting });
|
||||
updated = true;
|
||||
});
|
||||
return updated;
|
||||
}
|
||||
);
|
||||
if (ret == "OK") return false;
|
||||
if (ret == "CHECKAGAIN") return "CHECKAGAIN";
|
||||
if (ret == "IGNORE") return true;
|
||||
@@ -236,8 +268,7 @@ export class ModuleResolvingMismatchedTweaks extends AbstractModule {
|
||||
preferredSource: TweakValues,
|
||||
updatePreferredRemote?: (setting: ObsidianLiveSyncSettings) => Promise<boolean>
|
||||
): Promise<"OK" | "CHECKAGAIN" | "IGNORE"> {
|
||||
const [conf, rebuildRequired] =
|
||||
await this.services.tweakValue.checkAndAskResolvingMismatched(preferredSource);
|
||||
const [conf, rebuildRequired] = await this.services.tweakValue.checkAndAskResolvingMismatched(preferredSource);
|
||||
if (!conf) return "IGNORE";
|
||||
|
||||
const updateRemote = async () => {
|
||||
|
||||
@@ -7,7 +7,12 @@ import {
|
||||
} from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
import { ModuleResolvingMismatchedTweaks } from "./ModuleResolveMismatchedTweaks";
|
||||
import { setLang } from "@/common/translation";
|
||||
import { REMOTE_RESOURCE_KINDS } from "@vrtmrz/livesync-commonlib/replication";
|
||||
import {
|
||||
CENTRAL_COMPATIBILITY_REJECTION_REASONS,
|
||||
REMOTE_RESOURCE_KINDS,
|
||||
USER_INITIATED_REPLICATION_AUTHORITY,
|
||||
type ReplicationAttemptFailure,
|
||||
} from "@vrtmrz/livesync-commonlib/replication";
|
||||
|
||||
function createModule(settingsOverride: Partial<typeof DEFAULT_SETTINGS> = {}) {
|
||||
const askSelectStringDialogue = vi.fn(async (..._args: unknown[]): Promise<string | undefined> => undefined);
|
||||
@@ -56,6 +61,75 @@ function createModule(settingsOverride: Partial<typeof DEFAULT_SETTINGS> = {}) {
|
||||
}
|
||||
|
||||
describe("ModuleResolvingMismatchedTweaks", () => {
|
||||
it("uses the failed attempt hint and writes only through that exact active publication", async () => {
|
||||
const { module, core } = createModule();
|
||||
const attemptPreferred = {
|
||||
...(DEFAULT_SETTINGS as unknown as TweakValues),
|
||||
customChunkSize: 60,
|
||||
};
|
||||
const replacementPreferred = {
|
||||
...(DEFAULT_SETTINGS as unknown as TweakValues),
|
||||
customChunkSize: 99,
|
||||
};
|
||||
let updatePreferredRemote: ((setting: typeof core.settings) => Promise<boolean>) | undefined;
|
||||
const askResolvingMismatched = vi.fn(
|
||||
async (_preferred: unknown, update: (setting: typeof core.settings) => Promise<boolean>) => {
|
||||
updatePreferredRemote = update;
|
||||
return "IGNORE" as const;
|
||||
}
|
||||
);
|
||||
core._services.tweakValue = { askResolvingMismatched };
|
||||
core.replicator = {
|
||||
tweakSettingsMismatched: true,
|
||||
preferredTweakValue: replacementPreferred,
|
||||
};
|
||||
const failedSetPreferred = vi.fn(async (_setting: typeof core.settings) => undefined);
|
||||
const replacementSetPreferred = vi.fn(async (_setting: typeof core.settings) => undefined);
|
||||
const failedContext = {
|
||||
provider: {},
|
||||
replicator: { setPreferredRemoteTweakSettings: failedSetPreferred },
|
||||
configurationIdentity: "profile-a",
|
||||
};
|
||||
const replacementContext = {
|
||||
provider: {},
|
||||
replicator: { setPreferredRemoteTweakSettings: replacementSetPreferred },
|
||||
configurationIdentity: "profile-b",
|
||||
};
|
||||
let activeContext = failedContext;
|
||||
core._services.replicator = {
|
||||
runWithActiveReplicatorContext: vi.fn(async (task: (context: typeof failedContext) => unknown) =>
|
||||
task(activeContext)
|
||||
),
|
||||
};
|
||||
const request = {
|
||||
context: failedContext,
|
||||
setting: core.settings,
|
||||
outcome: {
|
||||
status: "failed" as const,
|
||||
error: new Error("directional replication failed"),
|
||||
recoveryHint: {
|
||||
reason: CENTRAL_COMPATIBILITY_REJECTION_REASONS.TWEAK_MISMATCH,
|
||||
preferredTweakValue: attemptPreferred,
|
||||
},
|
||||
},
|
||||
showMessage: true,
|
||||
interaction: USER_INITIATED_REPLICATION_AUTHORITY,
|
||||
} as unknown as ReplicationAttemptFailure;
|
||||
|
||||
await expect(module._anyAfterConnectCheckFailed(request)).resolves.toBe(true);
|
||||
|
||||
expect(askResolvingMismatched).toHaveBeenCalledWith(attemptPreferred, expect.any(Function));
|
||||
const effectiveSetting = { ...core.settings, customChunkSize: 64 };
|
||||
await expect(updatePreferredRemote?.(effectiveSetting)).resolves.toBe(true);
|
||||
expect(failedSetPreferred).toHaveBeenCalledWith(effectiveSetting);
|
||||
expect(failedSetPreferred.mock.calls[0][0]).not.toBe(effectiveSetting);
|
||||
|
||||
activeContext = replacementContext;
|
||||
await expect(updatePreferredRemote?.({ ...effectiveSetting, customChunkSize: 72 })).resolves.toBe(false);
|
||||
expect(failedSetPreferred).toHaveBeenCalledOnce();
|
||||
expect(replacementSetPreferred).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns an unconfigured remote result without a separate connection preflight", async () => {
|
||||
const { module, core } = createModule();
|
||||
const read = vi.fn(async () => ({
|
||||
|
||||
@@ -960,6 +960,8 @@ export class ObsidianLiveSyncSettingTab extends PluginSettingTab {
|
||||
/**
|
||||
* Checks the edited CouchDB passphrase through an owned synchronisation-
|
||||
* information resource. A missing document may be created by the check.
|
||||
* Incompatibility and operational failure retain their distinct existing
|
||||
* result messages.
|
||||
*/
|
||||
checkWorkingPassphrase = async (): Promise<boolean> => {
|
||||
if (this.editingSettings.remoteType == REMOTE_MINIO) return true;
|
||||
@@ -980,6 +982,15 @@ export class ObsidianLiveSyncSettingTab extends PluginSettingTab {
|
||||
Logger($msg("obsidianLiveSyncSettingTab.logPassphraseNotCompatible"), LOG_LEVEL_NOTICE);
|
||||
return false;
|
||||
}
|
||||
} catch (error) {
|
||||
const reason = error instanceof Error ? error.message : String(error);
|
||||
Logger(
|
||||
$msg("obsidianLiveSyncSettingTab.logCheckPassphraseFailed", {
|
||||
db: reason,
|
||||
}),
|
||||
LOG_LEVEL_NOTICE
|
||||
);
|
||||
return false;
|
||||
} finally {
|
||||
await resource.dispose();
|
||||
}
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { DEFAULT_SETTINGS, REMOTE_COUCHDB } from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
import { DEFAULT_SETTINGS, LOG_LEVEL_NOTICE, REMOTE_COUCHDB } from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
import { REMOTE_RESOURCE_KINDS } from "@vrtmrz/livesync-commonlib/replication";
|
||||
|
||||
const settingsInitialisationMocks = vi.hoisted(() => ({
|
||||
applySettingsWithInitialisationChoice: vi.fn(),
|
||||
}));
|
||||
const loggerMocks = vi.hoisted(() => ({
|
||||
Logger: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/deps.ts", () => ({
|
||||
App: class {},
|
||||
@@ -18,6 +21,14 @@ vi.mock("@/deps.ts", () => ({
|
||||
requireApiVersion: vi.fn(() => false),
|
||||
}));
|
||||
vi.mock("@/main.ts", () => ({ default: class {} }));
|
||||
vi.mock("@vrtmrz/livesync-commonlib/compat/common/logger", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("@vrtmrz/livesync-commonlib/compat/common/logger")>();
|
||||
return { ...actual, Logger: loggerMocks.Logger };
|
||||
});
|
||||
vi.mock("@/common/translation", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("@/common/translation")>();
|
||||
return { ...actual, $msg: vi.fn(actual.$msg) };
|
||||
});
|
||||
vi.mock("@vrtmrz/livesync-commonlib/compat/common/coreEnvFunctions", () => ({
|
||||
getLanguage: vi.fn(() => "en"),
|
||||
compatGlobal: {
|
||||
@@ -58,9 +69,12 @@ vi.mock("./PanePatches.ts", () => ({ panePatches: vi.fn() }));
|
||||
vi.mock("./PaneMaintenance.ts", () => ({ paneMaintenance: vi.fn() }));
|
||||
|
||||
import { ObsidianLiveSyncSettingTab } from "./ObsidianLiveSyncSettingTab";
|
||||
import { $msg } from "@/common/translation";
|
||||
|
||||
beforeEach(() => {
|
||||
settingsInitialisationMocks.applySettingsWithInitialisationChoice.mockReset();
|
||||
loggerMocks.Logger.mockClear();
|
||||
vi.mocked($msg).mockClear();
|
||||
});
|
||||
|
||||
describe("ObsidianLiveSyncSettingTab passphrase verification", () => {
|
||||
@@ -120,6 +134,70 @@ describe("ObsidianLiveSyncSettingTab passphrase verification", () => {
|
||||
|
||||
expect(getNewReplicator).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("reports a CouchDB connection or setup failure with the connection-failure message", async () => {
|
||||
const failure = new Error("remote unavailable");
|
||||
const check = vi.fn(async () => {
|
||||
throw failure;
|
||||
});
|
||||
const dispose = vi.fn(async () => undefined);
|
||||
const createRemoteResource = vi.fn(async () => ({ check, dispose }));
|
||||
const plugin = {
|
||||
app: {},
|
||||
core: {
|
||||
services: {
|
||||
replicator: { createRemoteResource },
|
||||
},
|
||||
},
|
||||
};
|
||||
const tab = new ObsidianLiveSyncSettingTab({} as never, plugin as never);
|
||||
Object.assign(tab, {
|
||||
_editingSettings: {
|
||||
...DEFAULT_SETTINGS,
|
||||
remoteType: REMOTE_COUCHDB,
|
||||
},
|
||||
});
|
||||
|
||||
await expect(tab.checkWorkingPassphrase()).resolves.toBe(false);
|
||||
|
||||
expect(vi.mocked($msg)).toHaveBeenCalledWith("obsidianLiveSyncSettingTab.logCheckPassphraseFailed", {
|
||||
db: failure.message,
|
||||
});
|
||||
expect(vi.mocked($msg)).not.toHaveBeenCalledWith("obsidianLiveSyncSettingTab.logPassphraseNotCompatible");
|
||||
expect(loggerMocks.Logger).toHaveBeenCalledWith(expect.any(String), LOG_LEVEL_NOTICE);
|
||||
expect(dispose).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("reports an actual synchronisation-information mismatch with the incompatibility message", async () => {
|
||||
const check = vi.fn(async () => false);
|
||||
const dispose = vi.fn(async () => undefined);
|
||||
const createRemoteResource = vi.fn(async () => ({ check, dispose }));
|
||||
const plugin = {
|
||||
app: {},
|
||||
core: {
|
||||
services: {
|
||||
replicator: { createRemoteResource },
|
||||
},
|
||||
},
|
||||
};
|
||||
const tab = new ObsidianLiveSyncSettingTab({} as never, plugin as never);
|
||||
Object.assign(tab, {
|
||||
_editingSettings: {
|
||||
...DEFAULT_SETTINGS,
|
||||
remoteType: REMOTE_COUCHDB,
|
||||
},
|
||||
});
|
||||
|
||||
await expect(tab.checkWorkingPassphrase()).resolves.toBe(false);
|
||||
|
||||
expect(vi.mocked($msg)).toHaveBeenCalledWith("obsidianLiveSyncSettingTab.logPassphraseNotCompatible");
|
||||
expect(vi.mocked($msg)).not.toHaveBeenCalledWith(
|
||||
"obsidianLiveSyncSettingTab.logCheckPassphraseFailed",
|
||||
expect.anything()
|
||||
);
|
||||
expect(loggerMocks.Logger).toHaveBeenCalledWith(expect.any(String), LOG_LEVEL_NOTICE);
|
||||
expect(dispose).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
|
||||
describe("ObsidianLiveSyncSettingTab connection testing", () => {
|
||||
|
||||
Reference in New Issue
Block a user