Restore Sync now progress and recovery policy

This commit is contained in:
vorotamoroz
2026-09-01 18:00:08 +00:00
parent 8e506fdbc7
commit ef3033d8db
14 changed files with 189 additions and 25 deletions
@@ -369,8 +369,17 @@ type InteractionAuthority =
const NO_INTERACTION = { kind: "forbidden" } as const;
const REPLICATION_PROGRESS_PRESENTATIONS = {
QUIET: "quiet",
NOTICE: "notice",
} as const;
type ReplicationProgressPresentation =
(typeof REPLICATION_PROGRESS_PRESENTATIONS)[keyof typeof REPLICATION_PROGRESS_PRESENTATIONS];
interface UserInitiatedOneShotRequest {
readonly trigger: "manual";
readonly progressPresentation: ReplicationProgressPresentation;
readonly interaction: InteractionAuthority;
}
@@ -393,11 +402,33 @@ same upper bound and expose only relevant permissions. An unattended path may
use persisted or automatic acceptance policy, but cannot obtain local
interaction authority implicitly.
The same authority bounds presentation. An unattended P2P path may retain an
informational diagnostic, but no-target, authentication, tweak-mismatch, and
overlapping-transfer settlements must not promote themselves to a Notice.
User-initiated paths retain their existing Notice-level presentation. This is
a caller-authority rule, not a new process-wide presentation framework.
Progress presentation and interaction authority are independent parts of the
request. `quiet` suppresses routine Notice-level progress, but does not remove
authorised peer selection or failure-recovery dialogue. `notice` permits
routine progress, but cannot grant an interaction which the request authority
forbids. Unattended work always uses `quiet` with `NO_INTERACTION`; it may retain
an informational diagnostic, but no-target, authentication, tweak-mismatch,
and overlapping-transfer settlements must not promote themselves to a Notice.
This remains a request-local policy, not a new process-wide presentation
framework.
The ordinary typed OneShot entry points deliberately retain these established
interaction shapes:
| Entry point | Routine progress | Interaction authority | Request received while another OneShot attempt is active |
| --- | --- | --- | --- |
| Command-palette `Sync now` | Quiet | Peer selection and failure recovery remain permitted | Settles as `blocked: replication-in-progress` |
| Ribbon synchronisation action | Notice | Full user-initiated authority | Settles as `blocked: replication-in-progress` |
| Config Sync `Sync once` | Notice | Full user-initiated authority | Settles as `blocked: replication-in-progress` |
| CLI `sync` | Notice | Full user-initiated authority, subject to the host's available interaction mechanisms | Settles as `blocked: replication-in-progress` |
| Resume, periodic, database-event, editor-save, file-open, merge, and daemon triggers | Quiet | `NO_INTERACTION` | Settles as `blocked: replication-in-progress` |
The OneShot owner is acquired before readiness evaluation and retained through
provider acquisition, finite activity, and failure handling. A later request
is not queued, joined to the running result, or treated as a failed transfer;
no work starts for that request. Directional Fetch and Rebuild, P2P-specific
commands, and other maintenance workflows remain outside this ordinary
OneShot gate and retain their own activity ownership.
Outcomes do not collapse failure into `void` or `boolean`:
+2
View File
@@ -88,6 +88,8 @@ Some settings must match across devices. LiveSync pauses synchronisation when th
Current releases automatically align compatible settings which control how new chunks are created, by default and where possible. This applies to the chunk hash algorithm, chunk size, and splitter version. Existing content remains readable across these choices, although using different choices can reduce chunk reuse and increase storage or transfer work. An explicit opt-out retains the manual review. A mismatch involving encryption, path obfuscation, file-name case handling, or any combination which includes one of those settings always remains a manual decision.
The `Sync now` command keeps routine replication progress quiet so that it is convenient to assign to a keyboard shortcut; assign one in Obsidian if that suits your workflow. A quiet command may still open this dialogue when a mismatch or another decision requires your attention.
The available actions depend on when the mismatch is found:
- While checking a remote profile, `Use configured settings` accepts the shared values already stored in that remote. `Dismiss` leaves this device's settings unchanged.
+2
View File
@@ -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 -2
View File
@@ -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,
});
+5 -1
View File
@@ -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,
});
}
+5 -1
View File
@@ -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);
+1
View File
@@ -16,6 +16,7 @@ Earlier releases remain available in the 1.0 release history, the 1.0 preview hi
#### Fixed
- **Sync now** once again keeps routine progress quiet, while still opening recovery dialogues when a decision is required. Repeated OneShot Sync requests received while an earlier attempt is running are now ignored instead of starting overlapping work.
- **Sync on Startup** now runs an immediate Object Storage synchronisation after start-up or resume, including migrated profiles which retain a Continuous setting that Object Storage cannot use.
- A temporarily unavailable Object Storage synchronisation-parameter read is no longer treated as a missing object and cannot regenerate the shared Security Seed. Flow-specific Security Seed checks also bypass an earlier process-cached result.
- Local database reset and plug-in unload now retire active replication through its owner before closing the database, without reporting a missing active Replicator or describing unload as a database reset.