Migrate LiveSync flows to provider-owned resources

This commit is contained in:
vorotamoroz
2026-08-28 17:54:04 +00:00
parent f7206b1a6e
commit db70b4c2b6
34 changed files with 1830 additions and 365 deletions
@@ -13,11 +13,10 @@ import {
} from "@vrtmrz/livesync-commonlib/compat/common/types";
import { delay, isObjectDifferent, sizeToHumanReadable } from "@vrtmrz/livesync-commonlib/compat/common/utils";
import { Logger } from "@vrtmrz/livesync-commonlib/compat/common/logger";
import { checkSyncInfo } from "@vrtmrz/livesync-commonlib/compat/pouchdb/negotiation";
import { testCrypt } from "octagonal-wheels/encryption/encryption";
import ObsidianLiveSyncPlugin from "@/main.ts";
import { scheduleTask } from "@/common/utils.ts";
import { LiveSyncCouchDBReplicator } from "@vrtmrz/livesync-commonlib/compat/replication/couchdb/LiveSyncReplicator";
import { REMOTE_RESOURCE_KINDS } from "@vrtmrz/livesync-commonlib/replication";
import {
type AllSettingItemKey,
type AllStringItemKey,
@@ -78,6 +77,7 @@ import type {
import { createExtraMenuSettingSpecGroup, createGeneralSettingSpecGroups } from "./GeneralSettingSpecs.ts";
import { SetupManager } from "@/modules/features/SetupManager.ts";
import { isP2PMainRemote } from "@/common/remoteConfiguration.ts";
import { withOwnedRemoteResource } from "@/common/ownedRemoteResource.ts";
// For creating a document
// const toc = new Set<string>();
@@ -340,15 +340,18 @@ export class ObsidianLiveSyncSettingTab extends PluginSettingTab {
async testConnection(settingOverride: Partial<ObsidianLiveSyncSettings> = {}): Promise<void> {
const trialSetting = { ...this.editingSettings, ...settingOverride };
const replicator = await this.services.replicator.getNewReplicator(trialSetting);
if (!replicator) {
Logger("No replicator available for the current settings.", LOG_LEVEL_NOTICE);
const probe = await this.services.replicator.createRemoteResource(
REMOTE_RESOURCE_KINDS.CONNECTION,
trialSetting
);
if (!probe) {
Logger("Connection testing is unavailable for the current settings.", LOG_LEVEL_NOTICE);
return;
}
await replicator.tryConnectRemote(trialSetting);
const status = await replicator.getRemoteStatus(trialSetting);
if (status) {
if (status.estimatedSize) {
await withOwnedRemoteResource(probe, async (ownedProbe) => {
await ownedProbe.check({ createIfMissing: true, showResult: true });
const status = await ownedProbe.getStatus();
if (status && status.estimatedSize) {
Logger(
$msg("obsidianLiveSyncSettingTab.logEstimatedSize", {
size: sizeToHumanReadable(status.estimatedSize),
@@ -356,7 +359,7 @@ export class ObsidianLiveSyncSettingTab extends PluginSettingTab {
LOG_LEVEL_NOTICE
);
}
}
});
}
closeSetting() {
@@ -954,27 +957,23 @@ export class ObsidianLiveSyncSettingTab extends PluginSettingTab {
visibility:
this.isConfiguredAs("remoteType", REMOTE_COUCHDB) || this.isConfiguredAs("remoteType", REMOTE_MINIO),
}) as OnUpdateResult;
// E2EE Function
/**
* Checks the edited CouchDB passphrase through an owned synchronisation-
* information resource. A missing document may be created by the check.
*/
checkWorkingPassphrase = async (): Promise<boolean> => {
if (this.editingSettings.remoteType == REMOTE_MINIO) return true;
const settingForCheck: RemoteDBSettings = {
...this.editingSettings,
};
const replicator = this.services.replicator.getNewReplicator(settingForCheck);
if (!(replicator instanceof LiveSyncCouchDBReplicator)) return true;
const db = await replicator.connectRemoteCouchDBWithSetting(
settingForCheck,
this.services.API.isMobile(),
true
const resource = await this.services.replicator.createRemoteResource(
REMOTE_RESOURCE_KINDS.SYNCHRONISATION_INFORMATION,
settingForCheck
);
if (typeof db === "string") {
Logger($msg("obsidianLiveSyncSettingTab.logCheckPassphraseFailed", { db }), LOG_LEVEL_NOTICE);
return false;
}
if (!resource) return true;
try {
if (await checkSyncInfo(db.db)) {
if (await resource.check()) {
// Logger($msg("obsidianLiveSyncSettingTab.logDatabaseConnected"), LOG_LEVEL_NOTICE);
return true;
} else {
@@ -982,7 +981,7 @@ export class ObsidianLiveSyncSettingTab extends PluginSettingTab {
return false;
}
} finally {
await db.db.close();
await resource.dispose();
}
};
isPassphraseValid = async () => {
@@ -1,9 +1,7 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { DEFAULT_SETTINGS, REMOTE_COUCHDB } from "@vrtmrz/livesync-commonlib/compat/common/types";
import { REMOTE_RESOURCE_KINDS } from "@vrtmrz/livesync-commonlib/replication";
const negotiationMocks = vi.hoisted(() => ({
checkSyncInfo: vi.fn(async () => true),
}));
const settingsInitialisationMocks = vi.hoisted(() => ({
applySettingsWithInitialisationChoice: vi.fn(),
}));
@@ -38,10 +36,6 @@ vi.mock("@/common/events.ts", () => ({
eventHub: { emitEvent: vi.fn(), onEvent: vi.fn() },
}));
vi.mock("@/modules/features/SetupManager.ts", () => ({ SetupManager: class {} }));
vi.mock("@vrtmrz/livesync-commonlib/compat/pouchdb/negotiation", () => negotiationMocks);
vi.mock("@vrtmrz/livesync-commonlib/compat/replication/couchdb/LiveSyncReplicator", () => ({
LiveSyncCouchDBReplicator: class {},
}));
vi.mock("./LiveSyncSetting.ts", () => ({ LiveSyncSetting: class {} }));
vi.mock("./SettingPane.ts", () => ({
enableOnly: vi.fn(() => vi.fn()),
@@ -63,7 +57,6 @@ vi.mock("./PanePowerUsers.ts", () => ({ panePowerUsers: vi.fn() }));
vi.mock("./PanePatches.ts", () => ({ panePatches: vi.fn() }));
vi.mock("./PaneMaintenance.ts", () => ({ paneMaintenance: vi.fn() }));
import { LiveSyncCouchDBReplicator } from "@vrtmrz/livesync-commonlib/compat/replication/couchdb/LiveSyncReplicator";
import { ObsidianLiveSyncSettingTab } from "./ObsidianLiveSyncSettingTab";
beforeEach(() => {
@@ -71,19 +64,15 @@ beforeEach(() => {
});
describe("ObsidianLiveSyncSettingTab passphrase verification", () => {
it("closes the finite remote connection after checking synchronisation information", async () => {
const remoteDatabase = {
close: vi.fn(async () => undefined),
};
const replicator = Object.assign(new LiveSyncCouchDBReplicator({} as never), {
connectRemoteCouchDBWithSetting: vi.fn(async () => ({ db: remoteDatabase })),
});
it("awaits and disposes the owned synchronisation-information resource", async () => {
const check = vi.fn(async () => true);
const dispose = vi.fn(async () => undefined);
const createRemoteResource = vi.fn(async () => ({ check, dispose }));
const plugin = {
app: {},
core: {
services: {
API: { isMobile: vi.fn(() => false) },
replicator: { getNewReplicator: vi.fn(() => replicator) },
replicator: { createRemoteResource },
},
},
};
@@ -97,8 +86,76 @@ describe("ObsidianLiveSyncSettingTab passphrase verification", () => {
await expect(tab.checkWorkingPassphrase()).resolves.toBe(true);
expect(negotiationMocks.checkSyncInfo).toHaveBeenCalledWith(remoteDatabase);
expect(remoteDatabase.close).toHaveBeenCalledOnce();
expect(createRemoteResource).toHaveBeenCalledWith(
REMOTE_RESOURCE_KINDS.SYNCHRONISATION_INFORMATION,
expect.objectContaining({ remoteType: REMOTE_COUCHDB })
);
expect(check).toHaveBeenCalledOnce();
expect(dispose).toHaveBeenCalledOnce();
});
it("does not use the general Replicator factory solely to verify synchronisation information", async () => {
const getNewReplicator = vi.fn(() => Promise.reject(new Error("must not construct a Replicator")));
const createRemoteResource = vi.fn(async () => ({
check: vi.fn(async () => true),
dispose: vi.fn(async () => undefined),
}));
const plugin = {
app: {},
core: {
services: {
replicator: { createRemoteResource, getNewReplicator },
},
},
};
const tab = new ObsidianLiveSyncSettingTab({} as never, plugin as never);
Object.assign(tab, {
_editingSettings: {
...DEFAULT_SETTINGS,
remoteType: REMOTE_COUCHDB,
},
});
await expect(tab.checkWorkingPassphrase()).resolves.toBe(true);
expect(getNewReplicator).not.toHaveBeenCalled();
});
});
describe("ObsidianLiveSyncSettingTab connection testing", () => {
it("uses and disposes the flow-specific connection probe without borrowing a Replicator", async () => {
const check = vi.fn(async () => ({ ok: true as const }));
const getStatus = vi.fn(async () => ({ estimatedSize: 1024 }));
const dispose = vi.fn(async () => undefined);
const createRemoteResource = vi.fn(async () => ({ check, getStatus, dispose }));
const getNewReplicator = vi.fn(() => Promise.reject(new Error("must not borrow a Replicator")));
const plugin = {
app: {},
core: {
services: {
replicator: { createRemoteResource, getNewReplicator },
},
},
};
const tab = new ObsidianLiveSyncSettingTab({} as never, plugin as never);
Object.assign(tab, {
_editingSettings: {
...DEFAULT_SETTINGS,
remoteType: REMOTE_COUCHDB,
couchDB_DBNAME: "saved",
},
});
await expect(tab.testConnection({ couchDB_DBNAME: "trial" })).resolves.toBeUndefined();
expect(createRemoteResource).toHaveBeenCalledWith(
REMOTE_RESOURCE_KINDS.CONNECTION,
expect.objectContaining({ remoteType: REMOTE_COUCHDB, couchDB_DBNAME: "trial" })
);
expect(check).toHaveBeenCalledWith({ createIfMissing: true, showResult: true });
expect(getStatus).toHaveBeenCalledOnce();
expect(dispose).toHaveBeenCalledOnce();
expect(getNewReplicator).not.toHaveBeenCalled();
});
});
@@ -20,6 +20,8 @@
import { copyTo, pickBucketSyncSettings } from "@vrtmrz/livesync-commonlib/compat/common/utils";
import { TYPE_CANCELLED, type SetupRemoteBucketResultType } from "./setupDialogTypes";
import { $msg as translateMessage } from "@/common/translation";
import { withOwnedRemoteResource } from "@/common/ownedRemoteResource";
import { REMOTE_RESOURCE_KINDS } from "@vrtmrz/livesync-commonlib/replication";
const default_setting = pickBucketSyncSettings(DEFAULT_SETTINGS);
@@ -81,13 +83,18 @@
try {
processing = true;
const trialRemoteSetting = generateSetting();
const replicator = await context.services.replicator.getNewReplicator(trialRemoteSetting);
if (!replicator) {
return translateMessage("Failed to create replicator instance.");
const probe = await context.services.replicator.createRemoteResource(
REMOTE_RESOURCE_KINDS.CONNECTION,
trialRemoteSetting
);
if (!probe) {
return translateMessage("Failed to connect to the server. Please check your settings.");
}
try {
const result = await replicator.tryConnectRemote(trialRemoteSetting, false);
if (result) {
const result = await withOwnedRemoteResource(probe, (ownedProbe) =>
ownedProbe.check({ createIfMissing: true, showResult: false })
);
if (result.ok) {
return "";
} else {
return translateMessage("Failed to connect to the server. Please check your settings.");
@@ -29,6 +29,7 @@
} from "./setupDialogTypes";
import { isValidCouchDBServerURL, probeCouchDBConnection } from "./couchDBConnectionProbe";
import { $msg as translateMessage } from "@/common/translation";
import { REMOTE_RESOURCE_KINDS } from "@vrtmrz/livesync-commonlib/replication";
const default_setting = pickCouchDBSyncSettings(DEFAULT_SETTINGS);
@@ -73,16 +74,15 @@
try {
processing = true;
const trialRemoteSetting = generateSetting();
const replicator = await context.services.replicator.getNewReplicator(trialRemoteSetting);
if (!replicator) {
return translateMessage("Failed to create replicator instance.");
const probe = await context.services.replicator.createRemoteResource(
REMOTE_RESOURCE_KINDS.CONNECTION,
trialRemoteSetting
);
if (!probe) {
return translateMessage("Failed to connect to the server. Please check your settings.");
}
try {
const result = await probeCouchDBConnection(
replicator,
trialRemoteSetting,
setupMode === "create-or-connect"
);
const result = await probeCouchDBConnection(probe, setupMode === "create-or-connect");
if (result.ok) {
return "";
} else {
@@ -1,60 +1,14 @@
import type {
ObsidianLiveSyncSettings,
RemoteDBSettings,
} from "@vrtmrz/livesync-commonlib/compat/common/models/setting.type";
export type CouchDBConnectionProbeResult = { ok: true } | { ok: false; reason: string };
type CouchDBConnectionResult =
| string
| {
db: { close(): Promise<void> };
info: unknown;
};
export interface CouchDBConnectionProbe {
isMobile(): boolean;
connectRemoteCouchDBWithSetting(
settings: RemoteDBSettings,
isMobile: boolean,
performSetup: boolean,
skipInfo: boolean
): CouchDBConnectionResult | Promise<CouchDBConnectionResult>;
}
export function isCouchDBConnectionProbe(value: unknown): value is CouchDBConnectionProbe {
return (
typeof value === "object" &&
value !== null &&
"isMobile" in value &&
typeof value.isMobile === "function" &&
"connectRemoteCouchDBWithSetting" in value &&
typeof value.connectRemoteCouchDBWithSetting === "function"
);
}
import type { RemoteConnectionProbe, RemoteConnectionProbeResult } from "@vrtmrz/livesync-commonlib/replication";
import { withOwnedRemoteResource } from "@/common/ownedRemoteResource";
/** Run the selected CouchDB setup mode within one owned probe lifetime. */
export async function probeCouchDBConnection(
replicator: unknown,
settings: ObsidianLiveSyncSettings,
probe: RemoteConnectionProbe,
createIfMissing: boolean
): Promise<CouchDBConnectionProbeResult> {
if (!isCouchDBConnectionProbe(replicator)) {
return { ok: false, reason: "The CouchDB connection probe is unavailable." };
}
const result = await replicator.connectRemoteCouchDBWithSetting(
settings,
replicator.isMobile(),
createIfMissing,
false
): Promise<RemoteConnectionProbeResult> {
return await withOwnedRemoteResource(probe, (ownedProbe) =>
ownedProbe.check({ createIfMissing, showResult: false })
);
if (typeof result === "string") {
return { ok: false, reason: result };
}
try {
return { ok: true };
} finally {
await result.db.close();
}
}
export function isValidCouchDBServerURL(value: string): boolean {
@@ -1,47 +1,34 @@
import { describe, expect, it, vi } from "vitest";
import type { ObsidianLiveSyncSettings } from "@vrtmrz/livesync-commonlib/compat/common/models/setting.type";
import { isValidCouchDBServerURL, probeCouchDBConnection } from "./couchDBConnectionProbe";
const settings = {
couchDB_URI: "https://couch.example",
couchDB_DBNAME: "notes",
} as ObsidianLiveSyncSettings;
describe("CouchDB setup connection policy", () => {
it.each([
[false, "connect to an existing database"],
[true, "create or connect to a database"],
] as const)(
"%s can %s without changing the Commonlib connection contract",
async (createIfMissing, _description) => {
const close = vi.fn(async () => undefined);
const connectRemoteCouchDBWithSetting = vi.fn(async () => ({
db: { close },
info: { db_name: "notes" },
}));
const replicator = {
isMobile: vi.fn(() => false),
connectRemoteCouchDBWithSetting,
tryConnectRemote: vi.fn(),
};
] as const)("%s can %s through an owned connection probe", async (createIfMissing, _description) => {
const check = vi.fn(async () => ({ ok: true as const }));
const dispose = vi.fn(async () => undefined);
const probe = { check, getStatus: vi.fn(), dispose };
await expect(probeCouchDBConnection(replicator, settings, createIfMissing)).resolves.toEqual({ ok: true });
expect(connectRemoteCouchDBWithSetting).toHaveBeenCalledWith(settings, false, createIfMissing, false);
expect(replicator.tryConnectRemote).not.toHaveBeenCalled();
expect(close).toHaveBeenCalledOnce();
}
);
await expect(probeCouchDBConnection(probe, createIfMissing)).resolves.toEqual({ ok: true });
it("returns the connection error without saving or creating through another path", async () => {
const replicator = {
isMobile: vi.fn(() => true),
connectRemoteCouchDBWithSetting: vi.fn(() => "database does not exist"),
expect(check).toHaveBeenCalledWith({ createIfMissing, showResult: false });
expect(dispose).toHaveBeenCalledOnce();
});
it("returns a connection error and still disposes the probe", async () => {
const dispose = vi.fn(async () => undefined);
const probe = {
check: vi.fn(async () => ({ ok: false as const, reason: "database does not exist" })),
getStatus: vi.fn(),
dispose,
};
await expect(probeCouchDBConnection(replicator, settings, false)).resolves.toEqual({
await expect(probeCouchDBConnection(probe, false)).resolves.toEqual({
ok: false,
reason: "database does not exist",
});
expect(dispose).toHaveBeenCalledOnce();
});
it.each([