diff --git a/src/LiveSyncBaseCore.ts b/src/LiveSyncBaseCore.ts index 60137090..47a36871 100644 --- a/src/LiveSyncBaseCore.ts +++ b/src/LiveSyncBaseCore.ts @@ -16,7 +16,11 @@ import type { LiveSyncLocalDBEnv } from "@vrtmrz/livesync-commonlib/compat/pouch import type { LiveSyncCouchDBReplicatorEnv } from "@vrtmrz/livesync-commonlib/compat/replication/couchdb/LiveSyncReplicator"; import type { CheckPointInfo } from "@vrtmrz/livesync-commonlib/compat/replication/journal/JournalSyncTypes"; import type { LiveSyncJournalReplicatorEnv } from "@vrtmrz/livesync-commonlib/compat/replication/journal/LiveSyncJournalReplicatorEnv"; -import type { LiveSyncReplicatorEnv } from "@vrtmrz/livesync-commonlib/compat/replication/LiveSyncAbstractReplicator"; +import type { + LiveSyncAbstractReplicator, + LiveSyncReplicatorEnv, +} from "@vrtmrz/livesync-commonlib/compat/replication/LiveSyncAbstractReplicator"; +import type { ReplicatorInstance } from "@vrtmrz/livesync-commonlib/replication"; import { useTargetFilters } from "@vrtmrz/livesync-commonlib/compat/serviceFeatures/targetFilter"; import { useRemoteConfigurationMigration } from "@vrtmrz/livesync-commonlib/compat/serviceFeatures/remoteConfig"; import type { ServiceContext } from "@vrtmrz/livesync-commonlib/context"; @@ -39,6 +43,8 @@ export interface LiveSyncCoreFeatureViews { readonly replicationScheduling: ReplicationSchedulingControl; } +type CompatibilityReplicatorView = ReplicatorInstance & Partial; + export class LiveSyncBaseCore< T extends ServiceContext = ServiceContext, TCommands extends IMinimumLiveSyncCommands = IMinimumLiveSyncCommands, @@ -236,10 +242,11 @@ export class LiveSyncBaseCore< } /** - * @obsolete Use services.replication.getActiveReplicator instead. Get the active replicator instance. Note that there can be multiple replicators, but only one can be active at a time. + * @obsolete Use the provider context or a focused service operation instead. + * Provider-specific members on this compatibility view are optional. */ - get replicator() { - return this.services.replicator.getActiveReplicator()!; + get replicator(): CompatibilityReplicatorView { + return this.services.replicator.getActiveReplicator() as CompatibilityReplicatorView; } /** diff --git a/src/apps/cli/commands/remoteAdministration.ts b/src/apps/cli/commands/centralRemoteAdministration.ts similarity index 52% rename from src/apps/cli/commands/remoteAdministration.ts rename to src/apps/cli/commands/centralRemoteAdministration.ts index 6f168d15..5fded766 100644 --- a/src/apps/cli/commands/remoteAdministration.ts +++ b/src/apps/cli/commands/centralRemoteAdministration.ts @@ -1,27 +1,29 @@ 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, + CENTRAL_REMOTE_ADMINISTRATION_ACTIONS, + CENTRAL_REMOTE_ADMINISTRATION_FAILURE_REASONS, + CENTRAL_REMOTE_ADMINISTRATION_OBSERVATION_KINDS, + isCentralRemoteAdministrationVerified, + type CentralRemoteAdministrationAction, + type CentralRemoteAdministrationResult, } 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>); +const CENTRAL_REMOTE_ADMINISTRATION_ACTION_BY_COMMAND = Object.freeze({ + "mark-resolved": CENTRAL_REMOTE_ADMINISTRATION_ACTIONS.MARK_RESOLVED, + "lock-remote": CENTRAL_REMOTE_ADMINISTRATION_ACTIONS.LOCK, + "unlock-remote": CENTRAL_REMOTE_ADMINISTRATION_ACTIONS.UNLOCK, +} as const satisfies Partial>); -export type RemoteAdministrationCommand = keyof typeof REMOTE_ADMINISTRATION_ACTION_BY_COMMAND; +export type CentralRemoteAdministrationCommand = keyof typeof CENTRAL_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); +/** Return whether a CLI command belongs to the central-remote administration category. */ +export function isCentralRemoteAdministrationCommand( + command: CLICommand +): command is CentralRemoteAdministrationCommand { + return Object.prototype.hasOwnProperty.call(CENTRAL_REMOTE_ADMINISTRATION_ACTION_BY_COMMAND, command); } function detailMessage(detail: unknown): string { @@ -31,8 +33,8 @@ function detailMessage(detail: unknown): string { function reportMilestoneObservation( standardIo: StandardIo, observation: Extract< - RemoteAdministrationResult["observation"], - { kind: typeof REMOTE_ADMINISTRATION_OBSERVATION_KINDS.MILESTONE } + CentralRemoteAdministrationResult["observation"], + { kind: typeof CENTRAL_REMOTE_ADMINISTRATION_OBSERVATION_KINDS.MILESTONE } > ): void { standardIo.writeStderr(`[Verification] Remote Database: ${observation.locked ? "LOCKED" : "UNLOCKED"}\n`); @@ -42,40 +44,43 @@ function reportMilestoneObservation( } /** 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) { +function reportCentralRemoteAdministrationResult( + standardIo: StandardIo, + result: CentralRemoteAdministrationResult +): void { + if (result.observation?.kind === CENTRAL_REMOTE_ADMINISTRATION_OBSERVATION_KINDS.MILESTONE) { reportMilestoneObservation(standardIo, result.observation); return; } - if (isRemoteAdministrationVerified(result)) { + if (isCentralRemoteAdministrationVerified(result)) { return; } switch (result.reason) { - case REMOTE_ADMINISTRATION_FAILURE_REASONS.NO_ACTIVE_REPLICATOR: + case CENTRAL_REMOTE_ADMINISTRATION_FAILURE_REASONS.NO_ACTIVE_REPLICATOR: standardIo.writeStderr("[Verification] No active replicator found\n"); return; - case REMOTE_ADMINISTRATION_FAILURE_REASONS.CONNECTION_FAILED: + case CENTRAL_REMOTE_ADMINISTRATION_FAILURE_REASONS.CONNECTION_FAILED: standardIo.writeStderr( - `[Verification] Failed to connect to remote CouchDB: ${detailMessage(result.detail)}\n` + `[Verification] Failed to connect to the configured remote: ${detailMessage(result.detail)}\n` ); return; - case REMOTE_ADMINISTRATION_FAILURE_REASONS.MILESTONE_NOT_FOUND: + case CENTRAL_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: + case CENTRAL_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: + case CENTRAL_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: + case CENTRAL_REMOTE_ADMINISTRATION_FAILURE_REASONS.CAPABILITY_NOT_IMPLEMENTED: + case CENTRAL_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: + case CENTRAL_REMOTE_ADMINISTRATION_FAILURE_REASONS.POSTCONDITION_MISMATCH: standardIo.writeStderr("[Verification] The requested remote state was not observed.\n"); return; } @@ -85,10 +90,10 @@ function reportRemoteAdministrationResult(standardIo: StandardIo, result: Remote * Apply one provider-owned mutation and map its typed verification to CLI exit policy. * Mutation exceptions deliberately escape this boundary. */ -export async function runRemoteAdministrationCommand( +export async function runCentralRemoteAdministrationCommand( options: CLIOptions, context: CLICommandContext, - command: RemoteAdministrationCommand + command: CentralRemoteAdministrationCommand ): Promise { const id = options.commandArgs[0]?.trim(); if (id) { @@ -113,8 +118,8 @@ export async function runRemoteAdministrationCommand( } 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; + const action = CENTRAL_REMOTE_ADMINISTRATION_ACTION_BY_COMMAND[command]; + const result = await context.core.services.replicator.runCentralRemoteAdministration({ action }); + reportCentralRemoteAdministrationResult(context.core.services.context.standardIo, result); + return isCentralRemoteAdministrationVerified(result) || options.compatRemoteAdminExitZero === true; } diff --git a/src/apps/cli/commands/runCommand.ts b/src/apps/cli/commands/runCommand.ts index f49087b8..85030a45 100644 --- a/src/apps/cli/commands/runCommand.ts +++ b/src/apps/cli/commands/runCommand.ts @@ -21,13 +21,17 @@ import { compatGlobal } from "@vrtmrz/livesync-commonlib/compat/common/coreEnvFu import { fsPromises as fs, path } from "@vrtmrz/livesync-commonlib/node"; import { writeStderrLine, writeStdoutLine } from "@/apps/cli/cliOutput"; import { + CENTRAL_COMPATIBILITY_REJECTION_REASONS, 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"; +import { + isCentralRemoteAdministrationCommand, + runCentralRemoteAdministrationCommand, +} from "./centralRemoteAdministration"; function redactConnectionString(uri: string): string { return uri.replace(/\/\/([^@/]+)@/u, "//***@"); @@ -175,8 +179,11 @@ export async function runCommand(options: CLIOptions, context: CLICommandContext // TODO: Standardise the logic for identifying the cause of replication // failure so that every reason (locked DB, version mismatch, network // error, etc.) is surfaced with a CLI-specific actionable message. - const replicator = core.services.replicator.getActiveReplicator(); - if (replicator?.remoteLockedAndDeviceNotAccepted) { + const recoveryHint = result.status === "failed" ? result.recoveryHint : undefined; + if ( + recoveryHint?.reason === CENTRAL_COMPATIBILITY_REJECTION_REASONS.NODE_LOCKED || + recoveryHint?.reason === CENTRAL_COMPATIBILITY_REJECTION_REASONS.NODE_CLEANED + ) { writeStderrLine( standardIo, `[Error] The remote database is locked and this device is not yet accepted.\n` + @@ -723,8 +730,8 @@ export async function runCommand(options: CLIOptions, context: CLICommandContext return true; } - if (isRemoteAdministrationCommand(options.command)) { - return await runRemoteAdministrationCommand(options, context, options.command); + if (isCentralRemoteAdministrationCommand(options.command)) { + return await runCentralRemoteAdministrationCommand(options, context, options.command); } if (options.command === "remote-status") { diff --git a/src/apps/cli/commands/runCommand.unit.spec.ts b/src/apps/cli/commands/runCommand.unit.spec.ts index 8e5b1b3f..d504d1b6 100644 --- a/src/apps/cli/commands/runCommand.unit.spec.ts +++ b/src/apps/cli/commands/runCommand.unit.spec.ts @@ -12,11 +12,14 @@ 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, + CENTRAL_REMOTE_ADMINISTRATION_ACTIONS, + CENTRAL_REMOTE_ADMINISTRATION_FAILURE_REASONS, + CENTRAL_REMOTE_ADMINISTRATION_OBSERVATION_KINDS, + CENTRAL_REMOTE_ADMINISTRATION_RESULT_STATUSES, REMOTE_RESOURCE_KINDS, + CENTRAL_COMPATIBILITY_REJECTION_REASONS, + REPLICATION_COMPLETED, + replicationFailed, } from "@vrtmrz/livesync-commonlib/replication"; function createStandardIoMock() { @@ -56,13 +59,14 @@ function createCoreMock() { markResolved: vi.fn(async () => {}), markUnlocked: vi.fn(async () => {}), markLocked: vi.fn(async () => {}), + replicateUserInitiated: vi.fn(async () => REPLICATION_COMPLETED), }, replicator: { - runRemoteAdministration: vi.fn(async ({ action }) => ({ - status: REMOTE_ADMINISTRATION_RESULT_STATUSES.VERIFIED, + runCentralRemoteAdministration: vi.fn(async ({ action }) => ({ + status: CENTRAL_REMOTE_ADMINISTRATION_RESULT_STATUSES.VERIFIED, observation: { - kind: REMOTE_ADMINISTRATION_OBSERVATION_KINDS.MILESTONE, - locked: action === REMOTE_ADMINISTRATION_ACTIONS.LOCK, + kind: CENTRAL_REMOTE_ADMINISTRATION_OBSERVATION_KINDS.MILESTONE, + locked: action === CENTRAL_REMOTE_ADMINISTRATION_ACTIONS.LOCK, accepted: true, nodeId: "test-node-id", }, @@ -261,6 +265,27 @@ describe("runCommand abnormal cases", () => { vi.restoreAllMocks(); }); + it("reports a lock from the exact sync outcome without inspecting a replacement Replicator", async () => { + const core = createCoreMock(); + core.services.replication.replicateUserInitiated.mockResolvedValue( + replicationFailed(new Error("locked"), { + reason: CENTRAL_COMPATIBILITY_REJECTION_REASONS.NODE_LOCKED, + }) + ); + + await expect( + runCommand(makeOptions("sync", []), { + ...context, + core, + }) + ).resolves.toBe(false); + + expect(core.services.context.standardIo.writeStderr).toHaveBeenCalledWith( + expect.stringContaining("remote database is locked") + ); + expect(core.services.replicator.getActiveReplicator).not.toHaveBeenCalled(); + }); + it("pull returns false for non-existing path", async () => { const core = createCoreMock(); core.serviceModules.fileHandler.dbToStorage.mockResolvedValue(false); @@ -736,11 +761,36 @@ describe("runCommand abnormal cases", () => { }); describe("mark-resolved and unlock-remote commands", () => { + it("reports a connection failure without claiming that every central remote is CouchDB", async () => { + const core = createCoreMock(); + core.services.replicator.runCentralRemoteAdministration.mockResolvedValueOnce({ + status: CENTRAL_REMOTE_ADMINISTRATION_RESULT_STATUSES.VERIFICATION_FAILED, + reason: CENTRAL_REMOTE_ADMINISTRATION_FAILURE_REASONS.CONNECTION_FAILED, + detail: new Error("remote unavailable"), + }); + + const result = await runCommand(makeOptions("mark-resolved", []), { + ...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] Failed to connect to the configured remote: remote unavailable\n" + ); + expect(verificationOutput).not.toContain("CouchDB"); + }); + 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, + core.services.replicator.runCentralRemoteAdministration.mockResolvedValueOnce({ + status: CENTRAL_REMOTE_ADMINISTRATION_RESULT_STATUSES.VERIFICATION_FAILED, + reason: CENTRAL_REMOTE_ADMINISTRATION_FAILURE_REASONS.NO_ACTIVE_REPLICATOR, }); const result = await runCommand(makeOptions("mark-resolved", []), { @@ -753,9 +803,9 @@ describe("runCommand abnormal cases", () => { 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, + core.services.replicator.runCentralRemoteAdministration.mockResolvedValueOnce({ + status: CENTRAL_REMOTE_ADMINISTRATION_RESULT_STATUSES.VERIFICATION_FAILED, + reason: CENTRAL_REMOTE_ADMINISTRATION_FAILURE_REASONS.NO_ACTIVE_REPLICATOR, }); const result = await runCommand( @@ -772,7 +822,7 @@ describe("runCommand abnormal cases", () => { 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); + core.services.replicator.runCentralRemoteAdministration.mockRejectedValueOnce(failure); await expect( runCommand( @@ -797,16 +847,16 @@ describe("runCommand abnormal cases", () => { ); expect(result).toBe(false); - expect(core.services.replicator.runRemoteAdministration).not.toHaveBeenCalled(); + expect(core.services.replicator.runCentralRemoteAdministration).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, + core.services.replicator.runCentralRemoteAdministration.mockResolvedValueOnce({ + status: CENTRAL_REMOTE_ADMINISTRATION_RESULT_STATUSES.VERIFICATION_FAILED, + reason: CENTRAL_REMOTE_ADMINISTRATION_FAILURE_REASONS.POSTCONDITION_MISMATCH, observation: { - kind: REMOTE_ADMINISTRATION_OBSERVATION_KINDS.MILESTONE, + kind: CENTRAL_REMOTE_ADMINISTRATION_OBSERVATION_KINDS.MILESTONE, locked: false, accepted: true, nodeId: "test-node-id", @@ -835,8 +885,8 @@ describe("runCommand abnormal cases", () => { core, }); expect(result).toBe(true); - expect(core.services.replicator.runRemoteAdministration).toHaveBeenCalledWith({ - action: REMOTE_ADMINISTRATION_ACTIONS.MARK_RESOLVED, + expect(core.services.replicator.runCentralRemoteAdministration).toHaveBeenCalledWith({ + action: CENTRAL_REMOTE_ADMINISTRATION_ACTIONS.MARK_RESOLVED, }); expect(core.services.control.applySettings).not.toHaveBeenCalled(); expect(core.services.replication.markResolved).not.toHaveBeenCalled(); @@ -857,8 +907,8 @@ describe("runCommand abnormal cases", () => { core, }); expect(result).toBe(true); - expect(core.services.replicator.runRemoteAdministration).toHaveBeenCalledWith({ - action: REMOTE_ADMINISTRATION_ACTIONS.MARK_RESOLVED, + expect(core.services.replicator.runCentralRemoteAdministration).toHaveBeenCalledWith({ + action: CENTRAL_REMOTE_ADMINISTRATION_ACTIONS.MARK_RESOLVED, }); expect(core.services.control.applySettings).toHaveBeenCalledTimes(1); expect(settings.activeConfigurationId).toBe("r1"); @@ -872,8 +922,8 @@ describe("runCommand abnormal cases", () => { core, }); expect(result).toBe(true); - expect(core.services.replicator.runRemoteAdministration).toHaveBeenCalledWith({ - action: REMOTE_ADMINISTRATION_ACTIONS.UNLOCK, + expect(core.services.replicator.runCentralRemoteAdministration).toHaveBeenCalledWith({ + action: CENTRAL_REMOTE_ADMINISTRATION_ACTIONS.UNLOCK, }); expect(core.services.control.applySettings).not.toHaveBeenCalled(); }); @@ -893,8 +943,8 @@ describe("runCommand abnormal cases", () => { core, }); expect(result).toBe(true); - expect(core.services.replicator.runRemoteAdministration).toHaveBeenCalledWith({ - action: REMOTE_ADMINISTRATION_ACTIONS.UNLOCK, + expect(core.services.replicator.runCentralRemoteAdministration).toHaveBeenCalledWith({ + action: CENTRAL_REMOTE_ADMINISTRATION_ACTIONS.UNLOCK, }); expect(core.services.control.applySettings).toHaveBeenCalledTimes(1); expect(settings.activeConfigurationId).toBe("r1"); @@ -908,8 +958,8 @@ describe("runCommand abnormal cases", () => { core, }); expect(result).toBe(true); - expect(core.services.replicator.runRemoteAdministration).toHaveBeenCalledWith({ - action: REMOTE_ADMINISTRATION_ACTIONS.LOCK, + expect(core.services.replicator.runCentralRemoteAdministration).toHaveBeenCalledWith({ + action: CENTRAL_REMOTE_ADMINISTRATION_ACTIONS.LOCK, }); expect(core.services.control.applySettings).not.toHaveBeenCalled(); }); @@ -929,8 +979,8 @@ describe("runCommand abnormal cases", () => { core, }); expect(result).toBe(true); - expect(core.services.replicator.runRemoteAdministration).toHaveBeenCalledWith({ - action: REMOTE_ADMINISTRATION_ACTIONS.LOCK, + expect(core.services.replicator.runCentralRemoteAdministration).toHaveBeenCalledWith({ + action: CENTRAL_REMOTE_ADMINISTRATION_ACTIONS.LOCK, }); expect(core.services.control.applySettings).toHaveBeenCalledTimes(1); expect(settings.activeConfigurationId).toBe("r1"); diff --git a/src/common/centralRemoteAdministration.ts b/src/common/centralRemoteAdministration.ts new file mode 100644 index 00000000..24d937b7 --- /dev/null +++ b/src/common/centralRemoteAdministration.ts @@ -0,0 +1,209 @@ +import { + MILESTONE_DOCID, + type EntryMilestoneInfo, + type RemoteDBSettings, +} from "@vrtmrz/livesync-commonlib/compat/common/types"; +import type { LiveSyncCouchDBReplicator } from "@vrtmrz/livesync-commonlib/compat/replication/couchdb/LiveSyncReplicator"; +import type { LiveSyncJournalReplicator } from "@vrtmrz/livesync-commonlib/compat/replication/journal/LiveSyncJournalReplicator"; +import { + CENTRAL_REMOTE_ADMINISTRATION_FAILURE_REASONS, + CENTRAL_REMOTE_ADMINISTRATION_OBSERVATION_KINDS, + applyCentralRemoteAdministrationMutation, + milestoneSatisfiesCentralRemoteAdministration, + centralRemoteAdministrationVerificationFailed, + centralRemoteAdministrationVerified, + supportedCapability, + type MilestoneCentralRemoteAdministrationObservation, + type CentralRemoteAdministrationFailureReason, + type CentralRemoteAdministrationRequest, + type CentralRemoteAdministrationReplicator, + type CentralRemoteAdministrationResult, + type CentralRemoteAdministrationRunner, + type SupportedCapability, +} from "@vrtmrz/livesync-commonlib/replication"; + +const JOURNAL_MILESTONE_PATH = "_00000000-milestone.json"; + +type CentralMilestoneReadResult = + | { readonly milestone: EntryMilestoneInfo | false | undefined } + | { readonly failureReason: CentralRemoteAdministrationFailureReason; readonly detail?: unknown }; + +type PreparedCentralMilestoneReader = () => Promise; + +type CentralMilestoneReaderPreparer = ( + replicator: CentralRemoteAdministrationReplicator, + setting: RemoteDBSettings +) => PreparedCentralMilestoneReader; + +type CouchDBAdministrationReplicator = CentralRemoteAdministrationReplicator & + Pick; + +type JournalAdministrationClient = Pick; + +async function ensureLocalNodeIdentity( + replicator: CentralRemoteAdministrationReplicator +): Promise { + if (replicator.nodeid) { + return undefined; + } + if ((await replicator.initializeDatabaseForReplication()) && replicator.nodeid) { + return undefined; + } + return centralRemoteAdministrationVerificationFailed( + CENTRAL_REMOTE_ADMINISTRATION_FAILURE_REASONS.LOCAL_IDENTITY_UNAVAILABLE + ); +} + +function observeMilestone( + replicator: CentralRemoteAdministrationReplicator, + milestone: EntryMilestoneInfo +): MilestoneCentralRemoteAdministrationObservation { + return { + kind: CENTRAL_REMOTE_ADMINISTRATION_OBSERVATION_KINDS.MILESTONE, + locked: !!milestone.locked, + accepted: !!milestone.accepted_nodes?.includes(replicator.nodeid), + nodeId: replicator.nodeid, + }; +} + +function resultFromMilestone( + replicator: CentralRemoteAdministrationReplicator, + request: CentralRemoteAdministrationRequest, + milestone: EntryMilestoneInfo | false | undefined +): CentralRemoteAdministrationResult { + if (!milestone) { + return centralRemoteAdministrationVerificationFailed( + CENTRAL_REMOTE_ADMINISTRATION_FAILURE_REASONS.MILESTONE_NOT_FOUND + ); + } + const observation = observeMilestone(replicator, milestone); + return milestoneSatisfiesCentralRemoteAdministration(request.action, observation) + ? centralRemoteAdministrationVerified(observation) + : centralRemoteAdministrationVerificationFailed( + CENTRAL_REMOTE_ADMINISTRATION_FAILURE_REASONS.POSTCONDITION_MISMATCH, + { + observation, + } + ); +} + +/** + * Apply and verify the central milestone protocol without selecting a provider. + * + * The provider definition has already selected the reader preparer. Preparing + * it before mutation rejects incomplete composition before a remote write and + * binds any provider-owned client which must be used for postcondition reading. + */ +async function runCentralRemoteAdministration( + replicator: CentralRemoteAdministrationReplicator, + setting: RemoteDBSettings, + request: CentralRemoteAdministrationRequest, + prepareMilestoneReader: CentralMilestoneReaderPreparer +): Promise { + const identityFailure = await ensureLocalNodeIdentity(replicator); + if (identityFailure) return identityFailure; + + const readMilestone = prepareMilestoneReader(replicator, setting); + await applyCentralRemoteAdministrationMutation(replicator, setting, request.action); + + const readResult = await readMilestone(); + if ("failureReason" in readResult) { + return centralRemoteAdministrationVerificationFailed(readResult.failureReason, { detail: readResult.detail }); + } + return resultFromMilestone(replicator, request, readResult.milestone); +} + +function requireCouchDBAdministrationOperations( + replicator: CentralRemoteAdministrationReplicator +): asserts replicator is CouchDBAdministrationReplicator { + const candidate = replicator as Partial; + if (typeof candidate.connectRemoteCouchDBWithSetting !== "function" || typeof candidate.isMobile !== "function") { + throw new Error("The configured CouchDB administration adapter does not provide milestone access."); + } +} + +function prepareCouchDBMilestoneReader( + replicator: CentralRemoteAdministrationReplicator, + setting: RemoteDBSettings +): PreparedCentralMilestoneReader { + requireCouchDBAdministrationOperations(replicator); + + return async () => { + let connection: Awaited>; + try { + connection = await replicator.connectRemoteCouchDBWithSetting(setting, replicator.isMobile(), true); + } catch (error) { + return { failureReason: CENTRAL_REMOTE_ADMINISTRATION_FAILURE_REASONS.CONNECTION_FAILED, detail: error }; + } + if (typeof connection === "string") { + return { + failureReason: CENTRAL_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 { + failureReason: CENTRAL_REMOTE_ADMINISTRATION_FAILURE_REASONS.MILESTONE_READ_FAILED, + detail: observationError, + }; + } + return { milestone }; + }; +} + +function requireJournalAdministrationClient( + replicator: CentralRemoteAdministrationReplicator +): JournalAdministrationClient { + const client = (replicator as { readonly client?: JournalAdministrationClient }).client; + if (typeof client?.downloadJson !== "function") { + throw new Error("The configured Object Storage administration adapter does not provide milestone access."); + } + return client; +} + +function prepareObjectStorageMilestoneReader( + replicator: CentralRemoteAdministrationReplicator +): PreparedCentralMilestoneReader { + const client = requireJournalAdministrationClient(replicator); + + return async () => { + try { + return { milestone: await client.downloadJson(JOURNAL_MILESTONE_PATH) }; + } catch (error) { + return { + failureReason: CENTRAL_REMOTE_ADMINISTRATION_FAILURE_REASONS.MILESTONE_READ_FAILED, + detail: error, + }; + } + }; +} + +const runCouchDBCentralRemoteAdministration: CentralRemoteAdministrationRunner = async (replicator, setting, request) => + await runCentralRemoteAdministration(replicator, setting, request, prepareCouchDBMilestoneReader); + +const runObjectStorageCentralRemoteAdministration: CentralRemoteAdministrationRunner = async ( + replicator, + setting, + request +) => await runCentralRemoteAdministration(replicator, setting, request, prepareObjectStorageMilestoneReader); + +/** CouchDB mutation and milestone postcondition verification capability. */ +export const COUCHDB_CENTRAL_REMOTE_ADMINISTRATION_CAPABILITY: SupportedCapability = + supportedCapability(runCouchDBCentralRemoteAdministration); + +/** Object Storage mutation and milestone postcondition verification capability. */ +export const OBJECT_STORAGE_CENTRAL_REMOTE_ADMINISTRATION_CAPABILITY: SupportedCapability = + supportedCapability(runObjectStorageCentralRemoteAdministration); diff --git a/src/common/replicatorAdministration.unit.spec.ts b/src/common/centralRemoteAdministration.unit.spec.ts similarity index 58% rename from src/common/replicatorAdministration.unit.spec.ts rename to src/common/centralRemoteAdministration.unit.spec.ts index 5bfd7c27..965509ce 100644 --- a/src/common/replicatorAdministration.unit.spec.ts +++ b/src/common/centralRemoteAdministration.unit.spec.ts @@ -1,15 +1,15 @@ 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, + CENTRAL_REMOTE_ADMINISTRATION_ACTIONS, + CENTRAL_REMOTE_ADMINISTRATION_FAILURE_REASONS, + CENTRAL_REMOTE_ADMINISTRATION_OBSERVATION_KINDS, + CENTRAL_REMOTE_ADMINISTRATION_RESULT_STATUSES, } from "@vrtmrz/livesync-commonlib/replication"; import { - COUCHDB_REMOTE_ADMINISTRATION_CAPABILITY, - OBJECT_STORAGE_REMOTE_ADMINISTRATION_CAPABILITY, -} from "./replicatorAdministration"; + COUCHDB_CENTRAL_REMOTE_ADMINISTRATION_CAPABILITY, + OBJECT_STORAGE_CENTRAL_REMOTE_ADMINISTRATION_CAPABILITY, +} from "./centralRemoteAdministration"; describe("central remote administration capabilities", () => { it("mutates CouchDB, verifies the requested postcondition, and closes only the owned connection", async () => { @@ -28,14 +28,14 @@ describe("central remote administration capabilities", () => { connectRemoteCouchDBWithSetting: vi.fn(async () => ({ db: database, close })), }; const setting = { ...DEFAULT_SETTINGS, remoteType: REMOTE_COUCHDB }; - const capability = COUCHDB_REMOTE_ADMINISTRATION_CAPABILITY; + const capability = COUCHDB_CENTRAL_REMOTE_ADMINISTRATION_CAPABILITY; await expect( - capability.run(replicator as never, setting, { action: REMOTE_ADMINISTRATION_ACTIONS.LOCK }) + capability.run(replicator as never, setting, { action: CENTRAL_REMOTE_ADMINISTRATION_ACTIONS.LOCK }) ).resolves.toEqual({ - status: REMOTE_ADMINISTRATION_RESULT_STATUSES.VERIFIED, + status: CENTRAL_REMOTE_ADMINISTRATION_RESULT_STATUSES.VERIFIED, observation: { - kind: REMOTE_ADMINISTRATION_OBSERVATION_KINDS.MILESTONE, + kind: CENTRAL_REMOTE_ADMINISTRATION_OBSERVATION_KINDS.MILESTONE, locked: true, accepted: true, nodeId: "node-1", @@ -60,20 +60,20 @@ describe("central remote administration capabilities", () => { close, })), }; - const capability = COUCHDB_REMOTE_ADMINISTRATION_CAPABILITY; + const capability = COUCHDB_CENTRAL_REMOTE_ADMINISTRATION_CAPABILITY; const result = await capability.run( replicator as never, { ...DEFAULT_SETTINGS, remoteType: REMOTE_COUCHDB }, { - action: REMOTE_ADMINISTRATION_ACTIONS.LOCK, + action: CENTRAL_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 }, + status: CENTRAL_REMOTE_ADMINISTRATION_RESULT_STATUSES.VERIFICATION_FAILED, + reason: CENTRAL_REMOTE_ADMINISTRATION_FAILURE_REASONS.POSTCONDITION_MISMATCH, + observation: { kind: CENTRAL_REMOTE_ADMINISTRATION_OBSERVATION_KINDS.MILESTONE, locked: false }, }); expect(close).toHaveBeenCalledOnce(); }); @@ -87,17 +87,17 @@ describe("central remote administration capabilities", () => { markRemoteResolved: vi.fn(async () => undefined), connectRemoteCouchDBWithSetting: vi.fn(async () => "must not connect"), }; - const capability = COUCHDB_REMOTE_ADMINISTRATION_CAPABILITY; + const capability = COUCHDB_CENTRAL_REMOTE_ADMINISTRATION_CAPABILITY; await expect( capability.run( replicator as never, { ...DEFAULT_SETTINGS, remoteType: REMOTE_COUCHDB }, - { action: REMOTE_ADMINISTRATION_ACTIONS.MARK_RESOLVED } + { action: CENTRAL_REMOTE_ADMINISTRATION_ACTIONS.MARK_RESOLVED } ) ).resolves.toEqual({ - status: REMOTE_ADMINISTRATION_RESULT_STATUSES.VERIFICATION_FAILED, - reason: REMOTE_ADMINISTRATION_FAILURE_REASONS.LOCAL_IDENTITY_UNAVAILABLE, + status: CENTRAL_REMOTE_ADMINISTRATION_RESULT_STATUSES.VERIFICATION_FAILED, + reason: CENTRAL_REMOTE_ADMINISTRATION_FAILURE_REASONS.LOCAL_IDENTITY_UNAVAILABLE, }); expect(replicator.markRemoteResolved).not.toHaveBeenCalled(); expect(replicator.connectRemoteCouchDBWithSetting).not.toHaveBeenCalled(); @@ -115,14 +115,14 @@ describe("central remote administration capabilities", () => { markRemoteResolved: vi.fn(async () => undefined), connectRemoteCouchDBWithSetting: vi.fn(), }; - const capability = COUCHDB_REMOTE_ADMINISTRATION_CAPABILITY; + const capability = COUCHDB_CENTRAL_REMOTE_ADMINISTRATION_CAPABILITY; await expect( capability.run( replicator as never, { ...DEFAULT_SETTINGS, remoteType: REMOTE_COUCHDB }, { - action: REMOTE_ADMINISTRATION_ACTIONS.UNLOCK, + action: CENTRAL_REMOTE_ADMINISTRATION_ACTIONS.UNLOCK, } ) ).rejects.toBe(failure); @@ -139,14 +139,16 @@ describe("central remote administration capabilities", () => { client: { downloadJson }, }; const setting = { ...DEFAULT_SETTINGS, remoteType: REMOTE_MINIO }; - const capability = OBJECT_STORAGE_REMOTE_ADMINISTRATION_CAPABILITY; + const capability = OBJECT_STORAGE_CENTRAL_REMOTE_ADMINISTRATION_CAPABILITY; await expect( - capability.run(replicator as never, setting, { action: REMOTE_ADMINISTRATION_ACTIONS.MARK_RESOLVED }) + capability.run(replicator as never, setting, { + action: CENTRAL_REMOTE_ADMINISTRATION_ACTIONS.MARK_RESOLVED, + }) ).resolves.toEqual({ - status: REMOTE_ADMINISTRATION_RESULT_STATUSES.VERIFIED, + status: CENTRAL_REMOTE_ADMINISTRATION_RESULT_STATUSES.VERIFIED, observation: { - kind: REMOTE_ADMINISTRATION_OBSERVATION_KINDS.MILESTONE, + kind: CENTRAL_REMOTE_ADMINISTRATION_OBSERVATION_KINDS.MILESTONE, locked: false, accepted: true, nodeId: "node-1", @@ -155,4 +157,43 @@ describe("central remote administration capabilities", () => { expect(replicator.markRemoteResolved).toHaveBeenCalledWith(setting); expect(downloadJson).toHaveBeenCalledWith("_00000000-milestone.json"); }); + + it("rejects an incomplete CouchDB milestone adapter before mutation", async () => { + const markRemoteLocked = vi.fn(async () => undefined); + const replicator = { + nodeid: "node-1", + initializeDatabaseForReplication: vi.fn(async () => true), + markRemoteLocked, + markRemoteResolved: vi.fn(async () => undefined), + }; + + await expect( + COUCHDB_CENTRAL_REMOTE_ADMINISTRATION_CAPABILITY.run( + replicator as never, + { ...DEFAULT_SETTINGS, remoteType: REMOTE_COUCHDB }, + { action: CENTRAL_REMOTE_ADMINISTRATION_ACTIONS.LOCK } + ) + ).rejects.toThrow("The configured CouchDB administration adapter does not provide milestone access."); + expect(markRemoteLocked).not.toHaveBeenCalled(); + }); + + it("rejects an incomplete Object Storage milestone adapter before mutation", async () => { + const markRemoteResolved = vi.fn(async () => undefined); + const replicator = { + nodeid: "node-1", + initializeDatabaseForReplication: vi.fn(async () => true), + markRemoteLocked: vi.fn(async () => undefined), + markRemoteResolved, + client: {}, + }; + + await expect( + OBJECT_STORAGE_CENTRAL_REMOTE_ADMINISTRATION_CAPABILITY.run( + replicator as never, + { ...DEFAULT_SETTINGS, remoteType: REMOTE_MINIO }, + { action: CENTRAL_REMOTE_ADMINISTRATION_ACTIONS.MARK_RESOLVED } + ) + ).rejects.toThrow("The configured Object Storage administration adapter does not provide milestone access."); + expect(markRemoteResolved).not.toHaveBeenCalled(); + }); }); diff --git a/src/common/replicatorAdministration.ts b/src/common/replicatorAdministration.ts deleted file mode 100644 index d2b41e94..00000000 --- a/src/common/replicatorAdministration.ts +++ /dev/null @@ -1,143 +0,0 @@ -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/replicatorProviders.ts b/src/common/replicatorProviders.ts index feeb991f..c94003f9 100644 --- a/src/common/replicatorProviders.ts +++ b/src/common/replicatorProviders.ts @@ -1,16 +1,20 @@ -import { REMOTE_COUCHDB, REMOTE_MINIO } from "@vrtmrz/livesync-commonlib/compat/common/types"; +import { REMOTE_COUCHDB, REMOTE_MINIO, type RemoteDBSettings } from "@vrtmrz/livesync-commonlib/compat/common/types"; import { CAPABILITY_NOT_APPLICABLE, CENTRAL_REMOTE_REPLICATION_READINESS, + NO_INTERACTION, REMOTE_RESOURCE_KINDS, - REPLACE_SAME_KIND_REPLICATOR, defineReplicatorProviderDefinitions, supportedOpenReplicationContinuous, - supportedOpenReplicationOneShot, - supportedOpenReplicationUnattended, + replicationBlocked, + replicationFailed, supportedStopActiveTransfer, supportedCapability, type ReplicatorProviderDefinitionMap, + type ReplicationOutcome, + type ReplicatorInstance, + type UserInitiatedOneShotRunner, + type UnattendedOneShotRunner, } from "@vrtmrz/livesync-commonlib/replication"; import { LiveSyncCouchDBReplicator, @@ -32,12 +36,62 @@ import { createObjectStorageSecuritySeedResourceFactory, } from "./replicatorResources"; import { - COUCHDB_REMOTE_ADMINISTRATION_CAPABILITY, - OBJECT_STORAGE_REMOTE_ADMINISTRATION_CAPABILITY, -} from "./replicatorAdministration"; + COUCHDB_CENTRAL_REMOTE_ADMINISTRATION_CAPABILITY, + OBJECT_STORAGE_CENTRAL_REMOTE_ADMINISTRATION_CAPABILITY, +} from "./centralRemoteAdministration"; export type CentralReplicatorProviderHost = LiveSyncCouchDBReplicatorEnv & LiveSyncJournalReplicatorEnv; +/** Minimal operation required by both central one-shot adapters. */ +interface OneShotOutcomeReplicator extends ReplicatorInstance { + openOneShotReplicationWithOutcome(setting: RemoteDBSettings, showResult: boolean): Promise; +} + +function asOneShotOutcomeReplicator(instance: ReplicatorInstance): OneShotOutcomeReplicator | undefined { + const candidate = instance as Partial; + return typeof candidate.openOneShotReplicationWithOutcome === "function" + ? (instance as OneShotOutcomeReplicator) + : undefined; +} + +async function runOneShotWithOutcome( + instance: ReplicatorInstance, + setting: RemoteDBSettings, + showResult: boolean +): Promise { + const replicator = asOneShotOutcomeReplicator(instance); + if (!replicator) { + return replicationFailed(new Error("The configured provider does not implement one-shot replication.")); + } + return await replicator.openOneShotReplicationWithOutcome(setting, showResult); +} + +const couchDBUserInitiatedOneShot: UserInitiatedOneShotRunner = async (instance, setting, request) => { + return await runOneShotWithOutcome( + instance, + setting, + request.interaction.kind === "permitted" && request.interaction.permissions.failureRecovery + ); +}; + +const couchDBUnattendedOneShot: UnattendedOneShotRunner = async (instance, setting, request) => { + if (request.interaction.kind !== NO_INTERACTION.kind) return replicationBlocked("interaction-required"); + return await runOneShotWithOutcome(instance, setting, false); +}; + +const objectStorageUserInitiatedOneShot: UserInitiatedOneShotRunner = async (instance, setting, request) => { + return await runOneShotWithOutcome( + instance, + setting, + request.interaction.kind === "permitted" && request.interaction.permissions.failureRecovery + ); +}; + +const objectStorageUnattendedOneShot: UnattendedOneShotRunner = async (instance, setting, request) => { + if (request.interaction.kind !== NO_INTERACTION.kind) return replicationBlocked("interaction-required"); + return await runOneShotWithOutcome(instance, setting, false); +}; + /** Build the complete central-remote provider policy for one LiveSync host. */ export function createCentralReplicatorProviderDefinitions( host: CentralReplicatorProviderHost @@ -52,7 +106,6 @@ export function createCentralReplicatorProviderDefinitions( !!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)), @@ -66,9 +119,9 @@ export function createCentralReplicatorProviderDefinitions( createCouchDBSynchronisationInformationResourceFactory(host) ), }, - remoteAdministration: COUCHDB_REMOTE_ADMINISTRATION_CAPABILITY, - userInitiatedOneShot: supportedOpenReplicationOneShot(), - unattendedOneShot: supportedOpenReplicationUnattended(), + centralRemoteAdministration: COUCHDB_CENTRAL_REMOTE_ADMINISTRATION_CAPABILITY, + userInitiatedOneShot: supportedCapability(couchDBUserInitiatedOneShot), + unattendedOneShot: supportedCapability(couchDBUnattendedOneShot), continuous: supportedOpenReplicationContinuous(), stopActiveTransfer: supportedStopActiveTransfer(), }, @@ -79,7 +132,6 @@ export function createCentralReplicatorProviderDefinitions( 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( @@ -93,9 +145,9 @@ export function createCentralReplicatorProviderDefinitions( ), [REMOTE_RESOURCE_KINDS.SYNCHRONISATION_INFORMATION]: CAPABILITY_NOT_APPLICABLE, }, - remoteAdministration: OBJECT_STORAGE_REMOTE_ADMINISTRATION_CAPABILITY, - userInitiatedOneShot: supportedOpenReplicationOneShot(), - unattendedOneShot: supportedOpenReplicationUnattended(), + centralRemoteAdministration: OBJECT_STORAGE_CENTRAL_REMOTE_ADMINISTRATION_CAPABILITY, + userInitiatedOneShot: supportedCapability(objectStorageUserInitiatedOneShot), + unattendedOneShot: supportedCapability(objectStorageUnattendedOneShot), continuous: CAPABILITY_NOT_APPLICABLE, stopActiveTransfer: supportedStopActiveTransfer(), }, diff --git a/src/common/replicatorProviders.unit.spec.ts b/src/common/replicatorProviders.unit.spec.ts index a6e87c24..8b3bcd5b 100644 --- a/src/common/replicatorProviders.unit.spec.ts +++ b/src/common/replicatorProviders.unit.spec.ts @@ -3,13 +3,17 @@ import { REMOTE_COUCHDB, REMOTE_MINIO } from "@vrtmrz/livesync-commonlib/compat/ import { createNewVaultSettings } from "@vrtmrz/livesync-commonlib/settings"; import { CAPABILITY_SUPPORT_KINDS, + NO_INTERACTION, + REPLICATION_COMPLETED, REMOTE_RESOURCE_KINDS, - REPLACE_SAME_KIND_REPLICATOR, + USER_INITIATED_REPLICATION_AUTHORITY, } from "@vrtmrz/livesync-commonlib/replication"; const constructorMocks = vi.hoisted(() => ({ couchDB: vi.fn(), + couchDBOneShot: vi.fn(async (..._args: unknown[]) => REPLICATION_COMPLETED), objectStorage: vi.fn(), + objectStorageOneShot: vi.fn(async (..._args: unknown[]) => REPLICATION_COMPLETED), })); vi.mock("@vrtmrz/livesync-commonlib/compat/replication/couchdb/LiveSyncReplicator", () => ({ @@ -17,6 +21,9 @@ vi.mock("@vrtmrz/livesync-commonlib/compat/replication/couchdb/LiveSyncReplicato constructor(host: unknown) { constructorMocks.couchDB(host); } + openOneShotReplicationWithOutcome(...args: unknown[]) { + return constructorMocks.couchDBOneShot(...args); + } }, })); vi.mock("@vrtmrz/livesync-commonlib/compat/replication/journal/LiveSyncJournalReplicator", () => ({ @@ -24,12 +31,21 @@ vi.mock("@vrtmrz/livesync-commonlib/compat/replication/journal/LiveSyncJournalRe constructor(host: unknown) { constructorMocks.objectStorage(host); } + openOneShotReplicationWithOutcome(...args: unknown[]) { + return constructorMocks.objectStorageOneShot(...args); + } }, })); import { createCentralReplicatorProviderDefinitions } from "./replicatorProviders"; describe("central Replicator provider definitions", () => { + it("keeps the retained remote-resource catalogue bounded", () => { + expect + .soft(Object.values(REMOTE_RESOURCE_KINDS).sort()) + .toEqual(["connection", "preferred-tweak", "security-seed", "synchronisation-information"].sort()); + }); + it("composes CouchDB and Object Storage policies outside LiveSyncBaseCore", async () => { const host = {} as Parameters[0]; const definitions = createCentralReplicatorProviderDefinitions(host); @@ -37,8 +53,8 @@ describe("central Replicator provider definitions", () => { 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("sameKindReconciliation" in couchDB).toBe(false); + expect("sameKindReconciliation" in objectStorage).toBe(false); expect( couchDB.isConfigured( @@ -76,10 +92,12 @@ describe("central Replicator provider definitions", () => { ).toBe(false); }); - it("declares an exhaustive resource and administration catalogue for both central providers", () => { + it("declares the retained owned resources and cohesive optional administration", () => { const definitions = createCentralReplicatorProviderDefinitions({} as never); const couchResources = definitions.get(REMOTE_COUCHDB)?.remoteResources; const objectResources = definitions.get(REMOTE_MINIO)?.remoteResources; + const couchAdministration = definitions.get(REMOTE_COUCHDB)?.centralRemoteAdministration; + const objectAdministration = definitions.get(REMOTE_MINIO)?.centralRemoteAdministration; expect(Object.keys(couchResources ?? {}).sort()).toEqual(Object.values(REMOTE_RESOURCE_KINDS).sort()); expect(Object.keys(objectResources ?? {}).sort()).toEqual(Object.values(REMOTE_RESOURCE_KINDS).sort()); @@ -93,7 +111,100 @@ describe("central Replicator provider definitions", () => { 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); + expect(couchAdministration?.kind).toBe(CAPABILITY_SUPPORT_KINDS.SUPPORTED); + expect(objectAdministration?.kind).toBe(CAPABILITY_SUPPORT_KINDS.SUPPORTED); + expect("activeRemoteReads" in definitions.get(REMOTE_COUCHDB)!).toBe(false); + expect("fullTransfers" in definitions.get(REMOTE_COUCHDB)!).toBe(false); + }); + + it("dispatches central finite work through provider-local attempt results", async () => { + const definitions = createCentralReplicatorProviderDefinitions({} as never); + const couchDB = definitions.get(REMOTE_COUCHDB)!; + const objectStorage = definitions.get(REMOTE_MINIO)!; + const setting = createNewVaultSettings(); + const couchInstance = await couchDB.create(setting); + const objectInstance = await objectStorage.create(setting); + if (!couchInstance || !objectInstance) throw new Error("Provider construction failed"); + if (couchDB.userInitiatedOneShot.kind !== CAPABILITY_SUPPORT_KINDS.SUPPORTED) { + throw new Error("CouchDB OneShot is unavailable"); + } + if (objectStorage.unattendedOneShot.kind !== CAPABILITY_SUPPORT_KINDS.SUPPORTED) { + throw new Error("Object Storage OneShot is unavailable"); + } + + await expect( + couchDB.userInitiatedOneShot.run(couchInstance, setting, { + trigger: "manual", + interaction: USER_INITIATED_REPLICATION_AUTHORITY, + }) + ).resolves.toBe(REPLICATION_COMPLETED); + await expect( + objectStorage.unattendedOneShot.run(objectInstance, setting, { + trigger: "resume", + interaction: NO_INTERACTION, + }) + ).resolves.toBe(REPLICATION_COMPLETED); + + expect(constructorMocks.couchDBOneShot).toHaveBeenCalledWith(setting, true); + expect(constructorMocks.objectStorageOneShot).toHaveBeenCalledWith(setting, false); + }); + + it("dispatches central finite work through the declared operation rather than constructor identity", async () => { + const definitions = createCentralReplicatorProviderDefinitions({} as never); + const couchDB = definitions.get(REMOTE_COUCHDB)!; + const objectStorage = definitions.get(REMOTE_MINIO)!; + const setting = createNewVaultSettings(); + const createStructuralOneShotReplicator = () => ({ + initializeDatabaseForReplication: vi.fn(async () => true), + openReplication: vi.fn(async () => true), + terminateSync: vi.fn(), + closeReplication: vi.fn(), + openOneShotReplicationWithOutcome: vi.fn(async () => REPLICATION_COMPLETED), + }); + const couchInstance = createStructuralOneShotReplicator(); + const objectStorageInstance = createStructuralOneShotReplicator(); + if (couchDB.userInitiatedOneShot.kind !== CAPABILITY_SUPPORT_KINDS.SUPPORTED) { + throw new Error("CouchDB OneShot is unavailable"); + } + if (objectStorage.unattendedOneShot.kind !== CAPABILITY_SUPPORT_KINDS.SUPPORTED) { + throw new Error("Object Storage OneShot is unavailable"); + } + + const couchOutcome = await couchDB.userInitiatedOneShot.run(couchInstance, setting, { + trigger: "manual", + interaction: USER_INITIATED_REPLICATION_AUTHORITY, + }); + const objectStorageOutcome = await objectStorage.unattendedOneShot.run(objectStorageInstance, setting, { + trigger: "resume", + interaction: NO_INTERACTION, + }); + + expect.soft(couchOutcome).toBe(REPLICATION_COMPLETED); + expect.soft(objectStorageOutcome).toBe(REPLICATION_COMPLETED); + expect(couchInstance.openOneShotReplicationWithOutcome).toHaveBeenCalledWith(setting, true); + expect(objectStorageInstance.openOneShotReplicationWithOutcome).toHaveBeenCalledWith(setting, false); + }); + + it("rejects a one-shot adapter whose Replicator does not declare the required operation", async () => { + const definitions = createCentralReplicatorProviderDefinitions({} as never); + const couchDB = definitions.get(REMOTE_COUCHDB)!; + const setting = createNewVaultSettings(); + const incompleteInstance = { + initializeDatabaseForReplication: vi.fn(async () => true), + openReplication: vi.fn(async () => true), + terminateSync: vi.fn(), + closeReplication: vi.fn(), + }; + if (couchDB.userInitiatedOneShot.kind !== CAPABILITY_SUPPORT_KINDS.SUPPORTED) { + throw new Error("CouchDB OneShot is unavailable"); + } + + const outcome = await couchDB.userInitiatedOneShot.run(incompleteInstance, setting, { + trigger: "manual", + interaction: USER_INITIATED_REPLICATION_AUTHORITY, + }); + + expect(outcome.status).toBe("failed"); + expect(incompleteInstance.openReplication).not.toHaveBeenCalled(); }); }); diff --git a/src/common/replicatorResources/securitySeed.ts b/src/common/replicatorResources/securitySeed.ts index 1aab8b6b..b0e873bb 100644 --- a/src/common/replicatorResources/securitySeed.ts +++ b/src/common/replicatorResources/securitySeed.ts @@ -1,3 +1,4 @@ +import type { RemoteDBSettings } from "@vrtmrz/livesync-commonlib/compat/common/types"; import type { SecuritySeedResourceFactory } from "@vrtmrz/livesync-commonlib/replication"; import { LiveSyncCouchDBReplicator, @@ -5,12 +6,17 @@ import { } 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"; +import { createReplicatorDisposer, snapshotRemoteSettings, type ResourceReplicator } from "./shared"; export type SecuritySeedResourceHost = LiveSyncCouchDBReplicatorEnv & LiveSyncJournalReplicatorEnv; +/** Minimal private Replicator surface required by a Security Seed resource. */ +interface SecuritySeedReplicator extends ResourceReplicator { + getReplicationPBKDF2Salt(setting: RemoteDBSettings, refresh?: boolean): Promise>; +} + function createSecuritySeedResourceFactory( - createReplicator: () => LiveSyncCouchDBReplicator | LiveSyncJournalReplicator + createReplicator: () => SecuritySeedReplicator ): SecuritySeedResourceFactory { return (setting) => { const snapshot = snapshotRemoteSettings(setting); diff --git a/src/features/LocalDatabaseMainte/CmdLocalDatabaseMainte.ts b/src/features/LocalDatabaseMainte/CmdLocalDatabaseMainte.ts index 75b00dcd..9ce9bd92 100644 --- a/src/features/LocalDatabaseMainte/CmdLocalDatabaseMainte.ts +++ b/src/features/LocalDatabaseMainte/CmdLocalDatabaseMainte.ts @@ -737,7 +737,8 @@ Success: ${successCount}, Errored: ${errored}`; } async compactDatabase() { - const replicator = this.core.replicator as LiveSyncCouchDBReplicator; + const replicator = this.core.replicator as Partial; + if (typeof replicator?.connectRemoteCouchDBWithSetting !== "function") return; const remote = await replicator.connectRemoteCouchDBWithSetting(this.settings, false, false, true); if (!remote) { this._notice("Failed to connect to remote for compaction.", "gc-compact"); @@ -840,8 +841,14 @@ Success: ${successCount}, Errored: ${errored}`; // } // } async gcv3() { + const replicator = this.core.replicator as Partial; + if ( + this.settings.remoteType !== REMOTE_COUCHDB || + typeof replicator?.openOneShotReplication !== "function" || + typeof replicator.getConnectedDeviceList !== "function" + ) + return; if (!(await this.ensureAvailable("Garbage Collection"))) return; - const replicator = this.core.replicator as LiveSyncCouchDBReplicator; // Start one-shot replication to ensure all changes are synced before GC. const r0 = await replicator.openOneShotReplication(this.settings, false, false, "sync"); if (!r0) { @@ -854,7 +861,7 @@ Success: ${successCount}, Errored: ${errored}`; // Delete the chunk, but first verify the following: // Fetch the list of accepted nodes from the replicator. const OPTION_CANCEL = "Cancel Garbage Collection"; - const info = await this.core.replicator.getConnectedDeviceList(); + const info = await replicator.getConnectedDeviceList(); if (!info) { this._notice("No connected device information found. Cancelling Garbage Collection."); return; diff --git a/src/modules/core/ModuleReplicator.ts b/src/modules/core/ModuleReplicator.ts index 288bcb97..f14321e3 100644 --- a/src/modules/core/ModuleReplicator.ts +++ b/src/modules/core/ModuleReplicator.ts @@ -1,7 +1,7 @@ import type PouchDB from "pouchdb-core"; import { fireAndForget } from "octagonal-wheels/promises"; import { AbstractModule } from "@/modules/AbstractModule"; -import { Logger, LOG_LEVEL_NOTICE, LOG_LEVEL_INFO } from "octagonal-wheels/common/logger"; +import { Logger, LOG_LEVEL_NOTICE, LOG_LEVEL_INFO, LOG_LEVEL_VERBOSE } from "octagonal-wheels/common/logger"; import { skipIfDuplicated } from "octagonal-wheels/concurrency/lock"; import { balanceChunkPurgedDBs } from "@vrtmrz/livesync-commonlib/compat/pouchdb/chunks"; import { purgeUnreferencedChunks } from "@vrtmrz/livesync-commonlib/compat/pouchdb/chunks"; @@ -23,7 +23,13 @@ import { clearHandlers } from "@vrtmrz/livesync-commonlib/compat/replication/Syn import type { NecessaryServices } from "@vrtmrz/livesync-commonlib/compat/interfaces/ServiceModule"; import { MARK_LOG_NETWORK_ERROR } from "@vrtmrz/livesync-commonlib/compat/services/lib/logUtils"; import { usesLegacyIndexedDBAdapter } from "@/common/compatibilitySettings.ts"; -import { NO_INTERACTION, type ReplicationInteraction } from "@vrtmrz/livesync-commonlib/replication"; +import { + CENTRAL_COMPATIBILITY_REJECTION_REASONS, + NO_INTERACTION, + REMOTE_RESOURCE_KINDS, + type ReplicationFailureRequest, +} from "@vrtmrz/livesync-commonlib/replication"; +import { withOwnedRemoteResource } from "@/common/ownedRemoteResource.ts"; function isOnlineAndCanReplicate( errorManager: UnresolvedErrorManager, @@ -38,31 +44,36 @@ function isOnlineAndCanReplicate( errorManager.clearError(errorMessage); return Promise.resolve(true); } -async function canReplicateWithPBKDF2( +/** Refresh and validate the selected central provider's owned Security Seed resource. */ +async function canReplicateWithSecuritySeed( errorManager: UnresolvedErrorManager, host: NecessaryServices<"replicator" | "setting", never>, showMessage: boolean ): Promise { const currentSettings = host.services.setting.currentSettings(); - // TODO: check using PBKDF2 salt? const errorMessage = $msg("Replicator.Message.InitialiseFatalError"); - const replicator = host.services.replicator.getActiveReplicator(); - if (!replicator) { - errorManager.showError(errorMessage, showMessage ? LOG_LEVEL_NOTICE : LOG_LEVEL_INFO); - return false; - } - errorManager.clearError(errorMessage); // Showing message is false: that because be shown here. (And it is a fatal error, no way to hide it). // tagged as network error at beginning for error filtering with NetworkWarningStyles const ensureMessage = `${MARK_LOG_NETWORK_ERROR}Failed to initialise the encryption key, preventing replication.`; - // A remote database rebuild replaces the Security Seed while this process may still hold the previous one. - const ensureResult = await replicator.ensurePBKDF2Salt(currentSettings, showMessage, false); - if (!ensureResult) { + try { + const resource = await host.services.replicator.createRemoteResource( + REMOTE_RESOURCE_KINDS.SECURITY_SEED, + currentSettings + ); + if (!resource) { + errorManager.showError(errorMessage, showMessage ? LOG_LEVEL_NOTICE : LOG_LEVEL_INFO); + return false; + } + errorManager.clearError(errorMessage); + const seed = await withOwnedRemoteResource(resource, (ownedResource) => ownedResource.read()); + if (seed.length == 0) throw new Error("PBKDF2 salt (Security Seed) is empty"); + } catch (error) { + Logger(error, LOG_LEVEL_VERBOSE); errorManager.showError(ensureMessage, showMessage ? LOG_LEVEL_NOTICE : LOG_LEVEL_INFO); return false; } errorManager.clearError(ensureMessage); - return ensureResult; // is true. + return true; } export class ModuleReplicator extends AbstractModule { @@ -158,8 +169,14 @@ export class ModuleReplicator extends AbstractModule { * database again, or purge unreferenced local chunks before accepting this device again. * * @param showMessage Whether to show the recovery choices as user-facing notices. + * @param setting Detached settings used by the failed attempt. + * @param expectedContext Publication which produced the compatibility rejection. */ - async cleaned(showMessage: boolean) { + async cleaned( + showMessage: boolean, + setting: ObsidianLiveSyncSettings, + expectedContext: ReplicationFailureRequest["context"] + ) { Logger(`The remote database has been cleaned.`, showMessage ? LOG_LEVEL_NOTICE : LOG_LEVEL_INFO); await skipIfDuplicated("cleanup", async () => { const count = await purgeUnreferencedChunks(this.localDatabase.localDatabase, true); @@ -183,82 +200,97 @@ Even if you choose to clean up, you will see this option again if you exit Obsid } if (ret == CHOICE_CLEAN) { await this.services.replicator.runBoundedRemoteActivity( - async () => { - const replicator = this.services.replicator.getActiveReplicator(); - if (!(replicator instanceof LiveSyncCouchDBReplicator)) return; - const remoteDB = await replicator.connectRemoteCouchDBWithSetting( - this.settings, - this.services.API.isMobile(), - true - ); - if (typeof remoteDB == "string") { - Logger(remoteDB, LOG_LEVEL_NOTICE); - return false; - } - - try { - await purgeUnreferencedChunks(this.localDatabase.localDatabase, false); - this.localDatabase.clearCaches(); - // Perform the synchronisation once. - const replicated = await this.services.replicator.runFiniteReplicationActivity( - () => this.core.replicator.openReplication(this.settings, false, showMessage, true), - { label: "replication" } + () => + this.services.replicator.runWithActiveReplicatorContext(async (context) => { + if (context !== expectedContext) return; + const replicator = context.replicator; + if (!(replicator instanceof LiveSyncCouchDBReplicator)) return; + const remoteDB = await replicator.connectRemoteCouchDBWithSetting( + setting, + this.services.API.isMobile(), + true ); - if (replicated) { - await balanceChunkPurgedDBs(this.localDatabase.localDatabase, remoteDB.db); + if (typeof remoteDB == "string") { + Logger(remoteDB, LOG_LEVEL_NOTICE); + return false; + } + + try { await purgeUnreferencedChunks(this.localDatabase.localDatabase, false); this.localDatabase.clearCaches(); - await this.services.replicator.getActiveReplicator()?.markRemoteResolved(this.settings); - Logger( - "The local database has been cleaned up.", - showMessage ? LOG_LEVEL_NOTICE : LOG_LEVEL_INFO - ); - } else { - Logger( - "Replication has been cancelled. Please try it again.", - showMessage ? LOG_LEVEL_NOTICE : LOG_LEVEL_INFO + // Perform the synchronisation once. + const replicated = await this.services.replicator.runFiniteReplicationActivity( + () => replicator.openOneShotReplication(setting, showMessage, false, "sync", true), + { label: "replication" } ); + if (replicated) { + await balanceChunkPurgedDBs(this.localDatabase.localDatabase, remoteDB.db); + await purgeUnreferencedChunks(this.localDatabase.localDatabase, false); + this.localDatabase.clearCaches(); + await replicator.markRemoteResolved(setting); + Logger( + "The local database has been cleaned up.", + showMessage ? LOG_LEVEL_NOTICE : LOG_LEVEL_INFO + ); + } else { + Logger( + "Replication has been cancelled. Please try it again.", + showMessage ? LOG_LEVEL_NOTICE : LOG_LEVEL_INFO + ); + } + } finally { + await remoteDB.close(); } - } finally { - await remoteDB.db.close(); - } - }, + }), { label: "database-cleanup" } ); } }); } - private async onReplicationFailed( - showMessageOrInteraction: boolean | ReplicationInteraction = false, - interaction?: ReplicationInteraction - ): Promise { - // The typed ReplicationService passes the legacy visibility flag first - // and the authority second. The authority is the source of truth for - // recovery dialogues when it is present; retain the legacy boolean for - // older callers which do not provide one. - const showMessage = interaction - ? interaction.kind === "permitted" && interaction.permissions.failureRecovery - : typeof showMessageOrInteraction === "boolean" - ? showMessageOrInteraction - : showMessageOrInteraction.kind === "permitted" && showMessageOrInteraction.permissions.failureRecovery; - const activeReplicator = this.services.replicator.getActiveReplicator(); - if (!activeReplicator) { - Logger(`No active replicator found`, LOG_LEVEL_INFO); - return false; - } + private async onReplicationFailed(request: ReplicationFailureRequest): Promise { + const { context, interaction, outcome, setting, showMessage } = request; if (!showMessage) { // Automatic requests may report the failure, but they must never // enter tweak, lock, fetch, unlock, or cleanup dialogues. Logger(`Replication failed on an unattended path.`, LOG_LEVEL_INFO); return false; } - if (activeReplicator.tweakSettingsMismatched && activeReplicator.preferredTweakValue) { - await this.services.tweakValue.askResolvingMismatched(activeReplicator.preferredTweakValue); + if (interaction.kind !== "permitted" || !interaction.permissions.failureRecovery) return false; + const recovery = outcome.recoveryHint; + if (!recovery) return false; + if ( + recovery.reason === CENTRAL_COMPATIBILITY_REJECTION_REASONS.TWEAK_MISMATCH && + recovery.preferredTweakValue + ) { + await this.services.tweakValue.askResolvingMismatched( + recovery.preferredTweakValue, + async (effectiveSetting) => { + let updated = false; + await this.services.replicator.runWithActiveReplicatorContext(async (activeContext) => { + if (activeContext !== context) return; + const candidate = activeContext.replicator as typeof activeContext.replicator & { + setPreferredRemoteTweakSettings?: ( + setting: ObsidianLiveSyncSettings + ) => Promise; + }; + if (typeof candidate.setPreferredRemoteTweakSettings !== "function") return; + await candidate.setPreferredRemoteTweakSettings({ ...effectiveSetting }); + updated = true; + }); + return updated; + } + ); } else { - if (activeReplicator.remoteLockedAndDeviceNotAccepted) { - if (activeReplicator.remoteCleaned && usesLegacyIndexedDBAdapter(this.settings)) { - await this.cleaned(showMessage); + if ( + recovery.reason === CENTRAL_COMPATIBILITY_REJECTION_REASONS.NODE_LOCKED || + recovery.reason === CENTRAL_COMPATIBILITY_REJECTION_REASONS.NODE_CLEANED + ) { + if ( + recovery.reason === CENTRAL_COMPATIBILITY_REJECTION_REASONS.NODE_CLEANED && + usesLegacyIndexedDBAdapter(setting) + ) { + await this.cleaned(showMessage, setting, context); } else { const message = $msg("Replicator.Dialogue.Locked.Message"); const CHOICE_FETCH = $msg("Replicator.Dialogue.Locked.Action.Fetch"); @@ -279,8 +311,19 @@ Even if you choose to clean up, you will see this option again if you exit Obsid this.services.appLifecycle.scheduleRestart(); return false; } else if (ret == CHOICE_UNLOCK) { - await activeReplicator.markRemoteResolved(this.settings); - this._log($msg("Replicator.Dialogue.Locked.Message.Unlocked"), LOG_LEVEL_NOTICE); + let unlocked = false; + await this.services.replicator.runWithActiveReplicatorContext(async (activeContext) => { + if (activeContext !== context) return; + const replicator = activeContext.replicator as typeof activeContext.replicator & { + markRemoteResolved(setting: ObsidianLiveSyncSettings): Promise; + }; + if (typeof replicator.markRemoteResolved !== "function") return; + await replicator.markRemoteResolved(setting); + unlocked = true; + }); + if (unlocked) { + this._log($msg("Replicator.Dialogue.Locked.Message.Unlocked"), LOG_LEVEL_NOTICE); + } return false; } } @@ -360,16 +403,20 @@ Even if you choose to clean up, you will see this option again if you exit Obsid }, serviceModules: {}, }); - const canReplicateWithPBKDF2WithHost = canReplicateWithPBKDF2.bind(null, this._unresolvedErrorManager, { - services: { - context: services.context, - replicator: services.replicator, - setting: services.setting, - }, - serviceModules: {}, - }); + const canReplicateWithSecuritySeedWithHost = canReplicateWithSecuritySeed.bind( + null, + this._unresolvedErrorManager, + { + services: { + context: services.context, + replicator: services.replicator, + setting: services.setting, + }, + serviceModules: {}, + } + ); services.replication.onBeforeReplicate.addHandler(isOnlineAndCanReplicateWithHost, 10); - services.replication.onPrepareCentralRemoteReplication.addHandler(canReplicateWithPBKDF2WithHost); + services.replication.onPrepareCentralRemoteReplication.addHandler(canReplicateWithSecuritySeedWithHost); // <-- End of handlers that can be separated. services.replication.onBeforeReplicate.addHandler(this._everyBeforeReplicate.bind(this), 100); services.replication.onReplicationFailed.addHandler(this.onReplicationFailed.bind(this)); diff --git a/src/modules/core/ModuleReplicator.unit.spec.ts b/src/modules/core/ModuleReplicator.unit.spec.ts index b89750c1..baa15e46 100644 --- a/src/modules/core/ModuleReplicator.unit.spec.ts +++ b/src/modules/core/ModuleReplicator.unit.spec.ts @@ -2,6 +2,12 @@ import { describe, expect, it, vi } from "vitest"; import { createServiceContext } from "@vrtmrz/livesync-commonlib/context"; import { EVENT_SETTING_SAVED, eventHub } from "@/common/events"; import type { ObsidianLiveSyncSettings } from "@vrtmrz/livesync-commonlib/compat/common/types"; +import { + CENTRAL_COMPATIBILITY_REJECTION_REASONS, + NO_INTERACTION, + USER_INITIATED_REPLICATION_AUTHORITY, + replicationFailed, +} from "@vrtmrz/livesync-commonlib/replication"; const chunkMocks = vi.hoisted(() => ({ purgeUnreferencedChunks: vi.fn(async (_db: unknown, countOnly: boolean) => (countOnly ? 2 : 0)), @@ -18,13 +24,15 @@ import { ModuleReplicator } from "./ModuleReplicator"; describe("ModuleReplicator", () => { it("refreshes the remote Security Seed before replication", async () => { - const ensurePBKDF2Salt = vi.fn(async () => true); + const read = vi.fn(async () => new Uint8Array([1])); + const dispose = vi.fn(async () => undefined); + const createRemoteResource = vi.fn(async () => ({ read, dispose })); let prepareCentralRemoteReplication: ((showMessage: boolean) => Promise) | undefined; const services = { API: { isOnline: true }, replicator: { onBeforeReplicatorPublication: { addHandler: vi.fn() }, - getActiveReplicator: () => ({ ensurePBKDF2Salt }), + createRemoteResource, }, setting: { currentSettings: () => ({}) }, databaseEvents: { onDatabaseInitialised: { addHandler: vi.fn() } }, @@ -58,11 +66,15 @@ describe("ModuleReplicator", () => { await prepareCentralRemoteReplication!(false); - expect(ensurePBKDF2Salt).toHaveBeenCalledWith({}, false, false); + expect(createRemoteResource).toHaveBeenCalledWith("security-seed", {}); + expect(read).toHaveBeenCalledOnce(); + expect(dispose).toHaveBeenCalledOnce(); }); it("keeps online and general pre-replication handlers for P2P while skipping central-remote Security Seed preparation", async () => { - const ensurePBKDF2Salt = vi.fn(async () => true); + const read = vi.fn(async () => new Uint8Array([1])); + const dispose = vi.fn(async () => undefined); + const createRemoteResource = vi.fn(async () => ({ read, dispose })); const handlers = new Map Promise>(); const centralRemoteHandlers: Array<(...args: unknown[]) => Promise> = []; const addHandler = vi.fn((handler: (...args: unknown[]) => Promise, priority?: number) => { @@ -72,7 +84,7 @@ describe("ModuleReplicator", () => { API: { isOnline: true }, replicator: { onBeforeReplicatorPublication: { addHandler: vi.fn() }, - getActiveReplicator: () => ({ ensurePBKDF2Salt }), + createRemoteResource, }, setting: { currentSettings: () => ({}) }, databaseEvents: { onDatabaseInitialised: { addHandler: vi.fn() } }, @@ -114,10 +126,12 @@ describe("ModuleReplicator", () => { await expect(general!(false)).resolves.toBe(true); expect(generalBeforeReplicate).toHaveBeenCalledOnce(); - expect(ensurePBKDF2Salt).not.toHaveBeenCalled(); + expect(createRemoteResource).not.toHaveBeenCalled(); await expect(securitySeed!(false)).resolves.toBe(true); - expect(ensurePBKDF2Salt).toHaveBeenCalledOnce(); + expect(createRemoteResource).toHaveBeenCalledOnce(); + expect(read).toHaveBeenCalledOnce(); + expect(dispose).toHaveBeenCalledOnce(); }); it("reprocesses stored documents when the normal-file target filters change", async () => { @@ -174,12 +188,23 @@ describe("ModuleReplicator", () => { } }); - it("only permits recovery dialogue when the authority grants failure recovery", async () => { - const askResolvingMismatched = vi.fn(async () => undefined); - const activeReplicator = { + it("uses the exact failed outcome and permits dialogue only with recovery authority", async () => { + const askResolvingMismatched = vi.fn(async (..._args: unknown[]) => undefined); + const failedSetPreferred = vi.fn(async (_setting: unknown) => undefined); + const failedReplicator = { setPreferredRemoteTweakSettings: failedSetPreferred }; + const replacementSetPreferred = vi.fn(async (_setting: unknown) => undefined); + const replacementReplicator = { tweakSettingsMismatched: true, - preferredTweakValue: { customChunkSize: 60 }, + preferredTweakValue: { customChunkSize: 99 }, + setPreferredRemoteTweakSettings: replacementSetPreferred, }; + const context = { provider: {}, replicator: failedReplicator }; + const replacementContext = { provider: {}, replicator: replacementReplicator }; + const preferredTweakValue = { customChunkSize: 60 }; + const outcome = replicationFailed(new Error("mismatched"), { + reason: CENTRAL_COMPATIBILITY_REJECTION_REASONS.TWEAK_MISMATCH, + preferredTweakValue, + }); const services = { context: createServiceContext(), API: { @@ -192,7 +217,12 @@ describe("ModuleReplicator", () => { appLifecycle: { getUnresolvedMessages: { addHandler: vi.fn() }, }, - replicator: { getActiveReplicator: vi.fn(() => activeReplicator) }, + replicator: { + getActiveReplicator: vi.fn(() => replacementReplicator), + runWithActiveReplicatorContext: vi.fn(async (task: (context: unknown) => unknown) => + task(replacementContext) + ), + }, tweakValue: { askResolvingMismatched }, }; const core = { @@ -202,30 +232,137 @@ describe("ModuleReplicator", () => { } as any; const module = new ModuleReplicator(core); - await (module as any).onReplicationFailed(false); + await (module as any).onReplicationFailed({ + context, + setting: {}, + outcome, + showMessage: false, + interaction: NO_INTERACTION, + }); expect(askResolvingMismatched).not.toHaveBeenCalled(); - await (module as any).onReplicationFailed(true, { - kind: "permitted", - permissions: { - peerSelection: true, - localPeerAdmission: true, - configurationExchange: true, - failureRecovery: false, + await (module as any).onReplicationFailed({ + context, + setting: {}, + outcome, + showMessage: false, + interaction: { + kind: "permitted", + permissions: { ...USER_INITIATED_REPLICATION_AUTHORITY.permissions, failureRecovery: false }, }, }); expect(askResolvingMismatched).not.toHaveBeenCalled(); - await (module as any).onReplicationFailed(true, { - kind: "permitted", - permissions: { - peerSelection: true, - localPeerAdmission: true, - configurationExchange: true, - failureRecovery: true, - }, + await (module as any).onReplicationFailed({ + context, + setting: {}, + outcome, + showMessage: true, + interaction: USER_INITIATED_REPLICATION_AUTHORITY, }); - expect(askResolvingMismatched).toHaveBeenCalledOnce(); + expect(askResolvingMismatched).toHaveBeenCalledWith(preferredTweakValue, expect.any(Function)); + const updatePreferredRemote = askResolvingMismatched.mock.calls[0][1] as ( + setting: Record + ) => Promise; + await expect(updatePreferredRemote({ customChunkSize: 64 } as any)).resolves.toBe(false); + expect(failedSetPreferred).not.toHaveBeenCalled(); + expect(replacementSetPreferred).not.toHaveBeenCalled(); + }); + + it("writes a mismatch decision only through the still-active failed publication", async () => { + const setPreferredRemoteTweakSettings = vi.fn(async (_setting: unknown) => undefined); + const context = { provider: {}, replicator: { setPreferredRemoteTweakSettings } }; + let updatePreferredRemote: + | ((setting: Record) => Promise) + | undefined; + const askResolvingMismatched = vi.fn( + async (_preferred: unknown, update: (setting: Record) => Promise) => { + updatePreferredRemote = update; + } + ); + const services = { + context: createServiceContext(), + API: { + addLog: vi.fn(), + addCommand: vi.fn(), + registerWindow: vi.fn(), + addRibbonIcon: vi.fn(), + registerProtocolHandler: vi.fn(), + }, + appLifecycle: { getUnresolvedMessages: { addHandler: vi.fn() } }, + replicator: { + runWithActiveReplicatorContext: vi.fn(async (task: (activeContext: unknown) => unknown) => + task(context) + ), + }, + tweakValue: { askResolvingMismatched }, + }; + const module = new ModuleReplicator({ _services: services, services, settings: {} } as any); + + await (module as any).onReplicationFailed({ + context, + setting: {}, + outcome: replicationFailed(new Error("mismatched"), { + reason: CENTRAL_COMPATIBILITY_REJECTION_REASONS.TWEAK_MISMATCH, + preferredTweakValue: { customChunkSize: 60 }, + }), + showMessage: true, + interaction: USER_INITIATED_REPLICATION_AUTHORITY, + }); + + const effectiveSetting = { customChunkSize: 64 }; + await expect(updatePreferredRemote?.(effectiveSetting)).resolves.toBe(true); + expect(setPreferredRemoteTweakSettings).toHaveBeenCalledWith(effectiveSetting); + expect(setPreferredRemoteTweakSettings.mock.calls[0][0]).not.toBe(effectiveSetting); + }); + + it("does not apply an unlock selected for a replaced failed publication", async () => { + const failedMarkResolved = vi.fn(async () => undefined); + const replacementMarkResolved = vi.fn(async () => undefined); + const failedContext = { provider: {}, replicator: { markRemoteResolved: failedMarkResolved } }; + const replacementContext = { provider: {}, replicator: { markRemoteResolved: replacementMarkResolved } }; + const runWithActiveReplicatorContext = vi.fn(async (task: (context: unknown) => unknown) => + task(replacementContext) + ); + const services = { + context: createServiceContext(), + API: { + addLog: vi.fn(), + addCommand: vi.fn(), + registerWindow: vi.fn(), + addRibbonIcon: vi.fn(), + registerProtocolHandler: vi.fn(), + }, + appLifecycle: { + getUnresolvedMessages: { addHandler: vi.fn() }, + scheduleRestart: vi.fn(), + }, + replicator: { runWithActiveReplicatorContext }, + }; + const core = { + _services: services, + services, + settings: {}, + confirm: { + askSelectStringDialogue: vi.fn(async (_message: string, choices: string[]) => choices[1]), + }, + rebuilder: { scheduleFetch: vi.fn() }, + } as any; + const module = new ModuleReplicator(core); + + await (module as any).onReplicationFailed({ + context: failedContext, + setting: {}, + outcome: replicationFailed(new Error("locked"), { + reason: CENTRAL_COMPATIBILITY_REJECTION_REASONS.NODE_LOCKED, + }), + showMessage: true, + interaction: USER_INITIATED_REPLICATION_AUTHORITY, + }); + + expect(runWithActiveReplicatorContext).toHaveBeenCalledOnce(); + expect(failedMarkResolved).not.toHaveBeenCalled(); + expect(replacementMarkResolved).not.toHaveBeenCalled(); }); }); @@ -240,14 +377,20 @@ describe("compatibility: cleaned-remote reconciliation for IndexedDB clients", ( } }); const runFiniteReplicationActivity = vi.fn(async (task: () => unknown) => await task()); - const openReplication = vi.fn(async () => true); + const openOneShotReplication = vi.fn(async () => true); const remoteDatabase = { close: vi.fn(async () => undefined), }; + const close = vi.fn(async () => undefined); const activeReplicator = Object.assign(new LiveSyncCouchDBReplicator({} as any), { - connectRemoteCouchDBWithSetting: vi.fn(async () => ({ db: remoteDatabase })), + connectRemoteCouchDBWithSetting: vi.fn(async () => ({ db: remoteDatabase, close })), + openOneShotReplication, markRemoteResolved: vi.fn(async () => undefined), }); + const expectedContext = { provider: {}, replicator: activeReplicator }; + const runWithActiveReplicatorContext = vi.fn(async (task: (context: unknown) => unknown) => + task(expectedContext) + ); const services = { context: createServiceContext(), API: { @@ -266,6 +409,7 @@ describe("compatibility: cleaned-remote reconciliation for IndexedDB clients", ( getActiveReplicator: vi.fn(() => activeReplicator), runBoundedRemoteActivity, runFiniteReplicationActivity, + runWithActiveReplicatorContext, }, }; const localDatabase = { @@ -278,11 +422,10 @@ describe("compatibility: cleaned-remote reconciliation for IndexedDB clients", ( settings: {}, localDatabase, confirm: { confirmWithMessage: vi.fn(async () => "Cleanup") }, - replicator: { openReplication }, } as any; const module = new ModuleReplicator(core); - await module.cleaned(true); + await module.cleaned(true, {} as ObsidianLiveSyncSettings, expectedContext as never); expect(runBoundedRemoteActivity).toHaveBeenCalledWith(expect.any(Function), { label: "database-cleanup", @@ -290,12 +433,13 @@ describe("compatibility: cleaned-remote reconciliation for IndexedDB clients", ( expect(runFiniteReplicationActivity).toHaveBeenCalledWith(expect.any(Function), { label: "replication", }); - expect(openReplication).toHaveBeenCalledOnce(); - expect(openReplication.mock.invocationCallOrder[0]).toBeLessThan(activityFinished.mock.invocationCallOrder[0]); - expect(chunkMocks.balanceChunkPurgedDBs).toHaveBeenCalledOnce(); - expect(remoteDatabase.close).toHaveBeenCalledOnce(); - expect(remoteDatabase.close.mock.invocationCallOrder[0]).toBeLessThan( + expect(runWithActiveReplicatorContext).toHaveBeenCalledOnce(); + expect(openOneShotReplication).toHaveBeenCalledOnce(); + expect(openOneShotReplication.mock.invocationCallOrder[0]).toBeLessThan( activityFinished.mock.invocationCallOrder[0] ); + expect(chunkMocks.balanceChunkPurgedDBs).toHaveBeenCalledOnce(); + expect(close).toHaveBeenCalledOnce(); + expect(close.mock.invocationCallOrder[0]).toBeLessThan(activityFinished.mock.invocationCallOrder[0]); }); }); diff --git a/src/modules/coreFeatures/ModuleResolveMismatchedTweaks.ts b/src/modules/coreFeatures/ModuleResolveMismatchedTweaks.ts index e94610ee..6a0121a7 100644 --- a/src/modules/coreFeatures/ModuleResolveMismatchedTweaks.ts +++ b/src/modules/coreFeatures/ModuleResolveMismatchedTweaks.ts @@ -232,19 +232,24 @@ export class ModuleResolvingMismatchedTweaks extends AbstractModule { return CHOICES[retKey]; } - async _askResolvingMismatchedTweaks(): Promise<"OK" | "CHECKAGAIN" | "IGNORE"> { - if (!this.core.replicator.tweakSettingsMismatched) { - return "OK"; - } - const tweaks = this.core.replicator.preferredTweakValue; - if (!tweaks) { - return "IGNORE"; - } - const [conf, rebuildRequired] = await this.services.tweakValue.checkAndAskResolvingMismatched(tweaks); + async _askResolvingMismatchedTweaks( + preferredSource: TweakValues, + updatePreferredRemote?: (setting: ObsidianLiveSyncSettings) => Promise + ): Promise<"OK" | "CHECKAGAIN" | "IGNORE"> { + const [conf, rebuildRequired] = + await this.services.tweakValue.checkAndAskResolvingMismatched(preferredSource); if (!conf) return "IGNORE"; + const updateRemote = async () => { + if (updatePreferredRemote) return await updatePreferredRemote(this.settings); + const candidate = this.core.replicator; + if (typeof candidate.setPreferredRemoteTweakSettings !== "function") return false; + await candidate.setPreferredRemoteTweakSettings(this.settings); + return true; + }; + if (conf === true) { - await this.core.replicator.setPreferredRemoteTweakSettings(this.settings); + if (!(await updateRemote())) return "IGNORE"; if (rebuildRequired) { await this.core.rebuilder.$rebuildRemote(); } @@ -261,7 +266,7 @@ export class ModuleResolvingMismatchedTweaks extends AbstractModule { // chunk-generation managers now so hash and splitter changes take effect before retrying. await this.localDatabase.managers.reinitialise(); } - await this.core.replicator.setPreferredRemoteTweakSettings(this.settings); + if (!(await updateRemote())) return "IGNORE"; if (rebuildRequired) { await this.core.rebuilder.$fetchLocal(); } diff --git a/src/modules/coreFeatures/ModuleResolveMismatchedTweaks.unit.spec.ts b/src/modules/coreFeatures/ModuleResolveMismatchedTweaks.unit.spec.ts index d82fec44..a340bf24 100644 --- a/src/modules/coreFeatures/ModuleResolveMismatchedTweaks.unit.spec.ts +++ b/src/modules/coreFeatures/ModuleResolveMismatchedTweaks.unit.spec.ts @@ -272,13 +272,18 @@ describe("ModuleResolvingMismatchedTweaks", () => { reinitialise.mockImplementation(async () => { calls.push("reinitialise"); }); + const updatePreferredRemote = vi.fn(async () => { + calls.push("set-preferred"); + return true; + }); - const result = await module._askResolvingMismatchedTweaks(); + const result = await module._askResolvingMismatchedTweaks(preferred, updatePreferredRemote); expect(result).toBe("CHECKAGAIN"); expect(core.settings).toBe(initialSettings); expect(core.settings.hashAlg).toBe("xxhash32"); expect(calls).toEqual(["save", "reinitialise", "set-preferred"]); + expect(core.replicator.setPreferredRemoteTweakSettings).not.toHaveBeenCalled(); }); }); diff --git a/src/modules/essential/ModuleMigration.ts b/src/modules/essential/ModuleMigration.ts index a878c301..6deb8123 100644 --- a/src/modules/essential/ModuleMigration.ts +++ b/src/modules/essential/ModuleMigration.ts @@ -37,6 +37,16 @@ type ErrorInfo = { const INCOMPLETE_DOCUMENT_NOTICE_GROUP = "startup-integrity-check"; +interface CompromisedChunkCounter { + countCompromisedChunks(): Promise; +} + +function hasCompromisedChunkCounter(value: object | undefined): value is CompromisedChunkCounter { + return ( + value !== undefined && "countCompromisedChunks" in value && typeof value.countCompromisedChunks === "function" + ); +} + export class ModuleMigration extends AbstractModule { constructor( core: LiveSyncCore, @@ -253,7 +263,10 @@ export class ModuleMigration extends AbstractModule { // Check local database for compromised chunks const localCompromised = await countCompromisedChunks(this.localDatabase.localDatabase); const remote = this.services.replicator.getActiveReplicator(); - const remoteCompromised = this.services.API.isOnline ? await remote?.countCompromisedChunks() : 0; + const remoteCompromised = + this.services.API.isOnline && hasCompromisedChunkCounter(remote) + ? await remote.countCompromisedChunks() + : 0; if (localCompromised === false) { Logger(`Failed to count compromised chunks in local database`, LOG_LEVEL_NOTICE); return false; diff --git a/src/modules/features/SettingDialogue/PaneMaintenance.ts b/src/modules/features/SettingDialogue/PaneMaintenance.ts index a58825d6..aed4e8ad 100644 --- a/src/modules/features/SettingDialogue/PaneMaintenance.ts +++ b/src/modules/features/SettingDialogue/PaneMaintenance.ts @@ -17,8 +17,8 @@ export function paneMaintenance( paneEl: HTMLElement, { addPanel }: PageFunctions ): void { - const isRemoteLockedAndDeviceNotAccepted = () => this.core?.replicator?.remoteLockedAndDeviceNotAccepted; - const isRemoteLocked = () => this.core?.replicator?.remoteLocked; + const isRemoteLockedAndDeviceNotAccepted = () => !!this.core?.replicator?.remoteLockedAndDeviceNotAccepted; + const isRemoteLocked = () => !!this.core?.replicator?.remoteLocked; // if (this.plugin?.replicator?.remoteLockedAndDeviceNotAccepted) { this.createEl( paneEl, diff --git a/src/modules/services/ObsidianServices.unit.spec.ts b/src/modules/services/ObsidianServices.unit.spec.ts index a46c05c3..e428a492 100644 --- a/src/modules/services/ObsidianServices.unit.spec.ts +++ b/src/modules/services/ObsidianServices.unit.spec.ts @@ -37,7 +37,11 @@ describe("ObsidianReplicatorService", () => { allowSleepDuringSynchronisationOnDesktop: false, }), }, - appLifecycleService: { onSuspending: handler(), getUnresolvedMessages: handler() }, + appLifecycleService: { + onSuspending: handler(), + onUnload: handler(), + getUnresolvedMessages: handler(), + }, databaseEventService: { onResetDatabase: handler(), onDatabaseInitialisation: handler(), @@ -73,7 +77,11 @@ describe("ObsidianReplicatorService", () => { allowSleepDuringSynchronisationOnDesktop: true, }), }, - appLifecycleService: { onSuspending: handler(), getUnresolvedMessages: handler() }, + appLifecycleService: { + onSuspending: handler(), + onUnload: handler(), + getUnresolvedMessages: handler(), + }, databaseEventService: { onResetDatabase: handler(), onDatabaseInitialisation: handler(),