mirror of
https://github.com/vrtmrz/obsidian-livesync.git
synced 2026-09-16 23:57:06 +00:00
Restore Sync now progress and recovery policy
This commit is contained in:
@@ -24,6 +24,7 @@ import {
|
||||
CENTRAL_COMPATIBILITY_REJECTION_REASONS,
|
||||
isReplicationCompleted,
|
||||
NO_INTERACTION,
|
||||
REPLICATION_PROGRESS_PRESENTATIONS,
|
||||
REMOTE_RESOURCE_KINDS,
|
||||
USER_INITIATED_REPLICATION_AUTHORITY,
|
||||
} from "@vrtmrz/livesync-commonlib/replication";
|
||||
@@ -173,6 +174,7 @@ export async function runCommand(options: CLIOptions, context: CLICommandContext
|
||||
writeStdoutLine(standardIo, "[Command] sync");
|
||||
const result = await core.services.replication.replicateUserInitiated({
|
||||
trigger: "manual",
|
||||
progressPresentation: REPLICATION_PROGRESS_PRESENTATIONS.NOTICE,
|
||||
interaction: USER_INITIATED_REPLICATION_AUTHORITY,
|
||||
});
|
||||
if (!isReplicationCompleted(result)) {
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
REMOTE_RESOURCE_KINDS,
|
||||
CENTRAL_COMPATIBILITY_REJECTION_REASONS,
|
||||
REPLICATION_COMPLETED,
|
||||
REPLICATION_PROGRESS_PRESENTATIONS,
|
||||
replicationFailed,
|
||||
} from "@vrtmrz/livesync-commonlib/replication";
|
||||
|
||||
@@ -265,6 +266,21 @@ describe("runCommand abnormal cases", () => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("retains visible progress for the interactive sync command", async () => {
|
||||
const core = createCoreMock();
|
||||
|
||||
await expect(
|
||||
runCommand(makeOptions("sync", []), {
|
||||
...context,
|
||||
core,
|
||||
})
|
||||
).resolves.toBe(true);
|
||||
|
||||
expect(core.services.replication.replicateUserInitiated).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ progressPresentation: REPLICATION_PROGRESS_PRESENTATIONS.NOTICE })
|
||||
);
|
||||
});
|
||||
|
||||
it("reports a lock from the exact sync outcome without inspecting a replacement Replicator", async () => {
|
||||
const core = createCoreMock();
|
||||
core.services.replication.replicateUserInitiated.mockResolvedValue(
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
CAPABILITY_NOT_APPLICABLE,
|
||||
CENTRAL_REMOTE_REPLICATION_READINESS,
|
||||
NO_INTERACTION,
|
||||
REPLICATION_PROGRESS_PRESENTATIONS,
|
||||
REMOTE_RESOURCE_KINDS,
|
||||
defineReplicatorProviderDefinitions,
|
||||
supportedOpenReplicationContinuous,
|
||||
@@ -72,7 +73,7 @@ const couchDBUserInitiatedOneShot: UserInitiatedOneShotRunner = async (instance,
|
||||
return await runOneShotWithOutcome(
|
||||
instance,
|
||||
setting,
|
||||
request.interaction.kind === "permitted" && request.interaction.permissions.failureRecovery
|
||||
request.progressPresentation === REPLICATION_PROGRESS_PRESENTATIONS.NOTICE
|
||||
);
|
||||
};
|
||||
|
||||
@@ -85,7 +86,7 @@ const objectStorageUserInitiatedOneShot: UserInitiatedOneShotRunner = async (ins
|
||||
return await runOneShotWithOutcome(
|
||||
instance,
|
||||
setting,
|
||||
request.interaction.kind === "permitted" && request.interaction.permissions.failureRecovery
|
||||
request.progressPresentation === REPLICATION_PROGRESS_PRESENTATIONS.NOTICE
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
CAPABILITY_SUPPORT_KINDS,
|
||||
NO_INTERACTION,
|
||||
REPLICATION_COMPLETED,
|
||||
REPLICATION_PROGRESS_PRESENTATIONS,
|
||||
REMOTE_RESOURCE_KINDS,
|
||||
USER_INITIATED_REPLICATION_AUTHORITY,
|
||||
} from "@vrtmrz/livesync-commonlib/replication";
|
||||
@@ -135,6 +136,7 @@ describe("central Replicator provider definitions", () => {
|
||||
await expect(
|
||||
couchDB.userInitiatedOneShot.run(couchInstance, setting, {
|
||||
trigger: "manual",
|
||||
progressPresentation: REPLICATION_PROGRESS_PRESENTATIONS.NOTICE,
|
||||
interaction: USER_INITIATED_REPLICATION_AUTHORITY,
|
||||
})
|
||||
).resolves.toBe(REPLICATION_COMPLETED);
|
||||
@@ -149,6 +151,40 @@ describe("central Replicator provider definitions", () => {
|
||||
expect(constructorMocks.objectStorageOneShot).toHaveBeenCalledWith(setting, false);
|
||||
});
|
||||
|
||||
it.each([
|
||||
["CouchDB", REMOTE_COUCHDB],
|
||||
["Object Storage", REMOTE_MINIO],
|
||||
] as const)("maps %s progress presentation independently of recovery authority", async (label, remoteType) => {
|
||||
const definitions = createCentralReplicatorProviderDefinitions({} as never);
|
||||
const definition = definitions.get(remoteType)!;
|
||||
if (definition.userInitiatedOneShot.kind !== CAPABILITY_SUPPORT_KINDS.SUPPORTED) {
|
||||
throw new Error(`${label} OneShot is unavailable`);
|
||||
}
|
||||
const setting = Object.assign(createNewVaultSettings(), { remoteType });
|
||||
const openOneShotReplicationWithOutcome = vi.fn(async () => REPLICATION_COMPLETED);
|
||||
const instance = {
|
||||
initializeDatabaseForReplication: vi.fn(async () => true),
|
||||
openReplication: vi.fn(async () => true),
|
||||
terminateSync: vi.fn(),
|
||||
closeReplication: vi.fn(),
|
||||
openOneShotReplicationWithOutcome,
|
||||
};
|
||||
|
||||
await definition.userInitiatedOneShot.run(instance, setting, {
|
||||
trigger: "manual",
|
||||
progressPresentation: REPLICATION_PROGRESS_PRESENTATIONS.QUIET,
|
||||
interaction: USER_INITIATED_REPLICATION_AUTHORITY,
|
||||
});
|
||||
await definition.userInitiatedOneShot.run(instance, setting, {
|
||||
trigger: "manual",
|
||||
progressPresentation: REPLICATION_PROGRESS_PRESENTATIONS.NOTICE,
|
||||
interaction: USER_INITIATED_REPLICATION_AUTHORITY,
|
||||
});
|
||||
|
||||
expect(openOneShotReplicationWithOutcome).toHaveBeenNthCalledWith(1, setting, false);
|
||||
expect(openOneShotReplicationWithOutcome).toHaveBeenNthCalledWith(2, setting, true);
|
||||
});
|
||||
|
||||
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)!;
|
||||
@@ -172,6 +208,7 @@ describe("central Replicator provider definitions", () => {
|
||||
|
||||
const couchOutcome = await couchDB.userInitiatedOneShot.run(couchInstance, setting, {
|
||||
trigger: "manual",
|
||||
progressPresentation: REPLICATION_PROGRESS_PRESENTATIONS.NOTICE,
|
||||
interaction: USER_INITIATED_REPLICATION_AUTHORITY,
|
||||
});
|
||||
const objectStorageOutcome = await objectStorage.unattendedOneShot.run(objectStorageInstance, setting, {
|
||||
@@ -201,6 +238,7 @@ describe("central Replicator provider definitions", () => {
|
||||
|
||||
const outcome = await couchDB.userInitiatedOneShot.run(incompleteInstance, setting, {
|
||||
trigger: "manual",
|
||||
progressPresentation: REPLICATION_PROGRESS_PRESENTATIONS.NOTICE,
|
||||
interaction: USER_INITIATED_REPLICATION_AUTHORITY,
|
||||
});
|
||||
|
||||
|
||||
@@ -24,7 +24,10 @@
|
||||
import { LOG_LEVEL_NOTICE, Logger } from "octagonal-wheels/common/logger";
|
||||
import type { LiveSyncBaseCore } from "@/LiveSyncBaseCore.ts";
|
||||
import { $msg as translateMessage } from "@/common/translation";
|
||||
import { USER_INITIATED_REPLICATION_AUTHORITY } from "@vrtmrz/livesync-commonlib/replication";
|
||||
import {
|
||||
REPLICATION_PROGRESS_PRESENTATIONS,
|
||||
USER_INITIATED_REPLICATION_AUTHORITY,
|
||||
} from "@vrtmrz/livesync-commonlib/replication";
|
||||
export let plugin: ObsidianLiveSyncPlugin;
|
||||
export let core :LiveSyncBaseCore;
|
||||
// $: core = plugin.core;
|
||||
@@ -107,6 +110,7 @@
|
||||
async function replicate() {
|
||||
await core.services.replication.replicateUserInitiated({
|
||||
trigger: "manual",
|
||||
progressPresentation: REPLICATION_PROGRESS_PRESENTATIONS.NOTICE,
|
||||
interaction: USER_INITIATED_REPLICATION_AUTHORITY,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -4,7 +4,10 @@ import { fireAndForget } from "octagonal-wheels/promises";
|
||||
import { AbstractModule } from "@/modules/AbstractModule";
|
||||
import { $msg } from "@/common/translation";
|
||||
import { copyFileDatabaseInfo } from "@/serviceFeatures/fileDatabaseInfo";
|
||||
import { USER_INITIATED_REPLICATION_AUTHORITY } from "@vrtmrz/livesync-commonlib/replication";
|
||||
import {
|
||||
REPLICATION_PROGRESS_PRESENTATIONS,
|
||||
USER_INITIATED_REPLICATION_AUTHORITY,
|
||||
} from "@vrtmrz/livesync-commonlib/replication";
|
||||
// Separated Module for basic menu commands, which are not related to obsidian specific features. It is expected to be used in other platforms with minimal changes.
|
||||
// However, it is odd that it has here at all; it really ought to be in each respective feature. It will likely be moved eventually. Until now, addCommand pointed to Obsidian's version.
|
||||
export class ModuleBasicMenu extends AbstractModule {
|
||||
@@ -15,6 +18,7 @@ export class ModuleBasicMenu extends AbstractModule {
|
||||
callback: async () => {
|
||||
await this.services.replication.replicateUserInitiated({
|
||||
trigger: "manual",
|
||||
progressPresentation: REPLICATION_PROGRESS_PRESENTATIONS.QUIET,
|
||||
interaction: USER_INITIATED_REPLICATION_AUTHORITY,
|
||||
});
|
||||
},
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { Command } from "@/deps";
|
||||
import {
|
||||
REPLICATION_PROGRESS_PRESENTATIONS,
|
||||
USER_INITIATED_REPLICATION_AUTHORITY,
|
||||
} from "@vrtmrz/livesync-commonlib/replication";
|
||||
import { ModuleBasicMenu } from "./ModuleBasicMenu";
|
||||
|
||||
type RegisteredCommand = Command & {
|
||||
@@ -124,6 +128,19 @@ describe("ModuleBasicMenu command palette", () => {
|
||||
expect(fixture.getCommand("livesync-runbatch").name).toBe("Apply pending changes now");
|
||||
});
|
||||
|
||||
it("keeps Sync now progress quiet while retaining failure-recovery authority", async () => {
|
||||
const fixture = createFixture();
|
||||
|
||||
await fixture.module._everyOnloadStart();
|
||||
await fixture.getCommand("livesync-replicate").callback?.();
|
||||
|
||||
expect(fixture.services.replication.replicateUserInitiated).toHaveBeenCalledWith({
|
||||
trigger: "manual",
|
||||
progressPresentation: REPLICATION_PROGRESS_PRESENTATIONS.QUIET,
|
||||
interaction: USER_INITIATED_REPLICATION_AUTHORITY,
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps maintenance commands out of the normal palette", async () => {
|
||||
const fixture = createFixture();
|
||||
|
||||
|
||||
@@ -2,7 +2,10 @@ import { addIcon } from "@/deps.ts";
|
||||
import { $msg } from "@/common/translation";
|
||||
import type { LiveSyncCore } from "@/main.ts";
|
||||
import { AbstractModule } from "@/modules/AbstractModule.ts";
|
||||
import { USER_INITIATED_REPLICATION_AUTHORITY } from "@vrtmrz/livesync-commonlib/replication";
|
||||
import {
|
||||
REPLICATION_PROGRESS_PRESENTATIONS,
|
||||
USER_INITIATED_REPLICATION_AUTHORITY,
|
||||
} from "@vrtmrz/livesync-commonlib/replication";
|
||||
// Obsidian specific menu commands.
|
||||
export class ModuleObsidianMenu extends AbstractModule {
|
||||
_everyOnloadStart(): Promise<boolean> {
|
||||
@@ -20,6 +23,7 @@ export class ModuleObsidianMenu extends AbstractModule {
|
||||
this.addRibbonIcon("replicate", $msg("moduleObsidianMenu.replicate"), async () => {
|
||||
await this.services.replication.replicateUserInitiated({
|
||||
trigger: "manual",
|
||||
progressPresentation: REPLICATION_PROGRESS_PRESENTATIONS.NOTICE,
|
||||
interaction: USER_INITIATED_REPLICATION_AUTHORITY,
|
||||
});
|
||||
}).addClass("livesync-ribbon-replicate");
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("@/deps.ts", () => ({ addIcon: vi.fn() }));
|
||||
|
||||
import {
|
||||
REPLICATION_PROGRESS_PRESENTATIONS,
|
||||
USER_INITIATED_REPLICATION_AUTHORITY,
|
||||
} from "@vrtmrz/livesync-commonlib/replication";
|
||||
import { ModuleObsidianMenu } from "./ModuleObsidianMenu";
|
||||
|
||||
describe("ModuleObsidianMenu ribbon", () => {
|
||||
it("retains visible progress and full interaction authority", async () => {
|
||||
let runRibbonAction: (() => Promise<void>) | undefined;
|
||||
const addClass = vi.fn();
|
||||
const replicateUserInitiated = vi.fn(async () => ({ status: "completed" as const }));
|
||||
const services = {
|
||||
API: {
|
||||
addLog: vi.fn(),
|
||||
addCommand: vi.fn(),
|
||||
registerWindow: vi.fn(),
|
||||
registerProtocolHandler: vi.fn(),
|
||||
addRibbonIcon: vi.fn((_icon: string, _title: string, callback: () => Promise<void>) => {
|
||||
runRibbonAction = callback;
|
||||
return { addClass };
|
||||
}),
|
||||
},
|
||||
replication: { replicateUserInitiated },
|
||||
};
|
||||
const module = new ModuleObsidianMenu({ _services: services, services } as never);
|
||||
|
||||
await module._everyOnloadStart();
|
||||
await runRibbonAction?.();
|
||||
|
||||
expect(replicateUserInitiated).toHaveBeenCalledWith({
|
||||
trigger: "manual",
|
||||
progressPresentation: REPLICATION_PROGRESS_PRESENTATIONS.NOTICE,
|
||||
interaction: USER_INITIATED_REPLICATION_AUTHORITY,
|
||||
});
|
||||
expect(addClass).toHaveBeenCalledWith("livesync-ribbon-replicate");
|
||||
});
|
||||
});
|
||||
@@ -5,6 +5,7 @@ import { balanceChunkPurgedDBs, purgeUnreferencedChunks } from "@vrtmrz/livesync
|
||||
import { LiveSyncCouchDBReplicator } from "@vrtmrz/livesync-commonlib/compat/replication/couchdb/LiveSyncReplicator";
|
||||
import {
|
||||
CENTRAL_COMPATIBILITY_REJECTION_REASONS,
|
||||
REPLICATION_PROGRESS_PRESENTATIONS,
|
||||
type ReplicatorInstance,
|
||||
type ReplicationFailureRequest,
|
||||
} from "@vrtmrz/livesync-commonlib/replication";
|
||||
@@ -52,11 +53,11 @@ function canMarkRemoteResolved(replicator: ReplicatorInstance): replicator is Re
|
||||
*/
|
||||
export function createCentralCompatibilityRecovery(context: CentralCompatibilityRecoveryContext) {
|
||||
async function reconcileCleanedRemote(
|
||||
showMessage: boolean,
|
||||
showProgress: boolean,
|
||||
setting: ObsidianLiveSyncSettings,
|
||||
expectedContext: ReplicationFailureRequest["context"]
|
||||
) {
|
||||
Logger("The remote database has been cleaned.", showMessage ? LOG_LEVEL_NOTICE : LOG_LEVEL_INFO);
|
||||
Logger("The remote database has been cleaned.", showProgress ? LOG_LEVEL_NOTICE : LOG_LEVEL_INFO);
|
||||
await skipIfDuplicated("cleanup", async () => {
|
||||
const count = await purgeUnreferencedChunks(context.getLocalDatabase().localDatabase, true);
|
||||
const message = `The remote database has been cleaned up.
|
||||
@@ -100,7 +101,7 @@ Even if you choose to clean up, you will see this option again if you exit Obsid
|
||||
await purgeUnreferencedChunks(localDatabase.localDatabase, false);
|
||||
localDatabase.clearCaches();
|
||||
const replicated = await context.services.replicator.runFiniteReplicationActivity(
|
||||
() => replicator.openOneShotReplication(setting, showMessage, false, "sync", true),
|
||||
() => replicator.openOneShotReplication(setting, showProgress, false, "sync", true),
|
||||
{ label: "replication" }
|
||||
);
|
||||
if (replicated) {
|
||||
@@ -110,12 +111,12 @@ Even if you choose to clean up, you will see this option again if you exit Obsid
|
||||
await replicator.markRemoteResolved(setting);
|
||||
Logger(
|
||||
"The local database has been cleaned up.",
|
||||
showMessage ? LOG_LEVEL_NOTICE : LOG_LEVEL_INFO
|
||||
showProgress ? LOG_LEVEL_NOTICE : LOG_LEVEL_INFO
|
||||
);
|
||||
} else {
|
||||
Logger(
|
||||
"Replication has been cancelled. Please try it again.",
|
||||
showMessage ? LOG_LEVEL_NOTICE : LOG_LEVEL_INFO
|
||||
showProgress ? LOG_LEVEL_NOTICE : LOG_LEVEL_INFO
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
@@ -128,14 +129,15 @@ Even if you choose to clean up, you will see this option again if you exit Obsid
|
||||
}
|
||||
|
||||
async function handleReplicationFailure(request: ReplicationFailureRequest): Promise<boolean> {
|
||||
const { context: failedContext, interaction, outcome, setting, showMessage } = request;
|
||||
if (!showMessage) {
|
||||
const { context: failedContext, interaction, outcome, progressPresentation, setting } = request;
|
||||
const showProgress = progressPresentation === REPLICATION_PROGRESS_PRESENTATIONS.NOTICE;
|
||||
if (interaction.kind === "forbidden") {
|
||||
// Automatic requests may report the failure, but must not enter
|
||||
// tweak, lock, fetch, unlock, or cleanup dialogues.
|
||||
Logger("Replication failed on an unattended path.", LOG_LEVEL_INFO);
|
||||
return false;
|
||||
}
|
||||
if (interaction.kind !== "permitted" || !interaction.permissions.failureRecovery) return false;
|
||||
if (!interaction.permissions.failureRecovery) return false;
|
||||
const recovery = outcome.recoveryHint;
|
||||
if (!recovery) return false;
|
||||
|
||||
@@ -169,7 +171,7 @@ Even if you choose to clean up, you will see this option again if you exit Obsid
|
||||
recovery.reason === CENTRAL_COMPATIBILITY_REJECTION_REASONS.NODE_CLEANED &&
|
||||
usesLegacyIndexedDBAdapter(setting)
|
||||
) {
|
||||
await reconcileCleanedRemote(showMessage, setting, failedContext);
|
||||
await reconcileCleanedRemote(showProgress, setting, failedContext);
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import { defaultLogger, LOG_LEVEL_INFO, LOG_LEVEL_NOTICE, setGlobalLogFunction }
|
||||
import {
|
||||
CENTRAL_COMPATIBILITY_REJECTION_REASONS,
|
||||
NO_INTERACTION,
|
||||
REPLICATION_PROGRESS_PRESENTATIONS,
|
||||
USER_INITIATED_REPLICATION_AUTHORITY,
|
||||
replicationFailed,
|
||||
} from "@vrtmrz/livesync-commonlib/replication";
|
||||
@@ -43,7 +44,7 @@ describe("central compatibility recovery", () => {
|
||||
context: { provider: {}, replicator: {} },
|
||||
setting: {},
|
||||
outcome: replicationFailed(new Error("provider failed")),
|
||||
showMessage: false,
|
||||
progressPresentation: REPLICATION_PROGRESS_PRESENTATIONS.QUIET,
|
||||
interaction: NO_INTERACTION,
|
||||
} as never)
|
||||
).resolves.toBe(false);
|
||||
@@ -93,7 +94,7 @@ describe("central compatibility recovery", () => {
|
||||
context: failedContext,
|
||||
setting: {},
|
||||
outcome,
|
||||
showMessage: false,
|
||||
progressPresentation: REPLICATION_PROGRESS_PRESENTATIONS.QUIET,
|
||||
interaction: NO_INTERACTION,
|
||||
} as never);
|
||||
expect(askResolvingMismatched).not.toHaveBeenCalled();
|
||||
@@ -102,7 +103,7 @@ describe("central compatibility recovery", () => {
|
||||
context: failedContext,
|
||||
setting: {},
|
||||
outcome,
|
||||
showMessage: false,
|
||||
progressPresentation: REPLICATION_PROGRESS_PRESENTATIONS.QUIET,
|
||||
interaction: {
|
||||
kind: "permitted",
|
||||
permissions: { ...USER_INITIATED_REPLICATION_AUTHORITY.permissions, failureRecovery: false },
|
||||
@@ -114,7 +115,7 @@ describe("central compatibility recovery", () => {
|
||||
context: failedContext,
|
||||
setting: {},
|
||||
outcome,
|
||||
showMessage: true,
|
||||
progressPresentation: REPLICATION_PROGRESS_PRESENTATIONS.QUIET,
|
||||
interaction: USER_INITIATED_REPLICATION_AUTHORITY,
|
||||
} as never);
|
||||
expect(askResolvingMismatched).toHaveBeenCalledWith(preferredTweakValue, expect.any(Function));
|
||||
@@ -158,7 +159,7 @@ describe("central compatibility recovery", () => {
|
||||
reason: CENTRAL_COMPATIBILITY_REJECTION_REASONS.TWEAK_MISMATCH,
|
||||
preferredTweakValue: { customChunkSize: 60 },
|
||||
}),
|
||||
showMessage: true,
|
||||
progressPresentation: REPLICATION_PROGRESS_PRESENTATIONS.NOTICE,
|
||||
interaction: USER_INITIATED_REPLICATION_AUTHORITY,
|
||||
} as never);
|
||||
|
||||
@@ -196,7 +197,7 @@ describe("central compatibility recovery", () => {
|
||||
outcome: replicationFailed(new Error("locked"), {
|
||||
reason: CENTRAL_COMPATIBILITY_REJECTION_REASONS.NODE_LOCKED,
|
||||
}),
|
||||
showMessage: true,
|
||||
progressPresentation: REPLICATION_PROGRESS_PRESENTATIONS.NOTICE,
|
||||
interaction: USER_INITIATED_REPLICATION_AUTHORITY,
|
||||
} as never);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user