diff --git a/src/LiveSyncBaseCore.ts b/src/LiveSyncBaseCore.ts index 2e1025f3..60137090 100644 --- a/src/LiveSyncBaseCore.ts +++ b/src/LiveSyncBaseCore.ts @@ -2,8 +2,6 @@ import { LOG_LEVEL_INFO } from "octagonal-wheels/common/logger"; import type PouchDB from "pouchdb-core"; import type { SimpleStore } from "octagonal-wheels/databases/SimpleStoreBase"; import { - REMOTE_COUCHDB, - REMOTE_MINIO, type HasSettings, type ObsidianLiveSyncSettings, type EntryDoc, @@ -33,18 +31,8 @@ import type { ServiceModules } from "@vrtmrz/livesync-commonlib/compat/interface import { ModuleBasicMenu } from "./modules/essential/ModuleBasicMenu"; import { usePrepareDatabaseForUse } from "@vrtmrz/livesync-commonlib/compat/serviceFeatures/prepareDatabaseForUse"; import type { Constructor } from "@vrtmrz/livesync-commonlib/compat/common/utils.type"; -import { - CAPABILITY_NOT_APPLICABLE, - CENTRAL_REMOTE_REPLICATION_READINESS, - defineReplicatorProviderDefinitions, - supportedOpenReplicationContinuous, - supportedOpenReplicationOneShot, - supportedOpenReplicationUnattended, - supportedStopActiveTransfer, -} from "@vrtmrz/livesync-commonlib/replication"; -import { LiveSyncCouchDBReplicator } from "@vrtmrz/livesync-commonlib/compat/replication/couchdb/LiveSyncReplicator"; -import { LiveSyncJournalReplicator } from "@vrtmrz/livesync-commonlib/compat/replication/journal/LiveSyncJournalReplicator"; import { useReplicationScheduling, type ReplicationSchedulingControl } from "./serviceFeatures/replicationScheduling"; +import { createCentralReplicatorProviderDefinitions } from "./common/replicatorProviders"; /** Focused views returned by serviceFeatures which the host may consume during composition. */ export interface LiveSyncCoreFeatureViews { @@ -159,35 +147,9 @@ export class LiveSyncBaseCore< /** Compose the current central providers before any lifecycle event can acquire one. */ private registerReplicatorProviders() { - const definitions = defineReplicatorProviderDefinitions([REMOTE_COUCHDB, REMOTE_MINIO] as const, { - [REMOTE_COUCHDB]: { - kind: REMOTE_COUCHDB, - diagnosticName: "CouchDB", - readiness: CENTRAL_REMOTE_REPLICATION_READINESS, - isConfigured: (settings) => - settings.remoteType === REMOTE_COUCHDB && - !!settings.couchDB_URI?.trim() && - !!settings.couchDB_DBNAME?.trim(), - create: (_settings) => Promise.resolve(new LiveSyncCouchDBReplicator(this)), - userInitiatedOneShot: supportedOpenReplicationOneShot(), - unattendedOneShot: supportedOpenReplicationUnattended(), - continuous: supportedOpenReplicationContinuous(), - stopActiveTransfer: supportedStopActiveTransfer(), - }, - [REMOTE_MINIO]: { - kind: REMOTE_MINIO, - diagnosticName: "Object Storage", - readiness: CENTRAL_REMOTE_REPLICATION_READINESS, - isConfigured: (settings) => - settings.remoteType === REMOTE_MINIO && !!settings.endpoint?.trim() && !!settings.bucket?.trim(), - create: (_settings) => Promise.resolve(new LiveSyncJournalReplicator(this)), - userInitiatedOneShot: supportedOpenReplicationOneShot(), - unattendedOneShot: supportedOpenReplicationUnattended(), - continuous: CAPABILITY_NOT_APPLICABLE, - stopActiveTransfer: supportedStopActiveTransfer(), - }, - }); - this.services.replicator.registerReplicatorProviderDefinitions(definitions); + this.services.replicator.registerReplicatorProviderDefinitions( + createCentralReplicatorProviderDefinitions(this) + ); } public registerModules(extraModules: AbstractModule[] = []) { diff --git a/src/apps/cli/README.md b/src/apps/cli/README.md index 09e9bb9b..a4815f6b 100644 --- a/src/apps/cli/README.md +++ b/src/apps/cli/README.md @@ -71,6 +71,9 @@ livesync-cli [database-path] [command] [args...] - `init-settings` writes its target file. `setup`, `remote-add`, `remote-rm`, `remote-set`, and `remote-activate` write their settings changes without this option. - All remaining commands leave the settings file unchanged by default. - Temporary values used to suspend synchronisation or select a remote for one command are never written. +- `--compat-remote-admin-exit-zero`: Preserve the former zero exit code when `mark-resolved`, `lock-remote`, or `unlock-remote` returns a provider verification failure. + - Without this option, those commands return a non-zero exit code when verification fails. + - Invalid arguments, unknown remote IDs, and errors thrown while activating or mutating the remote remain errors with or without this option. ### Commands @@ -96,6 +99,8 @@ livesync-cli [database-path] [command] [args...] - `remote-status [remote-id]`: Show remote database status. - `init-settings [file]`: Create a default settings file. +Remote-administration commands verify the resulting milestone state through the selected provider. The existing `[Verification]` lines remain suitable for scripts which inspect command output, while the default exit code now reflects whether that verification succeeded. + ### Examples ```bash @@ -338,6 +343,8 @@ Options: --interval , -i (daemon only) Poll CouchDB every N seconds instead of using the _changes feed --vault , -V (daemon/mirror) Path to vault directory, decoupled from database-path --write-settings Write setting changes after a successful command + --compat-remote-admin-exit-zero + Preserve the former zero exit code when remote-administration verification fails --help, -h Show this help message Commands: diff --git a/src/apps/cli/commands/remoteAdministration.ts b/src/apps/cli/commands/remoteAdministration.ts new file mode 100644 index 00000000..6f168d15 --- /dev/null +++ b/src/apps/cli/commands/remoteAdministration.ts @@ -0,0 +1,120 @@ +import type { StandardIo } from "@vrtmrz/livesync-commonlib/context"; +import { + REMOTE_ADMINISTRATION_ACTIONS, + REMOTE_ADMINISTRATION_FAILURE_REASONS, + REMOTE_ADMINISTRATION_OBSERVATION_KINDS, + isRemoteAdministrationVerified, + type RemoteAdministrationAction, + type RemoteAdministrationResult, +} from "@vrtmrz/livesync-commonlib/replication"; +import { activateRemoteConfiguration } from "@vrtmrz/livesync-commonlib/remote-configurations"; +import { writeStderrLine } from "@/apps/cli/cliOutput"; +import type { CLICommand, CLICommandContext, CLIOptions } from "./types"; + +const REMOTE_ADMINISTRATION_ACTION_BY_COMMAND = Object.freeze({ + "mark-resolved": REMOTE_ADMINISTRATION_ACTIONS.MARK_RESOLVED, + "lock-remote": REMOTE_ADMINISTRATION_ACTIONS.LOCK, + "unlock-remote": REMOTE_ADMINISTRATION_ACTIONS.UNLOCK, +} as const satisfies Partial>); + +export type RemoteAdministrationCommand = keyof typeof REMOTE_ADMINISTRATION_ACTION_BY_COMMAND; + +/** Return whether a CLI command belongs to the remote-administration category. */ +export function isRemoteAdministrationCommand(command: CLICommand): command is RemoteAdministrationCommand { + return Object.prototype.hasOwnProperty.call(REMOTE_ADMINISTRATION_ACTION_BY_COMMAND, command); +} + +function detailMessage(detail: unknown): string { + return detail instanceof Error ? detail.message : String(detail); +} + +function reportMilestoneObservation( + standardIo: StandardIo, + observation: Extract< + RemoteAdministrationResult["observation"], + { kind: typeof REMOTE_ADMINISTRATION_OBSERVATION_KINDS.MILESTONE } + > +): void { + standardIo.writeStderr(`[Verification] Remote Database: ${observation.locked ? "LOCKED" : "UNLOCKED"}\n`); + standardIo.writeStderr( + `[Verification] Current Device Node ID (${observation.nodeId}): ${observation.accepted ? "ACCEPTED" : "NOT ACCEPTED"}\n` + ); +} + +/** Map typed provider observations to the CLI's established verification output. */ +function reportRemoteAdministrationResult(standardIo: StandardIo, result: RemoteAdministrationResult): void { + if (result.observation?.kind === REMOTE_ADMINISTRATION_OBSERVATION_KINDS.MILESTONE) { + reportMilestoneObservation(standardIo, result.observation); + return; + } + if (isRemoteAdministrationVerified(result)) { + return; + } + + switch (result.reason) { + case REMOTE_ADMINISTRATION_FAILURE_REASONS.NO_ACTIVE_REPLICATOR: + standardIo.writeStderr("[Verification] No active replicator found\n"); + return; + case REMOTE_ADMINISTRATION_FAILURE_REASONS.CONNECTION_FAILED: + standardIo.writeStderr( + `[Verification] Failed to connect to remote CouchDB: ${detailMessage(result.detail)}\n` + ); + return; + case REMOTE_ADMINISTRATION_FAILURE_REASONS.MILESTONE_NOT_FOUND: + standardIo.writeStderr("[Verification] Milestone document not found on remote.\n"); + return; + case REMOTE_ADMINISTRATION_FAILURE_REASONS.MILESTONE_READ_FAILED: + standardIo.writeStderr( + `[Verification] Failed to fetch milestone document: ${detailMessage(result.detail)}\n` + ); + return; + case REMOTE_ADMINISTRATION_FAILURE_REASONS.LOCAL_IDENTITY_UNAVAILABLE: + standardIo.writeStderr("[Verification] Failed to initialise the current device identity.\n"); + return; + case REMOTE_ADMINISTRATION_FAILURE_REASONS.CAPABILITY_NOT_IMPLEMENTED: + case REMOTE_ADMINISTRATION_FAILURE_REASONS.CAPABILITY_NOT_APPLICABLE: + standardIo.writeStderr("[Verification] Remote administration is unavailable for this provider.\n"); + return; + case REMOTE_ADMINISTRATION_FAILURE_REASONS.POSTCONDITION_MISMATCH: + standardIo.writeStderr("[Verification] The requested remote state was not observed.\n"); + return; + } +} + +/** + * Apply one provider-owned mutation and map its typed verification to CLI exit policy. + * Mutation exceptions deliberately escape this boundary. + */ +export async function runRemoteAdministrationCommand( + options: CLIOptions, + context: CLICommandContext, + command: RemoteAdministrationCommand +): Promise { + const id = options.commandArgs[0]?.trim(); + if (id) { + let switched = false; + await context.core.services.setting.updateSettings((currentSettings) => { + const activated = activateRemoteConfiguration(currentSettings, id); + if (activated) { + switched = true; + return activated; + } + return currentSettings; + }, false); + + if (!switched) { + context.core.services.context.standardIo.writeStderr( + `[Info] Failed to temporarily activate remote configuration: ${id}\n` + ); + return false; + } + + await context.core.services.control.applySettings(); + } + + writeStderrLine(context.core.services.context.standardIo, `[Command] ${command}${id ? ` ${id}` : ""}`); + const action = REMOTE_ADMINISTRATION_ACTION_BY_COMMAND[command]; + const result = await context.core.services.replicator.runRemoteAdministration({ action }); + reportRemoteAdministrationResult(context.core.services.context.standardIo, result); + return isRemoteAdministrationVerified(result) || options.compatRemoteAdminExitZero === true; +} diff --git a/src/apps/cli/commands/runCommand.ts b/src/apps/cli/commands/runCommand.ts index 532e1c82..f49087b8 100644 --- a/src/apps/cli/commands/runCommand.ts +++ b/src/apps/cli/commands/runCommand.ts @@ -2,12 +2,8 @@ import { decodeSettingsFromSetupURI } from "@vrtmrz/livesync-commonlib/compat/AP import { configURIBase } from "@vrtmrz/livesync-commonlib/compat/common/models/shared.const"; import { DEFAULT_SETTINGS, - MILESTONE_DOCID, type FilePathWithPrefix, type ObsidianLiveSyncSettings, - REMOTE_COUCHDB, - REMOTE_MINIO, - type EntryMilestoneInfo, type EntryDoc, } from "@vrtmrz/livesync-commonlib/compat/common/types"; import { ConnectionStringParser } from "@vrtmrz/livesync-commonlib/compat/common/ConnectionString"; @@ -23,74 +19,20 @@ import { performFullScan } from "@vrtmrz/livesync-commonlib/compat/serviceFeatur import { UnresolvedErrorManager } from "@vrtmrz/livesync-commonlib/compat/services/base/UnresolvedErrorManager"; import { compatGlobal } from "@vrtmrz/livesync-commonlib/compat/common/coreEnvFunctions"; import { fsPromises as fs, path } from "@vrtmrz/livesync-commonlib/node"; -import type { LiveSyncCouchDBReplicator } from "@vrtmrz/livesync-commonlib/compat/replication/couchdb/LiveSyncReplicator"; -import type { LiveSyncJournalReplicator } from "@vrtmrz/livesync-commonlib/compat/replication/journal/LiveSyncJournalReplicator"; import { writeStderrLine, writeStdoutLine } from "@/apps/cli/cliOutput"; import { isReplicationCompleted, NO_INTERACTION, + REMOTE_RESOURCE_KINDS, USER_INITIATED_REPLICATION_AUTHORITY, } from "@vrtmrz/livesync-commonlib/replication"; +import { withOwnedRemoteResource } from "@/common/ownedRemoteResource"; +import { isRemoteAdministrationCommand, runRemoteAdministrationCommand } from "./remoteAdministration"; function redactConnectionString(uri: string): string { return uri.replace(/\/\/([^@/]+)@/u, "//***@"); } -async function verifyRemoteState( - core: CLICommandContext["core"], - settings: ObsidianLiveSyncSettings -): Promise { - const { standardIo } = core.services.context; - const replicator = core.services.replicator.getActiveReplicator(); - if (!replicator) { - standardIo.writeStderr("[Verification] No active replicator found\n"); - return false; - } - - if (!replicator.nodeid) { - await replicator.initializeDatabaseForReplication(); - } - - try { - let milestone: EntryMilestoneInfo | false | undefined = undefined; - if (settings.remoteType === REMOTE_COUCHDB) { - const dbRet = await (replicator as LiveSyncCouchDBReplicator).connectRemoteCouchDBWithSetting( - settings, - false, - true - ); - if (typeof dbRet === "string") { - standardIo.writeStderr(`[Verification] Failed to connect to remote CouchDB: ${dbRet}\n`); - return false; - } - try { - milestone = await dbRet.db.get(MILESTONE_DOCID); - } finally { - await dbRet.db.close(); - } - } else if (settings.remoteType === REMOTE_MINIO) { - milestone = await (replicator as LiveSyncJournalReplicator).client.downloadJson("_00000000-milestone.json"); - } - - if (milestone) { - const isLocked = !!milestone.locked; - const isAccepted = !!milestone.accepted_nodes?.includes(replicator.nodeid); - standardIo.writeStderr(`[Verification] Remote Database: ${isLocked ? "LOCKED" : "UNLOCKED"}\n`); - standardIo.writeStderr( - `[Verification] Current Device Node ID (${replicator.nodeid}): ${isAccepted ? "ACCEPTED" : "NOT ACCEPTED"}\n` - ); - return true; - } else { - standardIo.writeStderr("[Verification] Milestone document not found on remote.\n"); - return false; - } - } catch (e) { - const message = e instanceof Error ? e.message : String(e); - standardIo.writeStderr(`[Verification] Failed to fetch milestone document: ${message}\n`); - return false; - } -} - export async function runCommand(options: CLIOptions, context: CLICommandContext): Promise { const { databasePath, core, replicationScheduling, settingsPath } = context; const { standardIo } = core.services.context; @@ -781,88 +723,8 @@ export async function runCommand(options: CLIOptions, context: CLICommandContext return true; } - if (options.command === "mark-resolved") { - const id = options.commandArgs[0]?.trim(); - if (id) { - let switched = false; - await core.services.setting.updateSettings((currentSettings) => { - const activated = activateRemoteConfiguration(currentSettings, id); - if (activated) { - switched = true; - return activated; - } - return currentSettings; - }, false); - - if (!switched) { - standardIo.writeStderr(`[Info] Failed to temporarily activate remote configuration: ${id}\n`); - return false; - } - - await core.services.control.applySettings(); - } - - writeStderrLine(standardIo, `[Command] mark-resolved${id ? ` ${id}` : ""}`); - await core.services.replication.markResolved(); - const settings = core.services.setting.currentSettings(); - await verifyRemoteState(core, settings); - return true; - } - - if (options.command === "unlock-remote") { - const id = options.commandArgs[0]?.trim(); - if (id) { - let switched = false; - await core.services.setting.updateSettings((currentSettings) => { - const activated = activateRemoteConfiguration(currentSettings, id); - if (activated) { - switched = true; - return activated; - } - return currentSettings; - }, false); - - if (!switched) { - standardIo.writeStderr(`[Info] Failed to temporarily activate remote configuration: ${id}\n`); - return false; - } - - await core.services.control.applySettings(); - } - - writeStderrLine(standardIo, `[Command] unlock-remote${id ? ` ${id}` : ""}`); - await core.services.replication.markUnlocked(); - const settings = core.services.setting.currentSettings(); - await verifyRemoteState(core, settings); - return true; - } - - if (options.command === "lock-remote") { - const id = options.commandArgs[0]?.trim(); - if (id) { - let switched = false; - await core.services.setting.updateSettings((currentSettings) => { - const activated = activateRemoteConfiguration(currentSettings, id); - if (activated) { - switched = true; - return activated; - } - return currentSettings; - }, false); - - if (!switched) { - standardIo.writeStderr(`[Info] Failed to temporarily activate remote configuration: ${id}\n`); - return false; - } - - await core.services.control.applySettings(); - } - - writeStderrLine(standardIo, `[Command] lock-remote${id ? ` ${id}` : ""}`); - await core.services.replication.markLocked(); - const settings = core.services.setting.currentSettings(); - await verifyRemoteState(core, settings); - return true; + if (isRemoteAdministrationCommand(options.command)) { + return await runRemoteAdministrationCommand(options, context, options.command); } if (options.command === "remote-status") { @@ -887,13 +749,16 @@ export async function runCommand(options: CLIOptions, context: CLICommandContext } writeStderrLine(standardIo, `[Command] remote-status${id ? ` ${id}` : ""}`); - const replicator = core.services.replicator.getActiveReplicator(); - if (!replicator) { - standardIo.writeStderr("[Error] No active replicator found\n"); + const settings = core.services.setting.currentSettings(); + const resource = await core.services.replicator.createRemoteResource( + REMOTE_RESOURCE_KINDS.CONNECTION, + settings + ); + if (!resource) { + standardIo.writeStderr("[Error] Remote status is unavailable for the current provider\n"); return false; } - const settings = core.services.setting.currentSettings(); - const status = await replicator.getRemoteStatus(settings); + const status = await withOwnedRemoteResource(resource, (ownedResource) => ownedResource.getStatus()); if (status === false) { standardIo.writeStderr("[Error] Failed to fetch remote status\n"); return false; diff --git a/src/apps/cli/commands/runCommand.unit.spec.ts b/src/apps/cli/commands/runCommand.unit.spec.ts index d48510e1..8e5b1b3f 100644 --- a/src/apps/cli/commands/runCommand.unit.spec.ts +++ b/src/apps/cli/commands/runCommand.unit.spec.ts @@ -2,10 +2,22 @@ import { fsPromises as fs, os, path } from "@vrtmrz/livesync-commonlib/node"; import * as processSetting from "@vrtmrz/livesync-commonlib/compat/API/processSetting"; import { ConnectionStringParser } from "@vrtmrz/livesync-commonlib/compat/common/ConnectionString"; import { configURIBase } from "@vrtmrz/livesync-commonlib/compat/common/models/shared.const"; -import { DEFAULT_SETTINGS, REMOTE_COUCHDB, REMOTE_MINIO, REMOTE_P2P } from "@vrtmrz/livesync-commonlib/compat/common/types"; +import { + DEFAULT_SETTINGS, + REMOTE_COUCHDB, + REMOTE_MINIO, + REMOTE_P2P, +} from "@vrtmrz/livesync-commonlib/compat/common/types"; import { describe, expect, it, vi, beforeEach, afterEach } from "vitest"; import { runCommand } from "./runCommand"; import type { CLIOptions } from "./types"; +import { + REMOTE_ADMINISTRATION_ACTIONS, + REMOTE_ADMINISTRATION_FAILURE_REASONS, + REMOTE_ADMINISTRATION_OBSERVATION_KINDS, + REMOTE_ADMINISTRATION_RESULT_STATUSES, + REMOTE_RESOURCE_KINDS, +} from "@vrtmrz/livesync-commonlib/replication"; function createStandardIoMock() { return { @@ -46,6 +58,23 @@ function createCoreMock() { markLocked: vi.fn(async () => {}), }, replicator: { + runRemoteAdministration: vi.fn(async ({ action }) => ({ + status: REMOTE_ADMINISTRATION_RESULT_STATUSES.VERIFIED, + observation: { + kind: REMOTE_ADMINISTRATION_OBSERVATION_KINDS.MILESTONE, + locked: action === REMOTE_ADMINISTRATION_ACTIONS.LOCK, + accepted: true, + nodeId: "test-node-id", + }, + })), + createRemoteResource: vi.fn(async () => ({ + check: vi.fn(async () => ({ ok: true as const })), + getStatus: vi.fn(async () => ({ + db_name: "test-db", + doc_count: 42, + })), + dispose: vi.fn(async () => undefined), + })), getActiveReplicator: vi.fn(() => ({ nodeid: "test-node-id", initializeDatabaseForReplication: vi.fn(async () => {}), @@ -93,6 +122,7 @@ function makeOptions(command: CLIOptions["command"], commandArgs: string[]): CLI databasePath: "/tmp/vault", verbose: false, force: false, + compatRemoteAdminExitZero: false, }; } @@ -706,28 +736,110 @@ describe("runCommand abnormal cases", () => { }); describe("mark-resolved and unlock-remote commands", () => { + it("fails by default when remote administration cannot verify its postcondition", async () => { + const core = createCoreMock(); + core.services.replicator.runRemoteAdministration.mockResolvedValueOnce({ + status: REMOTE_ADMINISTRATION_RESULT_STATUSES.VERIFICATION_FAILED, + reason: REMOTE_ADMINISTRATION_FAILURE_REASONS.NO_ACTIVE_REPLICATOR, + }); + + const result = await runCommand(makeOptions("mark-resolved", []), { + ...context, + core, + }); + + expect(result).toBe(false); + }); + + it("preserves the historical zero exit for returned verification failures only when requested", async () => { + const core = createCoreMock(); + core.services.replicator.runRemoteAdministration.mockResolvedValueOnce({ + status: REMOTE_ADMINISTRATION_RESULT_STATUSES.VERIFICATION_FAILED, + reason: REMOTE_ADMINISTRATION_FAILURE_REASONS.NO_ACTIVE_REPLICATOR, + }); + + const result = await runCommand( + { ...makeOptions("mark-resolved", []), compatRemoteAdminExitZero: true }, + { + ...context, + core, + } + ); + + expect(result).toBe(true); + }); + + it("does not hide a thrown remote mutation failure behind the compatibility option", async () => { + const core = createCoreMock(); + const failure = new Error("mutation failed"); + core.services.replicator.runRemoteAdministration.mockRejectedValueOnce(failure); + + await expect( + runCommand( + { ...makeOptions("mark-resolved", []), compatRemoteAdminExitZero: true }, + { + ...context, + core, + } + ) + ).rejects.toBe(failure); + }); + + it("does not hide an unknown remote ID behind the compatibility option", async () => { + const core = createCoreMock(); + + const result = await runCommand( + { ...makeOptions("mark-resolved", ["missing-remote"]), compatRemoteAdminExitZero: true }, + { + ...context, + core, + } + ); + + expect(result).toBe(false); + expect(core.services.replicator.runRemoteAdministration).not.toHaveBeenCalled(); + }); + + it("fails a lock command when the observed milestone remains unlocked", async () => { + const core = createCoreMock(); + core.services.replicator.runRemoteAdministration.mockResolvedValueOnce({ + status: REMOTE_ADMINISTRATION_RESULT_STATUSES.VERIFICATION_FAILED, + reason: REMOTE_ADMINISTRATION_FAILURE_REASONS.POSTCONDITION_MISMATCH, + observation: { + kind: REMOTE_ADMINISTRATION_OBSERVATION_KINDS.MILESTONE, + locked: false, + accepted: true, + nodeId: "test-node-id", + }, + }); + + const result = await runCommand(makeOptions("lock-remote", []), { + ...context, + core, + }); + + expect(result).toBe(false); + const verificationOutput = core.services.context.standardIo.writeStderr.mock.calls + .map(([chunk]: [string | Uint8Array]) => + typeof chunk === "string" ? chunk : new TextDecoder().decode(chunk) + ) + .join(""); + expect(verificationOutput).toContain("[Verification] Remote Database: UNLOCKED\n"); + expect(verificationOutput).toContain("[Verification] Current Device Node ID (test-node-id): ACCEPTED\n"); + }); + it("mark-resolved without args runs on active database", async () => { const core = createCoreMock(); - const remoteDatabase = { - close: vi.fn(async () => undefined), - get: vi.fn(async () => ({ - locked: false, - accepted_nodes: ["test-node-id"], - })), - }; - core.services.replicator.getActiveReplicator.mockReturnValueOnce({ - nodeid: "test-node-id", - initializeDatabaseForReplication: vi.fn(async () => undefined), - connectRemoteCouchDBWithSetting: vi.fn(async () => ({ db: remoteDatabase })), - }); const result = await runCommand(makeOptions("mark-resolved", []), { ...context, core, }); expect(result).toBe(true); - expect(core.services.replication.markResolved).toHaveBeenCalledTimes(1); + expect(core.services.replicator.runRemoteAdministration).toHaveBeenCalledWith({ + action: REMOTE_ADMINISTRATION_ACTIONS.MARK_RESOLVED, + }); expect(core.services.control.applySettings).not.toHaveBeenCalled(); - expect(remoteDatabase.close).toHaveBeenCalledOnce(); + expect(core.services.replication.markResolved).not.toHaveBeenCalled(); }); it("mark-resolved with remote-id temporarily activates it and runs markResolved", async () => { @@ -745,7 +857,9 @@ describe("runCommand abnormal cases", () => { core, }); expect(result).toBe(true); - expect(core.services.replication.markResolved).toHaveBeenCalledTimes(1); + expect(core.services.replicator.runRemoteAdministration).toHaveBeenCalledWith({ + action: REMOTE_ADMINISTRATION_ACTIONS.MARK_RESOLVED, + }); expect(core.services.control.applySettings).toHaveBeenCalledTimes(1); expect(settings.activeConfigurationId).toBe("r1"); expect(core.services.setting.updateSettings).toHaveBeenCalledWith(expect.any(Function), false); @@ -758,7 +872,9 @@ describe("runCommand abnormal cases", () => { core, }); expect(result).toBe(true); - expect(core.services.replication.markUnlocked).toHaveBeenCalledTimes(1); + expect(core.services.replicator.runRemoteAdministration).toHaveBeenCalledWith({ + action: REMOTE_ADMINISTRATION_ACTIONS.UNLOCK, + }); expect(core.services.control.applySettings).not.toHaveBeenCalled(); }); @@ -777,7 +893,9 @@ describe("runCommand abnormal cases", () => { core, }); expect(result).toBe(true); - expect(core.services.replication.markUnlocked).toHaveBeenCalledTimes(1); + expect(core.services.replicator.runRemoteAdministration).toHaveBeenCalledWith({ + action: REMOTE_ADMINISTRATION_ACTIONS.UNLOCK, + }); expect(core.services.control.applySettings).toHaveBeenCalledTimes(1); expect(settings.activeConfigurationId).toBe("r1"); expect(core.services.setting.updateSettings).toHaveBeenCalledWith(expect.any(Function), false); @@ -790,7 +908,9 @@ describe("runCommand abnormal cases", () => { core, }); expect(result).toBe(true); - expect(core.services.replication.markLocked).toHaveBeenCalledTimes(1); + expect(core.services.replicator.runRemoteAdministration).toHaveBeenCalledWith({ + action: REMOTE_ADMINISTRATION_ACTIONS.LOCK, + }); expect(core.services.control.applySettings).not.toHaveBeenCalled(); }); @@ -809,7 +929,9 @@ describe("runCommand abnormal cases", () => { core, }); expect(result).toBe(true); - expect(core.services.replication.markLocked).toHaveBeenCalledTimes(1); + expect(core.services.replicator.runRemoteAdministration).toHaveBeenCalledWith({ + action: REMOTE_ADMINISTRATION_ACTIONS.LOCK, + }); expect(core.services.control.applySettings).toHaveBeenCalledTimes(1); expect(settings.activeConfigurationId).toBe("r1"); expect(core.services.setting.updateSettings).toHaveBeenCalledWith(expect.any(Function), false); @@ -817,6 +939,17 @@ describe("runCommand abnormal cases", () => { it("remote-status without args outputs status of active remote configuration", async () => { const core = createCoreMock(); + const getStatus = vi.fn(async () => ({ + db_name: "test-db", + doc_count: 42, + })); + const dispose = vi.fn(async () => undefined); + const createRemoteResource = vi.fn(async () => ({ + check: vi.fn(), + getStatus, + dispose, + })); + core.services.replicator.createRemoteResource = createRemoteResource; const stdout = captureStdout(core); const result = await runCommand(makeOptions("remote-status", []), { ...context, @@ -827,6 +960,13 @@ describe("runCommand abnormal cases", () => { const parsedStatus = JSON.parse(fullOutput); expect(parsedStatus.db_name).toBe("test-db"); expect(parsedStatus.doc_count).toBe(42); + expect(createRemoteResource).toHaveBeenCalledWith( + REMOTE_RESOURCE_KINDS.CONNECTION, + core.services.setting.currentSettings() + ); + expect(getStatus).toHaveBeenCalledOnce(); + expect(dispose).toHaveBeenCalledOnce(); + expect(core.services.replicator.getActiveReplicator).not.toHaveBeenCalled(); }); it("remote-status with remote-id temporarily activates it and outputs status", async () => { diff --git a/src/apps/cli/commands/types.ts b/src/apps/cli/commands/types.ts index 8ea537f9..58576176 100644 --- a/src/apps/cli/commands/types.ts +++ b/src/apps/cli/commands/types.ts @@ -42,6 +42,8 @@ export interface CLIOptions { debug?: boolean; force?: boolean; writeSettings?: boolean; + /** Restore the former zero exit code after a returned remote-administration verification failure. */ + compatRemoteAdminExitZero?: boolean; command: CLICommand; commandArgs: string[]; interval?: number; diff --git a/src/apps/cli/main.ts b/src/apps/cli/main.ts index 69d5bfc8..895b5a16 100644 --- a/src/apps/cli/main.ts +++ b/src/apps/cli/main.ts @@ -103,6 +103,8 @@ Options: (defaults to database-path; allows separate PouchDB and vault dirs) --interval , -i (daemon only) Poll CouchDB every N seconds instead of using the _changes feed --write-settings Write setting changes after a successful command + --compat-remote-admin-exit-zero + Preserve the former zero exit code when remote-administration verification fails Examples: livesync-cli ./my-database Run daemon (LiveSync mode) @@ -153,6 +155,7 @@ export function parseArgs(standardIo: StandardIo = createNodeStandardIo()): CLIO let debug = false; let force = false; let writeSettings = false; + let compatRemoteAdminExitZero = false; let interval: number | undefined; let command: CLICommand = "daemon"; const commandArgs: string[] = []; @@ -212,6 +215,9 @@ export function parseArgs(standardIo: StandardIo = createNodeStandardIo()): CLIO case "--write-settings": writeSettings = true; break; + case "--compat-remote-admin-exit-zero": + compatRemoteAdminExitZero = true; + break; default: { if (!databasePath) { if (command === "daemon" && isCLICommand(token)) { @@ -253,6 +259,7 @@ export function parseArgs(standardIo: StandardIo = createNodeStandardIo()): CLIO debug, force, writeSettings, + compatRemoteAdminExitZero, command, commandArgs, interval, diff --git a/src/apps/cli/main.unit.spec.ts b/src/apps/cli/main.unit.spec.ts index 0b8dc45f..49712bd9 100644 --- a/src/apps/cli/main.unit.spec.ts +++ b/src/apps/cli/main.unit.spec.ts @@ -69,6 +69,7 @@ describe("CLI parseArgs", () => { const combined = standardIo.writeStdout.mock.calls.flat().join(""); expect(combined).toContain("Usage:"); expect(combined).toContain("livesync-cli [options] [command-args]"); + expect(combined).toContain("--compat-remote-admin-exit-zero"); }); it("parses p2p-peers command and timeout", () => { @@ -215,4 +216,13 @@ describe("CLI parseArgs", () => { expect(parsed.writeSettings).toBe(true); expect(parsed.commandArgs).toEqual([]); }); + + it("parses the remote-administration exit compatibility option globally", () => { + process.argv = ["node", "livesync-cli", "./vault", "--compat-remote-admin-exit-zero", "mark-resolved"]; + const parsed = parseArgs(); + + expect(parsed.command).toBe("mark-resolved"); + expect(parsed.compatRemoteAdminExitZero).toBe(true); + expect(parsed.commandArgs).toEqual([]); + }); }); diff --git a/src/common/ownedRemoteResource.ts b/src/common/ownedRemoteResource.ts new file mode 100644 index 00000000..60483414 --- /dev/null +++ b/src/common/ownedRemoteResource.ts @@ -0,0 +1,18 @@ +/** + * Run a finite operation with a flow-owned remote resource and release it + * after either success or failure. + * + * Resource implementations make `dispose()` idempotent. This helper makes the + * caller's ownership boundary explicit and prevents finite flows from leaking + * a provider-owned resource when their operation rejects. + */ +export async function withOwnedRemoteResource }, TResult>( + resource: TResource, + operation: (ownedResource: TResource) => Promise +): Promise { + try { + return await operation(resource); + } finally { + await resource.dispose(); + } +} diff --git a/src/common/ownedRemoteResource.unit.spec.ts b/src/common/ownedRemoteResource.unit.spec.ts new file mode 100644 index 00000000..840acf7e --- /dev/null +++ b/src/common/ownedRemoteResource.unit.spec.ts @@ -0,0 +1,28 @@ +import { describe, expect, it, vi } from "vitest"; +import { withOwnedRemoteResource } from "./ownedRemoteResource"; + +describe("flow-owned remote resources", () => { + it("disposes a resource after a successful finite operation", async () => { + const dispose = vi.fn(async () => undefined); + const resource = { dispose }; + + await expect( + withOwnedRemoteResource(resource, async (owned) => (owned === resource ? "done" : "wrong")) + ).resolves.toBe("done"); + + expect(dispose).toHaveBeenCalledOnce(); + }); + + it("disposes a resource when the finite operation rejects", async () => { + const dispose = vi.fn(async () => undefined); + const error = new Error("resource operation failed"); + + await expect( + withOwnedRemoteResource({ dispose }, async () => { + throw error; + }) + ).rejects.toBe(error); + + expect(dispose).toHaveBeenCalledOnce(); + }); +}); diff --git a/src/common/replicatorAdministration.ts b/src/common/replicatorAdministration.ts new file mode 100644 index 00000000..d2b41e94 --- /dev/null +++ b/src/common/replicatorAdministration.ts @@ -0,0 +1,143 @@ +import { + MILESTONE_DOCID, + type EntryMilestoneInfo, + type RemoteDBSettings, +} from "@vrtmrz/livesync-commonlib/compat/common/types"; +import type { LiveSyncAbstractReplicator } from "@vrtmrz/livesync-commonlib/compat/replication/LiveSyncAbstractReplicator"; +import { LiveSyncCouchDBReplicator } from "@vrtmrz/livesync-commonlib/compat/replication/couchdb/LiveSyncReplicator"; +import { LiveSyncJournalReplicator } from "@vrtmrz/livesync-commonlib/compat/replication/journal/LiveSyncJournalReplicator"; +import { + REMOTE_ADMINISTRATION_FAILURE_REASONS, + REMOTE_ADMINISTRATION_OBSERVATION_KINDS, + applyRemoteAdministrationMutation, + milestoneSatisfiesRemoteAdministration, + remoteAdministrationVerificationFailed, + remoteAdministrationVerified, + supportedCapability, + type MilestoneRemoteAdministrationObservation, + type RemoteAdministrationRequest, + type RemoteAdministrationResult, + type SupportedCapability, + type RemoteAdministrationRunner, +} from "@vrtmrz/livesync-commonlib/replication"; + +const JOURNAL_MILESTONE_PATH = "_00000000-milestone.json"; + +async function ensureLocalNodeIdentity( + replicator: LiveSyncAbstractReplicator +): Promise { + if (replicator.nodeid) { + return undefined; + } + if ((await replicator.initializeDatabaseForReplication()) && replicator.nodeid) { + return undefined; + } + return remoteAdministrationVerificationFailed(REMOTE_ADMINISTRATION_FAILURE_REASONS.LOCAL_IDENTITY_UNAVAILABLE); +} + +function observeMilestone( + replicator: LiveSyncAbstractReplicator, + milestone: EntryMilestoneInfo +): MilestoneRemoteAdministrationObservation { + return { + kind: REMOTE_ADMINISTRATION_OBSERVATION_KINDS.MILESTONE, + locked: !!milestone.locked, + accepted: !!milestone.accepted_nodes?.includes(replicator.nodeid), + nodeId: replicator.nodeid, + }; +} + +function resultFromMilestone( + replicator: LiveSyncAbstractReplicator, + request: RemoteAdministrationRequest, + milestone: EntryMilestoneInfo | false | undefined +): RemoteAdministrationResult { + if (!milestone) { + return remoteAdministrationVerificationFailed(REMOTE_ADMINISTRATION_FAILURE_REASONS.MILESTONE_NOT_FOUND); + } + const observation = observeMilestone(replicator, milestone); + return milestoneSatisfiesRemoteAdministration(request.action, observation) + ? remoteAdministrationVerified(observation) + : remoteAdministrationVerificationFailed(REMOTE_ADMINISTRATION_FAILURE_REASONS.POSTCONDITION_MISMATCH, { + observation, + }); +} + +async function runCouchDBRemoteAdministration( + replicator: LiveSyncAbstractReplicator, + setting: RemoteDBSettings, + request: RemoteAdministrationRequest +): Promise { + const identityFailure = await ensureLocalNodeIdentity(replicator); + if (identityFailure) return identityFailure; + + await applyRemoteAdministrationMutation(replicator, setting, request.action); + + const couchDBReplicator = replicator as LiveSyncCouchDBReplicator; + let connection; + try { + connection = await couchDBReplicator.connectRemoteCouchDBWithSetting( + setting, + couchDBReplicator.isMobile(), + true + ); + } catch (error) { + return remoteAdministrationVerificationFailed(REMOTE_ADMINISTRATION_FAILURE_REASONS.CONNECTION_FAILED, { + detail: error, + }); + } + if (typeof connection === "string") { + return remoteAdministrationVerificationFailed(REMOTE_ADMINISTRATION_FAILURE_REASONS.CONNECTION_FAILED, { + detail: connection, + }); + } + + let milestone: EntryMilestoneInfo | undefined; + let observationError: unknown; + try { + milestone = await connection.db.get(MILESTONE_DOCID); + } catch (error) { + observationError = error; + } + try { + await connection.close(); + } catch (error) { + observationError ??= error; + } + if (observationError !== undefined) { + return remoteAdministrationVerificationFailed(REMOTE_ADMINISTRATION_FAILURE_REASONS.MILESTONE_READ_FAILED, { + detail: observationError, + }); + } + return resultFromMilestone(replicator, request, milestone); +} + +async function runObjectStorageRemoteAdministration( + replicator: LiveSyncAbstractReplicator, + setting: RemoteDBSettings, + request: RemoteAdministrationRequest +): Promise { + const identityFailure = await ensureLocalNodeIdentity(replicator); + if (identityFailure) return identityFailure; + + await applyRemoteAdministrationMutation(replicator, setting, request.action); + + const journalReplicator = replicator as LiveSyncJournalReplicator; + let milestone: EntryMilestoneInfo | false | undefined; + try { + milestone = await journalReplicator.client.downloadJson(JOURNAL_MILESTONE_PATH); + } catch (error) { + return remoteAdministrationVerificationFailed(REMOTE_ADMINISTRATION_FAILURE_REASONS.MILESTONE_READ_FAILED, { + detail: error, + }); + } + return resultFromMilestone(replicator, request, milestone); +} + +/** CouchDB mutation and milestone postcondition verification capability. */ +export const COUCHDB_REMOTE_ADMINISTRATION_CAPABILITY: SupportedCapability = + supportedCapability(runCouchDBRemoteAdministration); + +/** Object Storage mutation and milestone postcondition verification capability. */ +export const OBJECT_STORAGE_REMOTE_ADMINISTRATION_CAPABILITY: SupportedCapability = + supportedCapability(runObjectStorageRemoteAdministration); diff --git a/src/common/replicatorAdministration.unit.spec.ts b/src/common/replicatorAdministration.unit.spec.ts new file mode 100644 index 00000000..5bfd7c27 --- /dev/null +++ b/src/common/replicatorAdministration.unit.spec.ts @@ -0,0 +1,158 @@ +import { describe, expect, it, vi } from "vitest"; +import { DEFAULT_SETTINGS, REMOTE_COUCHDB, REMOTE_MINIO } from "@vrtmrz/livesync-commonlib/compat/common/types"; +import { + REMOTE_ADMINISTRATION_ACTIONS, + REMOTE_ADMINISTRATION_FAILURE_REASONS, + REMOTE_ADMINISTRATION_OBSERVATION_KINDS, + REMOTE_ADMINISTRATION_RESULT_STATUSES, +} from "@vrtmrz/livesync-commonlib/replication"; +import { + COUCHDB_REMOTE_ADMINISTRATION_CAPABILITY, + OBJECT_STORAGE_REMOTE_ADMINISTRATION_CAPABILITY, +} from "./replicatorAdministration"; + +describe("central remote administration capabilities", () => { + it("mutates CouchDB, verifies the requested postcondition, and closes only the owned connection", async () => { + const rawDatabaseClose = vi.fn(async () => undefined); + const close = vi.fn(async () => undefined); + const database = { + get: vi.fn(async () => ({ locked: true, accepted_nodes: ["node-1"] })), + close: rawDatabaseClose, + }; + const replicator = { + nodeid: "node-1", + initializeDatabaseForReplication: vi.fn(async () => true), + isMobile: vi.fn(() => false), + markRemoteLocked: vi.fn(async () => undefined), + markRemoteResolved: vi.fn(async () => undefined), + connectRemoteCouchDBWithSetting: vi.fn(async () => ({ db: database, close })), + }; + const setting = { ...DEFAULT_SETTINGS, remoteType: REMOTE_COUCHDB }; + const capability = COUCHDB_REMOTE_ADMINISTRATION_CAPABILITY; + + await expect( + capability.run(replicator as never, setting, { action: REMOTE_ADMINISTRATION_ACTIONS.LOCK }) + ).resolves.toEqual({ + status: REMOTE_ADMINISTRATION_RESULT_STATUSES.VERIFIED, + observation: { + kind: REMOTE_ADMINISTRATION_OBSERVATION_KINDS.MILESTONE, + locked: true, + accepted: true, + nodeId: "node-1", + }, + }); + + expect(replicator.markRemoteLocked).toHaveBeenCalledWith(setting, true, false); + expect(close).toHaveBeenCalledOnce(); + expect(rawDatabaseClose).not.toHaveBeenCalled(); + }); + + it("returns a typed CouchDB failure when the observed milestone does not satisfy the action", async () => { + const close = vi.fn(async () => undefined); + const replicator = { + nodeid: "node-1", + initializeDatabaseForReplication: vi.fn(async () => true), + isMobile: vi.fn(() => false), + markRemoteLocked: vi.fn(async () => undefined), + markRemoteResolved: vi.fn(async () => undefined), + connectRemoteCouchDBWithSetting: vi.fn(async () => ({ + db: { get: vi.fn(async () => ({ locked: false, accepted_nodes: ["node-1"] })) }, + close, + })), + }; + const capability = COUCHDB_REMOTE_ADMINISTRATION_CAPABILITY; + + const result = await capability.run( + replicator as never, + { ...DEFAULT_SETTINGS, remoteType: REMOTE_COUCHDB }, + { + action: REMOTE_ADMINISTRATION_ACTIONS.LOCK, + } + ); + + expect(result).toMatchObject({ + status: REMOTE_ADMINISTRATION_RESULT_STATUSES.VERIFICATION_FAILED, + reason: REMOTE_ADMINISTRATION_FAILURE_REASONS.POSTCONDITION_MISMATCH, + observation: { kind: REMOTE_ADMINISTRATION_OBSERVATION_KINDS.MILESTONE, locked: false }, + }); + expect(close).toHaveBeenCalledOnce(); + }); + + it("does not mutate when initialisation succeeds without publishing a local node identity", async () => { + const replicator = { + nodeid: "", + initializeDatabaseForReplication: vi.fn(async () => true), + isMobile: vi.fn(() => false), + markRemoteLocked: vi.fn(async () => undefined), + markRemoteResolved: vi.fn(async () => undefined), + connectRemoteCouchDBWithSetting: vi.fn(async () => "must not connect"), + }; + const capability = COUCHDB_REMOTE_ADMINISTRATION_CAPABILITY; + + await expect( + capability.run( + replicator as never, + { ...DEFAULT_SETTINGS, remoteType: REMOTE_COUCHDB }, + { action: REMOTE_ADMINISTRATION_ACTIONS.MARK_RESOLVED } + ) + ).resolves.toEqual({ + status: REMOTE_ADMINISTRATION_RESULT_STATUSES.VERIFICATION_FAILED, + reason: REMOTE_ADMINISTRATION_FAILURE_REASONS.LOCAL_IDENTITY_UNAVAILABLE, + }); + expect(replicator.markRemoteResolved).not.toHaveBeenCalled(); + expect(replicator.connectRemoteCouchDBWithSetting).not.toHaveBeenCalled(); + }); + + it("allows a CouchDB mutation exception to reject before verification", async () => { + const failure = new Error("write failed"); + const replicator = { + nodeid: "node-1", + initializeDatabaseForReplication: vi.fn(async () => true), + isMobile: vi.fn(() => false), + markRemoteLocked: vi.fn(async () => { + throw failure; + }), + markRemoteResolved: vi.fn(async () => undefined), + connectRemoteCouchDBWithSetting: vi.fn(), + }; + const capability = COUCHDB_REMOTE_ADMINISTRATION_CAPABILITY; + + await expect( + capability.run( + replicator as never, + { ...DEFAULT_SETTINGS, remoteType: REMOTE_COUCHDB }, + { + action: REMOTE_ADMINISTRATION_ACTIONS.UNLOCK, + } + ) + ).rejects.toBe(failure); + expect(replicator.connectRemoteCouchDBWithSetting).not.toHaveBeenCalled(); + }); + + it("mutates Object Storage and verifies its milestone postcondition", async () => { + const downloadJson = vi.fn(async () => ({ locked: false, accepted_nodes: ["node-1"] })); + const replicator = { + nodeid: "node-1", + initializeDatabaseForReplication: vi.fn(async () => true), + markRemoteLocked: vi.fn(async () => undefined), + markRemoteResolved: vi.fn(async () => undefined), + client: { downloadJson }, + }; + const setting = { ...DEFAULT_SETTINGS, remoteType: REMOTE_MINIO }; + const capability = OBJECT_STORAGE_REMOTE_ADMINISTRATION_CAPABILITY; + + await expect( + capability.run(replicator as never, setting, { action: REMOTE_ADMINISTRATION_ACTIONS.MARK_RESOLVED }) + ).resolves.toEqual({ + status: REMOTE_ADMINISTRATION_RESULT_STATUSES.VERIFIED, + observation: { + kind: REMOTE_ADMINISTRATION_OBSERVATION_KINDS.MILESTONE, + locked: false, + accepted: true, + nodeId: "node-1", + }, + }); + expect(replicator.markRemoteResolved).toHaveBeenCalledWith(setting); + expect(downloadJson).toHaveBeenCalledWith("_00000000-milestone.json"); + }); +}); diff --git a/src/common/replicatorConfigurationIdentity.ts b/src/common/replicatorConfigurationIdentity.ts new file mode 100644 index 00000000..80976066 --- /dev/null +++ b/src/common/replicatorConfigurationIdentity.ts @@ -0,0 +1,91 @@ +import type { RemoteDBSettings } from "@vrtmrz/livesync-commonlib/compat/common/types"; + +type EndpointProjection = readonly [kind: "url" | "invalid-url", value: string]; + +function projectEndpoint(value: string): EndpointProjection { + try { + const endpoint = new URL(value); + endpoint.hash = ""; + endpoint.searchParams.sort(); + while (endpoint.pathname.length > 1 && endpoint.pathname.endsWith("/")) { + endpoint.pathname = endpoint.pathname.slice(0, -1); + } + return ["url", endpoint.toString()]; + } catch { + return ["invalid-url", value]; + } +} + +function projectHeaders(value: string): readonly (readonly [name: string, value: string])[] { + const headers = new Map(); + for (const line of value.split("\n")) { + const [name, headerValue] = line.split(":", 2).map((part) => part.trim()); + if (name && headerValue) { + headers.set(name, headerValue); + } + } + return [...headers.entries()].sort(([leftName, leftValue], [rightName, rightValue]) => { + const nameOrder = leftName.localeCompare(rightName); + return nameOrder || leftValue.localeCompare(rightValue); + }); +} + +function projectRemoteSecurity(settings: RemoteDBSettings) { + return settings.encrypt + ? ([ + "encrypted", + settings.passphrase, + settings.useDynamicIterationCount, + settings.E2EEAlgorithm, + settings.permitEmptyPassphrase, + ] as const) + : (["plain"] as const); +} + +/** + * Project the effective CouchDB connection settings to a private comparison identity. + * The returned value can contain credentials and must not be logged, persisted, or displayed. + */ +export function getCouchDBReplicatorConfigurationIdentity(settings: RemoteDBSettings): string { + const authentication = settings.useJWT + ? ([ + "jwt", + settings.jwtAlgorithm, + settings.jwtKey, + settings.jwtKid, + settings.jwtSub, + settings.jwtExpDuration, + ] as const) + : (["basic", settings.couchDB_USER, settings.couchDB_PASSWORD] as const); + return JSON.stringify([ + "couchdb", + projectEndpoint(settings.couchDB_URI), + settings.couchDB_DBNAME, + authentication, + projectHeaders(settings.couchDB_CustomHeaders), + settings.useRequestAPI, + settings.disableRequestURI, + projectRemoteSecurity(settings), + settings.enableCompression, + ]); +} + +/** + * Project the effective Object Storage connection settings to a private comparison identity. + * The returned value can contain credentials and must not be logged, persisted, or displayed. + */ +export function getObjectStorageReplicatorConfigurationIdentity(settings: RemoteDBSettings): string { + return JSON.stringify([ + "s3", + projectEndpoint(settings.endpoint), + settings.bucket, + settings.bucketPrefix, + settings.region, + settings.accessKey, + settings.secretKey, + settings.forcePathStyle, + settings.useCustomRequestHandler, + projectHeaders(settings.bucketCustomHeaders), + projectRemoteSecurity(settings), + ]); +} diff --git a/src/common/replicatorConfigurationIdentity.unit.spec.ts b/src/common/replicatorConfigurationIdentity.unit.spec.ts new file mode 100644 index 00000000..719db130 --- /dev/null +++ b/src/common/replicatorConfigurationIdentity.unit.spec.ts @@ -0,0 +1,174 @@ +import { describe, expect, it } from "vitest"; +import type { ObsidianLiveSyncSettings } from "@vrtmrz/livesync-commonlib/compat/common/types"; +import { createNewVaultSettings } from "@vrtmrz/livesync-commonlib/settings"; +import { + getCouchDBReplicatorConfigurationIdentity, + getObjectStorageReplicatorConfigurationIdentity, +} from "./replicatorConfigurationIdentity"; + +describe("active Replicator configuration identity", () => { + function configuredSettings(overrides: Partial = {}): ObsidianLiveSyncSettings { + return Object.assign(createNewVaultSettings(), { + activeConfigurationId: "profile-a", + couchDB_URI: "https://couch.example.test/base", + couchDB_USER: "alice", + couchDB_PASSWORD: "secret-a", + couchDB_DBNAME: "vault", + couchDB_CustomHeaders: "X-Second: two\nX-First: one", + endpoint: "https://objects.example.test/base", + accessKey: "alice", + secretKey: "secret-a", + bucket: "vault", + bucketPrefix: "notes/", + region: "auto", + bucketCustomHeaders: "X-Second: two\nX-First: one", + encrypt: true, + passphrase: "encryption-a", + useDynamicIterationCount: false, + permitEmptyPassphrase: false, + enableCompression: false, + ...overrides, + }); + } + + it.each([ + ["couchDB_URI", "https://other.example.test/base"], + ["couchDB_DBNAME", "other-vault"], + ["couchDB_USER", "bob"], + ["couchDB_PASSWORD", "secret-b"], + ["couchDB_CustomHeaders", "X-First: changed"], + ["useRequestAPI", true], + ["disableRequestURI", true], + ["encrypt", false], + ["passphrase", "encryption-b"], + ["useDynamicIterationCount", true], + ["E2EEAlgorithm", ""], + ["permitEmptyPassphrase", true], + ["enableCompression", true], + ] satisfies Array<[keyof ObsidianLiveSyncSettings, ObsidianLiveSyncSettings[keyof ObsidianLiveSyncSettings]]>)( + "detects a CouchDB %s change", + (key, value) => { + const settings = configuredSettings(); + expect(getCouchDBReplicatorConfigurationIdentity({ ...settings, [key]: value })).not.toBe( + getCouchDBReplicatorConfigurationIdentity(settings) + ); + } + ); + + it("ignores persisted central profile identity when the effective connection settings match", () => { + const settings = configuredSettings({ activeConfigurationId: "profile-a" }); + const otherProfile = { ...settings, activeConfigurationId: "profile-b" }; + + expect(getCouchDBReplicatorConfigurationIdentity(otherProfile)).toBe( + getCouchDBReplicatorConfigurationIdentity(settings) + ); + expect(getObjectStorageReplicatorConfigurationIdentity(otherProfile)).toBe( + getObjectStorageReplicatorConfigurationIdentity(settings) + ); + }); + + it("projects only the active CouchDB authentication mode", () => { + const basic = configuredSettings({ useJWT: false, jwtKey: "inactive-a" }); + expect(getCouchDBReplicatorConfigurationIdentity({ ...basic, jwtKey: "inactive-b" })).toBe( + getCouchDBReplicatorConfigurationIdentity(basic) + ); + + const jwt = configuredSettings({ + useJWT: true, + jwtAlgorithm: "HS256", + jwtKey: "jwt-a", + jwtKid: "kid-a", + jwtSub: "subject-a", + jwtExpDuration: 5, + }); + expect(getCouchDBReplicatorConfigurationIdentity({ ...jwt, couchDB_PASSWORD: "inactive" })).toBe( + getCouchDBReplicatorConfigurationIdentity(jwt) + ); + expect(getCouchDBReplicatorConfigurationIdentity({ ...jwt, jwtKey: "jwt-b" })).not.toBe( + getCouchDBReplicatorConfigurationIdentity(jwt) + ); + }); + + it.each([ + ["endpoint", "https://other.example.test/base"], + ["bucket", "other-vault"], + ["bucketPrefix", "archive/"], + ["region", "eu-west-1"], + ["accessKey", "bob"], + ["secretKey", "secret-b"], + ["forcePathStyle", false], + ["useCustomRequestHandler", true], + ["bucketCustomHeaders", "X-First: changed"], + ["encrypt", false], + ["passphrase", "encryption-b"], + ["useDynamicIterationCount", true], + ["E2EEAlgorithm", ""], + ["permitEmptyPassphrase", true], + ] satisfies Array<[keyof ObsidianLiveSyncSettings, ObsidianLiveSyncSettings[keyof ObsidianLiveSyncSettings]]>)( + "detects an Object Storage %s change", + (key, value) => { + const settings = configuredSettings(); + expect(getObjectStorageReplicatorConfigurationIdentity({ ...settings, [key]: value })).not.toBe( + getObjectStorageReplicatorConfigurationIdentity(settings) + ); + } + ); + + it("normalises endpoint and header representation without using the setup URI grammar", () => { + const settings = configuredSettings(); + const couchIdentity = getCouchDBReplicatorConfigurationIdentity(settings); + const objectStorageIdentity = getObjectStorageReplicatorConfigurationIdentity(settings); + + expect( + getCouchDBReplicatorConfigurationIdentity({ + ...settings, + couchDB_URI: "https://couch.example.test:443/base/", + couchDB_CustomHeaders: "X-First: one\nX-Second: two", + }) + ).toBe(couchIdentity); + expect( + getObjectStorageReplicatorConfigurationIdentity({ + ...settings, + endpoint: "https://objects.example.test:443/base/", + bucketCustomHeaders: "X-First: one\nX-Second: two", + }) + ).toBe(objectStorageIdentity); + }); + + it("ignores inactive remote-security credentials", () => { + const settings = configuredSettings({ encrypt: false, passphrase: "inactive-a" }); + + expect( + getCouchDBReplicatorConfigurationIdentity({ + ...settings, + passphrase: "inactive-b", + useDynamicIterationCount: !settings.useDynamicIterationCount, + E2EEAlgorithm: "", + permitEmptyPassphrase: !settings.permitEmptyPassphrase, + }) + ).toBe(getCouchDBReplicatorConfigurationIdentity(settings)); + expect( + getObjectStorageReplicatorConfigurationIdentity({ + ...settings, + passphrase: "inactive-b", + useDynamicIterationCount: !settings.useDynamicIterationCount, + E2EEAlgorithm: "", + permitEmptyPassphrase: !settings.permitEmptyPassphrase, + }) + ).toBe(getObjectStorageReplicatorConfigurationIdentity(settings)); + }); + + it("keeps malformed endpoints deterministic and scoped", () => { + const settings = configuredSettings({ couchDB_URI: "not a URL", endpoint: "also not a URL" }); + + expect(() => getCouchDBReplicatorConfigurationIdentity(settings)).not.toThrow(); + expect(() => getObjectStorageReplicatorConfigurationIdentity(settings)).not.toThrow(); + expect( + getCouchDBReplicatorConfigurationIdentity({ ...settings, couchDB_URI: "different invalid URL" }) + ).not.toBe(getCouchDBReplicatorConfigurationIdentity(settings)); + const unrelatedPluginChange = { ...settings, displayLanguage: "ja" }; + expect(getObjectStorageReplicatorConfigurationIdentity(unrelatedPluginChange)).toBe( + getObjectStorageReplicatorConfigurationIdentity(settings) + ); + }); +}); diff --git a/src/common/replicatorProviders.ts b/src/common/replicatorProviders.ts new file mode 100644 index 00000000..feeb991f --- /dev/null +++ b/src/common/replicatorProviders.ts @@ -0,0 +1,103 @@ +import { REMOTE_COUCHDB, REMOTE_MINIO } from "@vrtmrz/livesync-commonlib/compat/common/types"; +import { + CAPABILITY_NOT_APPLICABLE, + CENTRAL_REMOTE_REPLICATION_READINESS, + REMOTE_RESOURCE_KINDS, + REPLACE_SAME_KIND_REPLICATOR, + defineReplicatorProviderDefinitions, + supportedOpenReplicationContinuous, + supportedOpenReplicationOneShot, + supportedOpenReplicationUnattended, + supportedStopActiveTransfer, + supportedCapability, + type ReplicatorProviderDefinitionMap, +} from "@vrtmrz/livesync-commonlib/replication"; +import { + LiveSyncCouchDBReplicator, + type LiveSyncCouchDBReplicatorEnv, +} from "@vrtmrz/livesync-commonlib/compat/replication/couchdb/LiveSyncReplicator"; +import { LiveSyncJournalReplicator } from "@vrtmrz/livesync-commonlib/compat/replication/journal/LiveSyncJournalReplicator"; +import type { LiveSyncJournalReplicatorEnv } from "@vrtmrz/livesync-commonlib/compat/replication/journal/LiveSyncJournalReplicatorEnv"; +import { + getCouchDBReplicatorConfigurationIdentity, + getObjectStorageReplicatorConfigurationIdentity, +} from "./replicatorConfigurationIdentity"; +import { + createCouchDBConnectionProbeFactory, + createCouchDBPreferredTweakProbeFactory, + createCouchDBSecuritySeedResourceFactory, + createCouchDBSynchronisationInformationResourceFactory, + createObjectStorageConnectionProbeFactory, + createObjectStoragePreferredTweakProbeFactory, + createObjectStorageSecuritySeedResourceFactory, +} from "./replicatorResources"; +import { + COUCHDB_REMOTE_ADMINISTRATION_CAPABILITY, + OBJECT_STORAGE_REMOTE_ADMINISTRATION_CAPABILITY, +} from "./replicatorAdministration"; + +export type CentralReplicatorProviderHost = LiveSyncCouchDBReplicatorEnv & LiveSyncJournalReplicatorEnv; + +/** Build the complete central-remote provider policy for one LiveSync host. */ +export function createCentralReplicatorProviderDefinitions( + host: CentralReplicatorProviderHost +): ReplicatorProviderDefinitionMap { + return defineReplicatorProviderDefinitions([REMOTE_COUCHDB, REMOTE_MINIO] as const, { + [REMOTE_COUCHDB]: { + kind: REMOTE_COUCHDB, + diagnosticName: "CouchDB", + readiness: CENTRAL_REMOTE_REPLICATION_READINESS, + isConfigured: (settings) => + settings.remoteType === REMOTE_COUCHDB && + !!settings.couchDB_URI?.trim() && + !!settings.couchDB_DBNAME?.trim(), + configurationIdentity: getCouchDBReplicatorConfigurationIdentity, + sameKindReconciliation: REPLACE_SAME_KIND_REPLICATOR, + create: () => Promise.resolve(new LiveSyncCouchDBReplicator(host)), + remoteResources: { + [REMOTE_RESOURCE_KINDS.CONNECTION]: supportedCapability(createCouchDBConnectionProbeFactory(host)), + [REMOTE_RESOURCE_KINDS.PREFERRED_TWEAK]: supportedCapability( + createCouchDBPreferredTweakProbeFactory(host) + ), + [REMOTE_RESOURCE_KINDS.SECURITY_SEED]: supportedCapability( + createCouchDBSecuritySeedResourceFactory(host) + ), + [REMOTE_RESOURCE_KINDS.SYNCHRONISATION_INFORMATION]: supportedCapability( + createCouchDBSynchronisationInformationResourceFactory(host) + ), + }, + remoteAdministration: COUCHDB_REMOTE_ADMINISTRATION_CAPABILITY, + userInitiatedOneShot: supportedOpenReplicationOneShot(), + unattendedOneShot: supportedOpenReplicationUnattended(), + continuous: supportedOpenReplicationContinuous(), + stopActiveTransfer: supportedStopActiveTransfer(), + }, + [REMOTE_MINIO]: { + kind: REMOTE_MINIO, + diagnosticName: "Object Storage", + readiness: CENTRAL_REMOTE_REPLICATION_READINESS, + isConfigured: (settings) => + settings.remoteType === REMOTE_MINIO && !!settings.endpoint?.trim() && !!settings.bucket?.trim(), + configurationIdentity: getObjectStorageReplicatorConfigurationIdentity, + sameKindReconciliation: REPLACE_SAME_KIND_REPLICATOR, + create: () => Promise.resolve(new LiveSyncJournalReplicator(host)), + remoteResources: { + [REMOTE_RESOURCE_KINDS.CONNECTION]: supportedCapability( + createObjectStorageConnectionProbeFactory(host) + ), + [REMOTE_RESOURCE_KINDS.PREFERRED_TWEAK]: supportedCapability( + createObjectStoragePreferredTweakProbeFactory(host) + ), + [REMOTE_RESOURCE_KINDS.SECURITY_SEED]: supportedCapability( + createObjectStorageSecuritySeedResourceFactory(host) + ), + [REMOTE_RESOURCE_KINDS.SYNCHRONISATION_INFORMATION]: CAPABILITY_NOT_APPLICABLE, + }, + remoteAdministration: OBJECT_STORAGE_REMOTE_ADMINISTRATION_CAPABILITY, + userInitiatedOneShot: supportedOpenReplicationOneShot(), + unattendedOneShot: supportedOpenReplicationUnattended(), + continuous: CAPABILITY_NOT_APPLICABLE, + stopActiveTransfer: supportedStopActiveTransfer(), + }, + }); +} diff --git a/src/common/replicatorProviders.unit.spec.ts b/src/common/replicatorProviders.unit.spec.ts new file mode 100644 index 00000000..a6e87c24 --- /dev/null +++ b/src/common/replicatorProviders.unit.spec.ts @@ -0,0 +1,99 @@ +import { describe, expect, it, vi } from "vitest"; +import { REMOTE_COUCHDB, REMOTE_MINIO } from "@vrtmrz/livesync-commonlib/compat/common/types"; +import { createNewVaultSettings } from "@vrtmrz/livesync-commonlib/settings"; +import { + CAPABILITY_SUPPORT_KINDS, + REMOTE_RESOURCE_KINDS, + REPLACE_SAME_KIND_REPLICATOR, +} from "@vrtmrz/livesync-commonlib/replication"; + +const constructorMocks = vi.hoisted(() => ({ + couchDB: vi.fn(), + objectStorage: vi.fn(), +})); + +vi.mock("@vrtmrz/livesync-commonlib/compat/replication/couchdb/LiveSyncReplicator", () => ({ + LiveSyncCouchDBReplicator: class { + constructor(host: unknown) { + constructorMocks.couchDB(host); + } + }, +})); +vi.mock("@vrtmrz/livesync-commonlib/compat/replication/journal/LiveSyncJournalReplicator", () => ({ + LiveSyncJournalReplicator: class { + constructor(host: unknown) { + constructorMocks.objectStorage(host); + } + }, +})); + +import { createCentralReplicatorProviderDefinitions } from "./replicatorProviders"; + +describe("central Replicator provider definitions", () => { + it("composes CouchDB and Object Storage policies outside LiveSyncBaseCore", async () => { + const host = {} as Parameters[0]; + const definitions = createCentralReplicatorProviderDefinitions(host); + const couchDB = definitions.get(REMOTE_COUCHDB)!; + const objectStorage = definitions.get(REMOTE_MINIO)!; + + expect([...definitions.keys()]).toEqual([REMOTE_COUCHDB, REMOTE_MINIO]); + expect(couchDB.sameKindReconciliation).toBe(REPLACE_SAME_KIND_REPLICATOR); + expect(objectStorage.sameKindReconciliation).toBe(REPLACE_SAME_KIND_REPLICATOR); + + expect( + couchDB.isConfigured( + Object.assign(createNewVaultSettings(), { + remoteType: REMOTE_COUCHDB, + couchDB_URI: "https://couch.example.test", + couchDB_DBNAME: "vault", + }) + ) + ).toBe(true); + expect( + objectStorage.isConfigured( + Object.assign(createNewVaultSettings(), { + remoteType: REMOTE_MINIO, + endpoint: "https://objects.example.test", + bucket: "vault", + }) + ) + ).toBe(true); + + await couchDB.create(createNewVaultSettings()); + await objectStorage.create(createNewVaultSettings()); + expect(constructorMocks.couchDB).toHaveBeenCalledWith(host); + expect(constructorMocks.objectStorage).toHaveBeenCalledWith(host); + }); + + it("rejects incomplete and wrong-kind settings before construction", () => { + const definitions = createCentralReplicatorProviderDefinitions({} as never); + const couchDB = definitions.get(REMOTE_COUCHDB)!; + const objectStorage = definitions.get(REMOTE_MINIO)!; + + expect(couchDB.isConfigured(Object.assign(createNewVaultSettings(), { remoteType: REMOTE_MINIO }))).toBe(false); + expect( + objectStorage.isConfigured(Object.assign(createNewVaultSettings(), { remoteType: REMOTE_COUCHDB })) + ).toBe(false); + }); + + it("declares an exhaustive resource and administration catalogue for both central providers", () => { + const definitions = createCentralReplicatorProviderDefinitions({} as never); + const couchResources = definitions.get(REMOTE_COUCHDB)?.remoteResources; + const objectResources = definitions.get(REMOTE_MINIO)?.remoteResources; + + expect(Object.keys(couchResources ?? {}).sort()).toEqual(Object.values(REMOTE_RESOURCE_KINDS).sort()); + expect(Object.keys(objectResources ?? {}).sort()).toEqual(Object.values(REMOTE_RESOURCE_KINDS).sort()); + expect(couchResources?.[REMOTE_RESOURCE_KINDS.CONNECTION].kind).toBe(CAPABILITY_SUPPORT_KINDS.SUPPORTED); + expect(couchResources?.[REMOTE_RESOURCE_KINDS.PREFERRED_TWEAK].kind).toBe(CAPABILITY_SUPPORT_KINDS.SUPPORTED); + expect(couchResources?.[REMOTE_RESOURCE_KINDS.SECURITY_SEED].kind).toBe(CAPABILITY_SUPPORT_KINDS.SUPPORTED); + expect(couchResources?.[REMOTE_RESOURCE_KINDS.SYNCHRONISATION_INFORMATION].kind).toBe( + CAPABILITY_SUPPORT_KINDS.SUPPORTED + ); + expect(objectResources?.[REMOTE_RESOURCE_KINDS.SECURITY_SEED].kind).toBe(CAPABILITY_SUPPORT_KINDS.SUPPORTED); + expect(objectResources?.[REMOTE_RESOURCE_KINDS.SYNCHRONISATION_INFORMATION].kind).toBe( + CAPABILITY_SUPPORT_KINDS.NOT_APPLICABLE + ); + expect(definitions.get(REMOTE_COUCHDB)?.remoteAdministration.kind).toBe(CAPABILITY_SUPPORT_KINDS.SUPPORTED); + expect(definitions.get(REMOTE_MINIO)?.remoteAdministration.kind).toBe(CAPABILITY_SUPPORT_KINDS.SUPPORTED); + }); +}); diff --git a/src/common/replicatorResources.unit.spec.ts b/src/common/replicatorResources.unit.spec.ts new file mode 100644 index 00000000..6d62ac1b --- /dev/null +++ b/src/common/replicatorResources.unit.spec.ts @@ -0,0 +1,271 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { REMOTE_COUCHDB, REMOTE_MINIO } from "@vrtmrz/livesync-commonlib/compat/common/types"; +import type { ObsidianLiveSyncSettings } from "@vrtmrz/livesync-commonlib/compat/common/types"; +import { createNewVaultSettings } from "@vrtmrz/livesync-commonlib/settings"; + +const mocks = vi.hoisted(() => ({ + couchDB: [] as Array<{ + host: unknown; + isMobile: ReturnType; + connectRemoteCouchDBWithSetting: ReturnType; + getRemoteStatus: ReturnType; + getRemotePreferredTweakValues: ReturnType; + getReplicationPBKDF2Salt: ReturnType; + closeReplication: ReturnType; + }>, + objectStorage: [] as Array<{ + host: unknown; + tryConnectRemote: ReturnType; + getRemoteStatus: ReturnType; + getRemotePreferredTweakValues: ReturnType; + getReplicationPBKDF2Salt: ReturnType; + closeReplication: ReturnType; + }>, + checkSyncInfo: vi.fn(async () => true), +})); + +vi.mock("@vrtmrz/livesync-commonlib/compat/pouchdb/negotiation", () => ({ + checkSyncInfo: mocks.checkSyncInfo, +})); + +vi.mock("@vrtmrz/livesync-commonlib/compat/replication/couchdb/LiveSyncReplicator", () => ({ + LiveSyncCouchDBReplicator: class { + host: unknown; + isMobile = vi.fn(() => false); + connectRemoteCouchDBWithSetting = vi.fn(); + getRemoteStatus = vi.fn(); + getRemotePreferredTweakValues = vi.fn(); + getReplicationPBKDF2Salt = vi.fn(); + closeReplication = vi.fn(); + + constructor(host: unknown) { + this.host = host; + mocks.couchDB.push(this); + } + }, +})); + +vi.mock("@vrtmrz/livesync-commonlib/compat/replication/journal/LiveSyncJournalReplicator", () => ({ + LiveSyncJournalReplicator: class { + host: unknown; + tryConnectRemote = vi.fn(); + getRemoteStatus = vi.fn(); + getRemotePreferredTweakValues = vi.fn(); + getReplicationPBKDF2Salt = vi.fn(); + closeReplication = vi.fn(); + + constructor(host: unknown) { + this.host = host; + mocks.objectStorage.push(this); + } + }, +})); + +import { + createCouchDBConnectionProbeFactory, + createCouchDBPreferredTweakProbeFactory, + createCouchDBSecuritySeedResourceFactory, + createCouchDBSynchronisationInformationResourceFactory, + createObjectStorageConnectionProbeFactory, + createObjectStoragePreferredTweakProbeFactory, + createObjectStorageSecuritySeedResourceFactory, +} from "./replicatorResources"; + +function createSettings(overrides: Partial = {}): ObsidianLiveSyncSettings { + return Object.assign(createNewVaultSettings(), { + remoteType: REMOTE_COUCHDB, + couchDB_URI: "https://couch.example.test", + couchDB_DBNAME: "vault", + endpoint: "https://objects.example.test", + bucket: "vault", + ...overrides, + }); +} + +describe("replicator probe factories", () => { + beforeEach(() => { + mocks.couchDB.length = 0; + mocks.objectStorage.length = 0; + mocks.checkSyncInfo.mockReset().mockResolvedValue(true); + }); + + it("binds a CouchDB connection probe to a shallow settings snapshot and closes its owned connection", async () => { + const host = { name: "host" }; + const source = createSettings(); + const snapshot = { ...source }; + const probe = await createCouchDBConnectionProbeFactory(host as never)(source); + const replicator = mocks.couchDB[0]; + const close = vi.fn(async () => undefined); + const databaseClose = vi.fn(async () => undefined); + replicator.isMobile.mockReturnValue(true); + replicator.connectRemoteCouchDBWithSetting.mockResolvedValue({ + db: { close: databaseClose }, + info: {}, + close, + }); + + source.couchDB_URI = "https://changed.example.test"; + expect(await probe.check({ createIfMissing: false, showResult: true })).toEqual({ ok: true }); + + expect(replicator.connectRemoteCouchDBWithSetting).toHaveBeenCalledWith(snapshot, true, false, false); + expect(replicator.connectRemoteCouchDBWithSetting.mock.calls[0][0]).not.toBe(source); + expect(close).toHaveBeenCalledOnce(); + expect(databaseClose).not.toHaveBeenCalled(); + }); + + it("maps a CouchDB connection error string and delegates status to the same snapshot", async () => { + const source = createSettings(); + const snapshot = { ...source }; + const probe = await createCouchDBConnectionProbeFactory({} as never)(source); + const replicator = mocks.couchDB[0]; + replicator.connectRemoteCouchDBWithSetting.mockResolvedValue("connection failed"); + + expect(await probe.check()).toEqual({ ok: false, reason: "connection failed" }); + + const status = { estimatedSize: 12 }; + replicator.getRemoteStatus.mockResolvedValue(status); + source.couchDB_DBNAME = "changed-vault"; + expect(await probe.getStatus()).toBe(status); + expect(replicator.getRemoteStatus).toHaveBeenCalledWith(snapshot); + }); + + it("creates an unpublished Object Storage replicator for each probe and normalises connection results", async () => { + const host = { name: "host" }; + const source = createSettings({ remoteType: REMOTE_MINIO }); + const snapshot = { ...source }; + const factory = createObjectStorageConnectionProbeFactory(host as never); + const firstProbe = await factory(source); + const secondProbe = await factory(source); + expect(mocks.objectStorage).toHaveLength(2); + + const firstReplicator = mocks.objectStorage[0]; + firstReplicator.tryConnectRemote.mockResolvedValue(true); + source.endpoint = "https://changed.example.test"; + expect(await firstProbe.check()).toEqual({ ok: true }); + expect(firstReplicator.tryConnectRemote).toHaveBeenCalledWith(snapshot, false); + + const secondReplicator = mocks.objectStorage[1]; + secondReplicator.tryConnectRemote.mockResolvedValue(false); + expect(await secondProbe.check({ showResult: true })).toEqual({ ok: false }); + expect(secondReplicator.tryConnectRemote).toHaveBeenCalledWith(snapshot, true); + + const error = new Error("storage offline"); + secondReplicator.tryConnectRemote.mockRejectedValue(error); + expect(await secondProbe.check()).toEqual({ ok: false, reason: error }); + }); + + it("delegates Object Storage status and preferred-tweak reads to the trial snapshot", async () => { + const source = createSettings({ remoteType: REMOTE_MINIO }); + const snapshot = { ...source }; + const connectionProbe = await createObjectStorageConnectionProbeFactory({} as never)(source); + const preferredProbe = await createObjectStoragePreferredTweakProbeFactory({} as never)(source); + const connectionReplicator = mocks.objectStorage[0]; + const preferredReplicator = mocks.objectStorage[1]; + const status = { estimatedSize: 42 }; + const preferred = { status: "unsupported" } as const; + connectionReplicator.getRemoteStatus.mockResolvedValue(status); + preferredReplicator.getRemotePreferredTweakValues.mockResolvedValue(preferred); + + source.bucket = "changed-vault"; + expect(await connectionProbe.getStatus()).toBe(status); + expect(await preferredProbe.read()).toBe(preferred); + expect(connectionReplicator.getRemoteStatus).toHaveBeenCalledWith(snapshot); + expect(preferredReplicator.getRemotePreferredTweakValues).toHaveBeenCalledWith(snapshot); + }); + + it("shares one successful asynchronous disposal promise for every probe kind", async () => { + const couchProbe = await createCouchDBPreferredTweakProbeFactory({} as never)(createSettings()); + const objectProbe = await createObjectStoragePreferredTweakProbeFactory({} as never)( + createSettings({ remoteType: REMOTE_MINIO }) + ); + const couchReplicator = mocks.couchDB[0]; + const objectReplicator = mocks.objectStorage[0]; + + const couchDisposal = couchProbe.dispose(); + expect(couchProbe.dispose()).toBe(couchDisposal); + const objectDisposal = objectProbe.dispose(); + expect(objectProbe.dispose()).toBe(objectDisposal); + await Promise.all([couchDisposal, objectDisposal]); + expect(couchReplicator.closeReplication).toHaveBeenCalledOnce(); + expect(objectReplicator.closeReplication).toHaveBeenCalledOnce(); + }); + + it("shares a rejected disposal promise and never retries closeReplication", async () => { + const probe = await createObjectStorageConnectionProbeFactory({} as never)( + createSettings({ remoteType: REMOTE_MINIO }) + ); + const replicator = mocks.objectStorage[0]; + const failure = new Error("close failed"); + replicator.closeReplication.mockImplementation(() => { + throw failure; + }); + + const disposal = probe.dispose(); + expect(probe.dispose()).toBe(disposal); + await expect(disposal).rejects.toBe(failure); + expect(replicator.closeReplication).toHaveBeenCalledOnce(); + }); + + it("reads the Security Seed from a settings snapshot and disposes its private Replicator", async () => { + const couchSettings = createSettings(); + const couchSnapshot = { ...couchSettings }; + const objectSettings = createSettings({ remoteType: REMOTE_MINIO }); + const objectSnapshot = { ...objectSettings }; + const couchResource = await createCouchDBSecuritySeedResourceFactory({} as never)(couchSettings); + const objectResource = await createObjectStorageSecuritySeedResourceFactory({} as never)(objectSettings); + const couchReplicator = mocks.couchDB[0]; + const objectReplicator = mocks.objectStorage[0]; + const couchSeed = new Uint8Array([1]); + const objectSeed = new Uint8Array([2]); + couchReplicator.getReplicationPBKDF2Salt.mockResolvedValue(couchSeed); + objectReplicator.getReplicationPBKDF2Salt.mockResolvedValue(objectSeed); + + couchSettings.couchDB_URI = "https://changed.example.test"; + objectSettings.endpoint = "https://changed.example.test"; + await expect(couchResource.read()).resolves.toBe(couchSeed); + await expect(objectResource.read()).resolves.toBe(objectSeed); + expect(couchReplicator.getReplicationPBKDF2Salt).toHaveBeenCalledWith(couchSnapshot); + expect(objectReplicator.getReplicationPBKDF2Salt).toHaveBeenCalledWith(objectSnapshot); + + await Promise.all([couchResource.dispose(), objectResource.dispose()]); + expect(couchReplicator.closeReplication).toHaveBeenCalledOnce(); + expect(objectReplicator.closeReplication).toHaveBeenCalledOnce(); + }); + + it("checks synchronisation information through an owned connection and disposes the private Replicator", async () => { + const settings = createSettings(); + const snapshot = { ...settings }; + const resource = await createCouchDBSynchronisationInformationResourceFactory({} as never)(settings); + const replicator = mocks.couchDB[0]; + const database = { close: vi.fn() }; + const close = vi.fn(async () => undefined); + replicator.connectRemoteCouchDBWithSetting.mockResolvedValue({ db: database, close }); + + settings.couchDB_DBNAME = "changed-vault"; + await expect(resource.check()).resolves.toBe(true); + expect(replicator.connectRemoteCouchDBWithSetting).toHaveBeenCalledWith(snapshot, false, true); + expect(mocks.checkSyncInfo).toHaveBeenCalledWith(database); + expect(close).toHaveBeenCalledOnce(); + expect(database.close).not.toHaveBeenCalled(); + + await resource.dispose(); + expect(replicator.closeReplication).toHaveBeenCalledOnce(); + }); + + it("closes the owned connection when synchronisation-information verification rejects", async () => { + const resource = await createCouchDBSynchronisationInformationResourceFactory({} as never)(createSettings()); + const replicator = mocks.couchDB[0]; + const database = { close: vi.fn() }; + const close = vi.fn(async () => undefined); + const failure = new Error("verification failed"); + replicator.connectRemoteCouchDBWithSetting.mockResolvedValue({ db: database, close }); + mocks.checkSyncInfo.mockRejectedValue(failure); + + await expect(resource.check()).rejects.toBe(failure); + expect(close).toHaveBeenCalledOnce(); + expect(database.close).not.toHaveBeenCalled(); + + await resource.dispose(); + expect(replicator.closeReplication).toHaveBeenCalledOnce(); + }); +}); diff --git a/src/common/replicatorResources/connection.ts b/src/common/replicatorResources/connection.ts new file mode 100644 index 00000000..2f20a7ec --- /dev/null +++ b/src/common/replicatorResources/connection.ts @@ -0,0 +1,77 @@ +import type { RemoteDBSettings } from "@vrtmrz/livesync-commonlib/compat/common/types"; +import type { + ConnectionProbeFactory, + RemoteConnectionProbe, + RemoteConnectionProbeOptions, +} from "@vrtmrz/livesync-commonlib/replication"; +import { + LiveSyncCouchDBReplicator, + type LiveSyncCouchDBReplicatorEnv, +} from "@vrtmrz/livesync-commonlib/compat/replication/couchdb/LiveSyncReplicator"; +import { LiveSyncJournalReplicator } from "@vrtmrz/livesync-commonlib/compat/replication/journal/LiveSyncJournalReplicator"; +import type { LiveSyncJournalReplicatorEnv } from "@vrtmrz/livesync-commonlib/compat/replication/journal/LiveSyncJournalReplicatorEnv"; +import { createReplicatorDisposer, snapshotRemoteSettings } from "./shared"; + +export type ConnectionResourceHost = LiveSyncCouchDBReplicatorEnv & LiveSyncJournalReplicatorEnv; + +function createCouchDBConnectionProbe( + replicator: LiveSyncCouchDBReplicator, + snapshot: RemoteDBSettings +): RemoteConnectionProbe { + const dispose = createReplicatorDisposer(replicator); + return { + check: async (options: RemoteConnectionProbeOptions = {}) => { + const connection = await replicator.connectRemoteCouchDBWithSetting( + snapshot, + replicator.isMobile(), + options.createIfMissing ?? true, + false + ); + if (typeof connection === "string") { + return { ok: false, reason: connection }; + } + try { + return { ok: true }; + } finally { + await connection.close(); + } + }, + getStatus: () => replicator.getRemoteStatus(snapshot), + dispose, + }; +} + +function createObjectStorageConnectionProbe( + replicator: LiveSyncJournalReplicator, + snapshot: RemoteDBSettings +): RemoteConnectionProbe { + const dispose = createReplicatorDisposer(replicator); + return { + check: async (options: RemoteConnectionProbeOptions = {}) => { + try { + const connected = await replicator.tryConnectRemote(snapshot, options.showResult ?? false); + return connected ? { ok: true } : { ok: false }; + } catch (error) { + return { ok: false, reason: error }; + } + }, + getStatus: () => replicator.getRemoteStatus(snapshot), + dispose, + }; +} + +/** Build an unpublished CouchDB connection resource for one host. */ +export function createCouchDBConnectionProbeFactory(host: ConnectionResourceHost): ConnectionProbeFactory { + return (setting) => { + const snapshot = snapshotRemoteSettings(setting); + return Promise.resolve(createCouchDBConnectionProbe(new LiveSyncCouchDBReplicator(host), snapshot)); + }; +} + +/** Build an unpublished Object Storage connection resource for one host. */ +export function createObjectStorageConnectionProbeFactory(host: ConnectionResourceHost): ConnectionProbeFactory { + return (setting) => { + const snapshot = snapshotRemoteSettings(setting); + return Promise.resolve(createObjectStorageConnectionProbe(new LiveSyncJournalReplicator(host), snapshot)); + }; +} diff --git a/src/common/replicatorResources/index.ts b/src/common/replicatorResources/index.ts new file mode 100644 index 00000000..9888e85c --- /dev/null +++ b/src/common/replicatorResources/index.ts @@ -0,0 +1,16 @@ +export { + createCouchDBConnectionProbeFactory, + createObjectStorageConnectionProbeFactory, + type ConnectionResourceHost, +} from "./connection"; +export { + createCouchDBPreferredTweakProbeFactory, + createObjectStoragePreferredTweakProbeFactory, + type PreferredTweakResourceHost, +} from "./preferredTweak"; +export { + createCouchDBSecuritySeedResourceFactory, + createObjectStorageSecuritySeedResourceFactory, + type SecuritySeedResourceHost, +} from "./securitySeed"; +export { createCouchDBSynchronisationInformationResourceFactory } from "./synchronisationInformation"; diff --git a/src/common/replicatorResources/preferredTweak.ts b/src/common/replicatorResources/preferredTweak.ts new file mode 100644 index 00000000..b2c66415 --- /dev/null +++ b/src/common/replicatorResources/preferredTweak.ts @@ -0,0 +1,43 @@ +import type { RemoteDBSettings, RemotePreferredTweakResult } from "@vrtmrz/livesync-commonlib/compat/common/types"; +import type { PreferredTweakProbe, PreferredTweakProbeFactory } from "@vrtmrz/livesync-commonlib/replication"; +import { + LiveSyncCouchDBReplicator, + type LiveSyncCouchDBReplicatorEnv, +} from "@vrtmrz/livesync-commonlib/compat/replication/couchdb/LiveSyncReplicator"; +import { LiveSyncJournalReplicator } from "@vrtmrz/livesync-commonlib/compat/replication/journal/LiveSyncJournalReplicator"; +import type { LiveSyncJournalReplicatorEnv } from "@vrtmrz/livesync-commonlib/compat/replication/journal/LiveSyncJournalReplicatorEnv"; +import { createReplicatorDisposer, snapshotRemoteSettings, type ResourceReplicator } from "./shared"; + +export type PreferredTweakResourceHost = LiveSyncCouchDBReplicatorEnv & LiveSyncJournalReplicatorEnv; + +interface PreferredTweakReplicator extends ResourceReplicator { + getRemotePreferredTweakValues(setting: RemoteDBSettings): Promise; +} + +function createPreferredTweakProbe( + replicator: PreferredTweakReplicator, + snapshot: RemoteDBSettings +): PreferredTweakProbe { + return { + read: () => replicator.getRemotePreferredTweakValues(snapshot), + dispose: createReplicatorDisposer(replicator), + }; +} + +/** Build an unpublished CouchDB preferred-tweak resource for one host. */ +export function createCouchDBPreferredTweakProbeFactory(host: PreferredTweakResourceHost): PreferredTweakProbeFactory { + return (setting) => { + const snapshot = snapshotRemoteSettings(setting); + return Promise.resolve(createPreferredTweakProbe(new LiveSyncCouchDBReplicator(host), snapshot)); + }; +} + +/** Build an unpublished Object Storage preferred-tweak resource for one host. */ +export function createObjectStoragePreferredTweakProbeFactory( + host: PreferredTweakResourceHost +): PreferredTweakProbeFactory { + return (setting) => { + const snapshot = snapshotRemoteSettings(setting); + return Promise.resolve(createPreferredTweakProbe(new LiveSyncJournalReplicator(host), snapshot)); + }; +} diff --git a/src/common/replicatorResources/securitySeed.ts b/src/common/replicatorResources/securitySeed.ts new file mode 100644 index 00000000..1aab8b6b --- /dev/null +++ b/src/common/replicatorResources/securitySeed.ts @@ -0,0 +1,35 @@ +import type { SecuritySeedResourceFactory } from "@vrtmrz/livesync-commonlib/replication"; +import { + LiveSyncCouchDBReplicator, + type LiveSyncCouchDBReplicatorEnv, +} from "@vrtmrz/livesync-commonlib/compat/replication/couchdb/LiveSyncReplicator"; +import { LiveSyncJournalReplicator } from "@vrtmrz/livesync-commonlib/compat/replication/journal/LiveSyncJournalReplicator"; +import type { LiveSyncJournalReplicatorEnv } from "@vrtmrz/livesync-commonlib/compat/replication/journal/LiveSyncJournalReplicatorEnv"; +import { createReplicatorDisposer, snapshotRemoteSettings } from "./shared"; + +export type SecuritySeedResourceHost = LiveSyncCouchDBReplicatorEnv & LiveSyncJournalReplicatorEnv; + +function createSecuritySeedResourceFactory( + createReplicator: () => LiveSyncCouchDBReplicator | LiveSyncJournalReplicator +): SecuritySeedResourceFactory { + return (setting) => { + const snapshot = snapshotRemoteSettings(setting); + const replicator = createReplicator(); + return Promise.resolve({ + read: () => replicator.getReplicationPBKDF2Salt(snapshot), + dispose: createReplicatorDisposer(replicator), + }); + }; +} + +/** Build an unpublished CouchDB Security Seed resource for one host. */ +export function createCouchDBSecuritySeedResourceFactory(host: SecuritySeedResourceHost): SecuritySeedResourceFactory { + return createSecuritySeedResourceFactory(() => new LiveSyncCouchDBReplicator(host)); +} + +/** Build an unpublished Object Storage Security Seed resource for one host. */ +export function createObjectStorageSecuritySeedResourceFactory( + host: SecuritySeedResourceHost +): SecuritySeedResourceFactory { + return createSecuritySeedResourceFactory(() => new LiveSyncJournalReplicator(host)); +} diff --git a/src/common/replicatorResources/shared.ts b/src/common/replicatorResources/shared.ts new file mode 100644 index 00000000..77d3fb98 --- /dev/null +++ b/src/common/replicatorResources/shared.ts @@ -0,0 +1,21 @@ +import type { RemoteDBSettings } from "@vrtmrz/livesync-commonlib/compat/common/types"; + +export interface ResourceReplicator { + closeReplication(): void | Promise; +} + +/** Create one idempotent asynchronous disposer for a private Replicator. */ +export function createReplicatorDisposer(replicator: ResourceReplicator): () => Promise { + let disposal: Promise | undefined; + return () => { + if (disposal === undefined) { + disposal = Promise.resolve().then(() => replicator.closeReplication()); + } + return disposal; + }; +} + +/** Fence a finite resource from later edits to its source settings object. */ +export function snapshotRemoteSettings(setting: RemoteDBSettings): RemoteDBSettings { + return { ...setting }; +} diff --git a/src/common/replicatorResources/synchronisationInformation.ts b/src/common/replicatorResources/synchronisationInformation.ts new file mode 100644 index 00000000..edd83174 --- /dev/null +++ b/src/common/replicatorResources/synchronisationInformation.ts @@ -0,0 +1,35 @@ +import type { SynchronisationInformationResourceFactory } from "@vrtmrz/livesync-commonlib/replication"; +import { + LiveSyncCouchDBReplicator, + type LiveSyncCouchDBReplicatorEnv, +} from "@vrtmrz/livesync-commonlib/compat/replication/couchdb/LiveSyncReplicator"; +import { checkSyncInfo } from "@vrtmrz/livesync-commonlib/compat/pouchdb/negotiation"; +import { createReplicatorDisposer, snapshotRemoteSettings } from "./shared"; + +/** Build an owned CouchDB synchronisation-information verifier for one host. */ +export function createCouchDBSynchronisationInformationResourceFactory( + host: LiveSyncCouchDBReplicatorEnv +): SynchronisationInformationResourceFactory { + return (setting) => { + const snapshot = snapshotRemoteSettings(setting); + const replicator = new LiveSyncCouchDBReplicator(host); + return Promise.resolve({ + check: async () => { + const connection = await replicator.connectRemoteCouchDBWithSetting( + snapshot, + replicator.isMobile(), + true + ); + if (typeof connection === "string") { + return false; + } + try { + return await checkSyncInfo(connection.db); + } finally { + await connection.close(); + } + }, + dispose: createReplicatorDisposer(replicator), + }); + }; +} diff --git a/src/modules/core/ModuleReplicator.ts b/src/modules/core/ModuleReplicator.ts index f77ee4fe..288bcb97 100644 --- a/src/modules/core/ModuleReplicator.ts +++ b/src/modules/core/ModuleReplicator.ts @@ -134,8 +134,8 @@ export class ModuleReplicator extends AbstractModule { return Promise.resolve(true); } - _onReplicatorInitialised(): Promise { - // For now, we only need to clear the error related to replicator initialisation, but in the future, if there are more things to do when the replicator is initialised, we can add them here. + _onBeforeReplicatorPublication(): Promise { + // Clear key-derivation handlers before the candidate Replicator becomes active. clearHandlers(); return Promise.resolve(true); } @@ -347,7 +347,7 @@ Even if you choose to clean up, you will see this option again if you exit Obsid // } override onBindFunction(core: LiveSyncCore, services: typeof core.services): void { - services.replicator.onReplicatorInitialised.addHandler(this._onReplicatorInitialised.bind(this)); + services.replicator.onBeforeReplicatorPublication.addHandler(this._onBeforeReplicatorPublication.bind(this)); services.databaseEvents.onDatabaseInitialised.addHandler(this._everyOnDatabaseInitialized.bind(this)); services.appLifecycle.onSettingLoaded.addHandler(this._everyOnloadAfterLoadSettings.bind(this)); services.replication.parseSynchroniseResult.addHandler(this._parseReplicationResult.bind(this)); diff --git a/src/modules/core/ModuleReplicator.unit.spec.ts b/src/modules/core/ModuleReplicator.unit.spec.ts index d1240ca3..b89750c1 100644 --- a/src/modules/core/ModuleReplicator.unit.spec.ts +++ b/src/modules/core/ModuleReplicator.unit.spec.ts @@ -23,7 +23,7 @@ describe("ModuleReplicator", () => { const services = { API: { isOnline: true }, replicator: { - onReplicatorInitialised: { addHandler: vi.fn() }, + onBeforeReplicatorPublication: { addHandler: vi.fn() }, getActiveReplicator: () => ({ ensurePBKDF2Salt }), }, setting: { currentSettings: () => ({}) }, @@ -45,7 +45,7 @@ describe("ModuleReplicator", () => { showError: vi.fn(), clearError: vi.fn(), }, - _onReplicatorInitialised: vi.fn(), + _onBeforeReplicatorPublication: vi.fn(), _everyOnDatabaseInitialized: vi.fn(), _everyOnloadAfterLoadSettings: vi.fn(), _parseReplicationResult: vi.fn(), @@ -71,7 +71,7 @@ describe("ModuleReplicator", () => { const services = { API: { isOnline: true }, replicator: { - onReplicatorInitialised: { addHandler: vi.fn() }, + onBeforeReplicatorPublication: { addHandler: vi.fn() }, getActiveReplicator: () => ({ ensurePBKDF2Salt }), }, setting: { currentSettings: () => ({}) }, @@ -94,7 +94,7 @@ describe("ModuleReplicator", () => { showError: vi.fn(), clearError: vi.fn(), }, - _onReplicatorInitialised: vi.fn(), + _onBeforeReplicatorPublication: vi.fn(), _everyOnDatabaseInitialized: vi.fn(), _everyOnloadAfterLoadSettings: vi.fn(), _parseReplicationResult: vi.fn(), diff --git a/src/modules/coreFeatures/ModuleResolveMismatchedTweaks.ts b/src/modules/coreFeatures/ModuleResolveMismatchedTweaks.ts index 41ad7bc2..e94610ee 100644 --- a/src/modules/coreFeatures/ModuleResolveMismatchedTweaks.ts +++ b/src/modules/coreFeatures/ModuleResolveMismatchedTweaks.ts @@ -20,6 +20,8 @@ import { $msg, translateIfAvailable } from "@/common/translation"; import type { InjectableServiceHub } from "@vrtmrz/livesync-commonlib/compat/services/implements/injectable/InjectableServiceHub"; 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"; /** * Localised counterpart of Commonlib's `confName()`, which takes no translator. @@ -271,12 +273,15 @@ export class ModuleResolvingMismatchedTweaks extends AbstractModule { async _fetchRemotePreferredTweakValues(trialSetting: RemoteDBSettings): Promise { try { - const replicator = await this.services.replicator.getNewReplicator(trialSetting); - if (!replicator) { + const probe = await this.services.replicator.createRemoteResource( + REMOTE_RESOURCE_KINDS.PREFERRED_TWEAK, + trialSetting + ); + if (!probe) { this._log("The remote type does not support preferred tweak values.", LOG_LEVEL_NOTICE); return { status: RemotePreferredTweakStatuses.UNSUPPORTED }; } - return await replicator.getRemotePreferredTweakValues(trialSetting); + return await withOwnedRemoteResource(probe, (ownedProbe) => ownedProbe.read()); } catch (ex) { this._log("Failed to get the preferred tweak values from the remote.", LOG_LEVEL_NOTICE); return { diff --git a/src/modules/coreFeatures/ModuleResolveMismatchedTweaks.unit.spec.ts b/src/modules/coreFeatures/ModuleResolveMismatchedTweaks.unit.spec.ts index e0fd1dc8..d82fec44 100644 --- a/src/modules/coreFeatures/ModuleResolveMismatchedTweaks.unit.spec.ts +++ b/src/modules/coreFeatures/ModuleResolveMismatchedTweaks.unit.spec.ts @@ -7,6 +7,7 @@ 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"; function createModule(settingsOverride: Partial = {}) { const askSelectStringDialogue = vi.fn(async (..._args: unknown[]): Promise => undefined); @@ -57,27 +58,31 @@ function createModule(settingsOverride: Partial = {}) { describe("ModuleResolvingMismatchedTweaks", () => { it("returns an unconfigured remote result without a separate connection preflight", async () => { const { module, core } = createModule(); - const tryConnectRemote = vi.fn(async () => true); - const getRemotePreferredTweakValues = vi.fn(async () => ({ + const read = vi.fn(async () => ({ status: "not-configured" as const, reason: "milestone-missing" as const, })); + const dispose = vi.fn(async () => undefined); + const createRemoteResource = vi.fn(async () => ({ read, dispose })); core._services.replicator = { - getNewReplicator: vi.fn(async () => ({ tryConnectRemote, getRemotePreferredTweakValues })), + createRemoteResource, + getNewReplicator: vi.fn(() => Promise.reject(new Error("must not borrow a Replicator"))), }; await expect(module._fetchRemotePreferredTweakValues(core.settings)).resolves.toEqual({ status: "not-configured", reason: "milestone-missing", }); - expect(getRemotePreferredTweakValues).toHaveBeenCalledOnce(); - expect(tryConnectRemote).not.toHaveBeenCalled(); + expect(createRemoteResource).toHaveBeenCalledWith(REMOTE_RESOURCE_KINDS.PREFERRED_TWEAK, core.settings); + expect(read).toHaveBeenCalledOnce(); + expect(dispose).toHaveBeenCalledOnce(); + expect(core._services.replicator.getNewReplicator).not.toHaveBeenCalled(); }); it("returns unsupported when no replicator implements the remote type", async () => { const { module, core } = createModule(); core._services.replicator = { - getNewReplicator: vi.fn(async () => undefined), + createRemoteResource: vi.fn(async () => undefined), }; await expect(module._fetchRemotePreferredTweakValues(core.settings)).resolves.toEqual({ @@ -85,6 +90,26 @@ describe("ModuleResolvingMismatchedTweaks", () => { }); }); + it("disposes the preferred-tweak probe when reading fails", async () => { + const { module, core } = createModule(); + const error = new Error("remote unavailable"); + const dispose = vi.fn(async () => undefined); + core._services.replicator = { + createRemoteResource: vi.fn(async () => ({ + read: vi.fn(async () => { + throw error; + }), + dispose, + })), + }; + + await expect(module._fetchRemotePreferredTweakValues(core.settings)).resolves.toEqual({ + status: "unavailable", + error, + }); + expect(dispose).toHaveBeenCalledOnce(); + }); + it("should enable and auto-accept compatible mismatches when the preference is undefined", async () => { const { module, core, askSelectStringDialogue, applyPartial } = createModule({ autoAcceptCompatibleTweak: undefined, diff --git a/src/modules/features/SettingDialogue/ObsidianLiveSyncSettingTab.ts b/src/modules/features/SettingDialogue/ObsidianLiveSyncSettingTab.ts index 38c1be92..ae6b5527 100644 --- a/src/modules/features/SettingDialogue/ObsidianLiveSyncSettingTab.ts +++ b/src/modules/features/SettingDialogue/ObsidianLiveSyncSettingTab.ts @@ -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(); @@ -340,15 +340,18 @@ export class ObsidianLiveSyncSettingTab extends PluginSettingTab { async testConnection(settingOverride: Partial = {}): Promise { 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 => { 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 () => { diff --git a/src/modules/features/SettingDialogue/ObsidianLiveSyncSettingTab.unit.spec.ts b/src/modules/features/SettingDialogue/ObsidianLiveSyncSettingTab.unit.spec.ts index 92955f45..c10d5c56 100644 --- a/src/modules/features/SettingDialogue/ObsidianLiveSyncSettingTab.unit.spec.ts +++ b/src/modules/features/SettingDialogue/ObsidianLiveSyncSettingTab.unit.spec.ts @@ -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(); }); }); diff --git a/src/modules/features/SetupWizard/dialogs/SetupRemoteBucket.svelte b/src/modules/features/SetupWizard/dialogs/SetupRemoteBucket.svelte index e31312b9..5ca7ca14 100644 --- a/src/modules/features/SetupWizard/dialogs/SetupRemoteBucket.svelte +++ b/src/modules/features/SetupWizard/dialogs/SetupRemoteBucket.svelte @@ -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."); diff --git a/src/modules/features/SetupWizard/dialogs/SetupRemoteCouchDB.svelte b/src/modules/features/SetupWizard/dialogs/SetupRemoteCouchDB.svelte index 508a552a..a163ceee 100644 --- a/src/modules/features/SetupWizard/dialogs/SetupRemoteCouchDB.svelte +++ b/src/modules/features/SetupWizard/dialogs/SetupRemoteCouchDB.svelte @@ -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 { diff --git a/src/modules/features/SetupWizard/dialogs/couchDBConnectionProbe.ts b/src/modules/features/SetupWizard/dialogs/couchDBConnectionProbe.ts index dcd3d90b..31dd30cb 100644 --- a/src/modules/features/SetupWizard/dialogs/couchDBConnectionProbe.ts +++ b/src/modules/features/SetupWizard/dialogs/couchDBConnectionProbe.ts @@ -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 }; - info: unknown; - }; - -export interface CouchDBConnectionProbe { - isMobile(): boolean; - connectRemoteCouchDBWithSetting( - settings: RemoteDBSettings, - isMobile: boolean, - performSetup: boolean, - skipInfo: boolean - ): CouchDBConnectionResult | Promise; -} - -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 { - if (!isCouchDBConnectionProbe(replicator)) { - return { ok: false, reason: "The CouchDB connection probe is unavailable." }; - } - const result = await replicator.connectRemoteCouchDBWithSetting( - settings, - replicator.isMobile(), - createIfMissing, - false +): Promise { + 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 { diff --git a/src/modules/features/SetupWizard/dialogs/couchDBConnectionProbe.unit.spec.ts b/src/modules/features/SetupWizard/dialogs/couchDBConnectionProbe.unit.spec.ts index ef19ecd0..8166c0dc 100644 --- a/src/modules/features/SetupWizard/dialogs/couchDBConnectionProbe.unit.spec.ts +++ b/src/modules/features/SetupWizard/dialogs/couchDBConnectionProbe.unit.spec.ts @@ -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([ diff --git a/updates.md b/updates.md index ffdf139e..8490c1b4 100644 --- a/updates.md +++ b/updates.md @@ -12,6 +12,12 @@ Earlier releases remain available in the 1.0 release history, the 1.0 preview hi ## Unreleased +### Command-line interface + +#### Fixed + +- `mark-resolved`, `lock-remote`, and `unlock-remote` now return a non-zero exit code when the selected provider cannot verify the requested remote state. Use `--compat-remote-admin-exit-zero` to retain the former exit code for returned verification failures; unknown remote IDs and mutation errors still fail. + ## 1.0.21 26th August, 2026