Expose explicit LiveSync batching and honour save failures

Show the batching choice for the current synchronisation mode while
preserving both mode preferences. Observe local save failures before
file-open work and report failed asynchronous unload preparation.

Record the accepted recovery and batching contract, update the primary
settings documentation, and add two-device runtime coverage for batching,
deletions, saved settings, and interrupted local processing.

Requires Commonlib commit 72bdc66af5d7338d8f2f69ebe83c3866def93360.
Update the exact registry dependency when its package version is published.

Validation: full repository check; latest packed dependency type checks,
production build, 1,373 unit tests (one skipped), and six focused runtime
recovery cases. The earlier implementation passed the full 23-case runtime
comparison and five additional interruption conditions.
This commit is contained in:
vorotamoroz committed 2026-10-25 02:47:55 +00:00
1 parent 73a14d4e4b
commit 68fa04af3c
9 files changed
+1512 -15

No files matched your search

+8 -1
View File
@@ -1,4 +1,5 @@
import { useP2PSettingsPreparation } from "@/serviceFeatures/useP2PSettingsPreparation";
import { LOG_LEVEL_URGENT } from "octagonal-wheels/common/logger";
import { getLanguage, Notice, Plugin, type App, type PluginManifest } from "./deps";
import { setGetLanguage } from "@vrtmrz/livesync-commonlib/compat/common/coreEnvFunctions";
setGetLanguage(getLanguage);
@@ -233,6 +234,12 @@ export default class ObsidianLiveSyncPlugin extends Plugin {
void this._startUp();
}
override onunload(): void {
return void this.core.services.control.onUnload();
void this.core.services.control.onUnload().catch((error) => {
this.core.services.API.addLog(
"Failed to unload Self-hosted LiveSync: " + String(error),
LOG_LEVEL_URGENT,
"shutdown"
);
});
}
}
@@ -230,7 +230,13 @@ export class ModuleObsidianEvents extends AbstractObsidianModule {
}
this.isLastHidden = isHidden;
await this.services.fileProcessing.commitPendingFileEvents();
const pendingFileEventsCommitted = await this.services.fileProcessing.commitPendingFileEvents();
if (!pendingFileEventsCommitted) {
this._log(
"Pending file events remain because their commit did not complete before the window visibility changed.",
LOG_LEVEL_NOTICE
);
}
// Desktop opt-in (LiveSync/Periodic only): keep the background channel running while the
// window is hidden, instead of suspending on hide. On hide we skip the suspend for both
@@ -287,7 +293,10 @@ export class ModuleObsidianEvents extends AbstractObsidianModule {
if (this.settings.suspendFileWatching) return;
if (!this.settings.isConfigured) return;
if (!this.services.appLifecycle.isReady()) return;
await this.services.fileProcessing.commitPendingFileEvents();
const pendingFileEventsCommitted = await this.services.fileProcessing.commitPendingFileEvents();
if (!pendingFileEventsCommitted) {
return;
}
if (file == null) {
return;
}
@@ -3,6 +3,7 @@ import { describe, it, expect, vi, afterEach } from "vitest";
import { ModuleObsidianEvents } from "./ModuleObsidianEvents";
import { DEFAULT_SETTINGS, REMOTE_COUCHDB } from "@vrtmrz/livesync-commonlib/compat/common/types";
import { reactiveSource } from "octagonal-wheels/dataobject/reactive";
import { LOG_LEVEL_NOTICE } from "octagonal-wheels/common/logger";
type SetupOptions = {
settings?: Partial<typeof DEFAULT_SETTINGS>;
@@ -23,6 +24,8 @@ function setup(opts: SetupOptions) {
onResumed: vi.fn(async () => true),
};
const fileProcessing = { commitPendingFileEvents: vi.fn(async () => true) };
const replication = { replicateUnattendedByEvent: vi.fn(async () => undefined) };
const conflict = { queueCheckForIfOpen: vi.fn(async () => undefined) };
const boundedRemoteActivityCount = reactiveSource(0);
const boundedLocalApplicationActivityCount = reactiveSource(0);
@@ -39,6 +42,8 @@ function setup(opts: SetupOptions) {
setting: { saveSettingData: vi.fn(async () => undefined) },
appLifecycle,
fileProcessing,
replication,
conflict,
replicator: { boundedRemoteActivityCount, boundedLocalApplicationActivityCount },
},
settings: {
@@ -61,6 +66,9 @@ function setup(opts: SetupOptions) {
module,
appLifecycle,
fileProcessing,
replication,
conflict,
api: core._services.API,
boundedRemoteActivityCount,
boundedLocalApplicationActivityCount,
};
@@ -303,6 +311,23 @@ describe("watchWindowVisibilityAsync — keepReplicationActiveInBackground", ()
expect(appLifecycle.onResumed).toHaveBeenCalledTimes(1);
});
it("logs a failed pending-file commit and still suspends on hide", async () => {
const { module, appLifecycle, fileProcessing, api } = setup({
settings: { keepReplicationActiveInBackground: false, liveSync: true },
hidden: true,
});
fileProcessing.commitPendingFileEvents.mockResolvedValue(false);
await module.watchWindowVisibilityAsync();
expect(appLifecycle.onSuspending).toHaveBeenCalledTimes(1);
expect(api.addLog).toHaveBeenCalledWith(
expect.stringContaining("Pending file events remain"),
LOG_LEVEL_NOTICE,
expect.any(String)
);
});
it("does not apply on mobile even if the flag is set", async () => {
const { module, appLifecycle } = setup({
settings: { keepReplicationActiveInBackground: true, liveSync: true },
@@ -313,3 +338,20 @@ describe("watchWindowVisibilityAsync — keepReplicationActiveInBackground", ()
expect(appLifecycle.onSuspending).toHaveBeenCalledTimes(1);
});
});
describe("watchWorkspaceOpenAsync — pending file event commit", () => {
it("skips replication and conflict checks when pending file events fail to commit", async () => {
const { module, fileProcessing, replication, conflict } = setup({
settings: { syncOnFileOpen: true },
hidden: false,
});
fileProcessing.commitPendingFileEvents.mockResolvedValue(false);
await module.watchWorkspaceOpenAsync({ path: "note.md" } as Parameters<
typeof module.watchWorkspaceOpenAsync
>[0]);
expect(replication.replicateUnattendedByEvent).not.toHaveBeenCalled();
expect(conflict.queueCheckForIfOpen).not.toHaveBeenCalled();
});
});
@@ -171,20 +171,18 @@ export function paneSyncSettings(
}
});
void addPanel(
paneEl,
$msg("obsidianLiveSyncSettingTab.titleUpdateThinning"),
undefined,
visibleOnly(() => !this.isConfiguredAs("syncMode", "LIVESYNC"))
).then((paneEl) => {
new Setting(paneEl).autoWireToggle("batchSave");
void addPanel(paneEl, $msg("obsidianLiveSyncSettingTab.titleUpdateThinning")).then((paneEl) => {
const inLiveSyncMode = () => this.isConfiguredAs("syncMode", "LIVESYNC");
const batchEnabled = () => this.isConfiguredAs(inLiveSyncMode() ? "batchSaveInLiveSync" : "batchSave", true);
new Setting(paneEl).autoWireToggle("batchSave", { onUpdate: visibleOnly(() => !inLiveSyncMode()) });
new Setting(paneEl).autoWireToggle("batchSaveInLiveSync", { onUpdate: visibleOnly(inLiveSyncMode) });
new Setting(paneEl).autoWireNumeric("batchSaveMinimumDelay", {
acceptZero: true,
onUpdate: visibleOnly(() => this.isConfiguredAs("batchSave", true)),
onUpdate: visibleOnly(batchEnabled),
});
new Setting(paneEl).autoWireNumeric("batchSaveMaximumDelay", {
acceptZero: true,
onUpdate: visibleOnly(() => this.isConfiguredAs("batchSave", true)),
onUpdate: visibleOnly(batchEnabled),
});
});
@@ -0,0 +1,98 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { DEFAULT_SETTINGS } from "@vrtmrz/livesync-commonlib/compat/common/types";
import type { ObsidianLiveSyncSettingTab } from "./ObsidianLiveSyncSettingTab";
import type { OnUpdateFunc, PageFunctions } from "./SettingPane";
const controls = vi.hoisted(() => new Map<string, { onUpdate?: () => { visibility?: boolean } }>());
vi.mock("@/common/translation", () => ({ $msg: (key: string) => key }));
vi.mock("@vrtmrz/livesync-commonlib/compat/common/logger", () => ({ Logger: vi.fn() }));
vi.mock("./LiveSyncSetting.ts", () => ({
LiveSyncSetting: class {
autoWireDropDown(key: string, options = {}) {
controls.set(key, options);
return this;
}
autoWireToggle(key: string, options = {}) {
controls.set(key, options);
return this;
}
autoWireNumeric(key: string, options = {}) {
controls.set(key, options);
return this;
}
},
}));
import { paneSyncSettings } from "./PaneSyncSettings";
function setup(liveSync: boolean, batchSave: boolean, batchSaveInLiveSync: boolean) {
const callbacks = new Map<string, (value: string) => Promise<void>>();
const panels = new Map<string, OnUpdateFunc | undefined>();
const tab = {
editingSettings: { ...DEFAULT_SETTINGS, liveSync, batchSave, batchSaveInLiveSync },
services: { API: { isMobile: () => false }, control: { applySettings: vi.fn(async () => {}) } },
addOnSaved: (key: string, callback: (value: string) => Promise<void>) => callbacks.set(key, callback),
isConfiguredAs(key: string, value: unknown) {
if (key === "syncMode") {
return (
(this.editingSettings.liveSync
? "LIVESYNC"
: this.editingSettings.periodicReplication
? "PERIODIC"
: "ONEVENTS") === value
);
}
return this.editingSettings[key as keyof typeof this.editingSettings] === value;
},
saveSettings: vi.fn(async () => {}),
requestCatalogueRefresh: vi.fn(),
};
const functions = {
addPanel: (_element: HTMLElement, title: string, _callback: unknown, onUpdate?: OnUpdateFunc) => ({
then(callback: (element: HTMLElement) => void) {
if (!title.endsWith("titleSynchronizationMethod") && !title.endsWith("titleUpdateThinning")) return;
panels.set(title, onUpdate);
callback({} as HTMLElement);
},
}),
} as unknown as PageFunctions;
paneSyncSettings.call(tab as unknown as ObsidianLiveSyncSettingTab, {} as HTMLElement, functions);
return { tab, callbacks, panels };
}
function visible(key: string): boolean {
const control = controls.get(key);
if (!control) throw new Error(`Missing setting control: ${key}`);
return control.onUpdate?.().visibility !== false;
}
beforeEach(() => controls.clear());
describe("mode-specific database batching settings", () => {
it.each([
{ liveSync: true, batchSave: true, batchSaveInLiveSync: false, delays: false },
{ liveSync: true, batchSave: false, batchSaveInLiveSync: true, delays: true },
{ liveSync: false, batchSave: true, batchSaveInLiveSync: false, delays: true },
{ liveSync: false, batchSave: false, batchSaveInLiveSync: true, delays: false },
])("shows the current mode choice and its shared delays: %j", ({ delays, ...settings }) => {
const { panels } = setup(settings.liveSync, settings.batchSave, settings.batchSaveInLiveSync);
expect(panels.get("obsidianLiveSyncSettingTab.titleUpdateThinning")?.().visibility).not.toBe(false);
expect(visible("batchSave")).toBe(!settings.liveSync);
expect(visible("batchSaveInLiveSync")).toBe(settings.liveSync);
expect(visible("batchSaveMinimumDelay")).toBe(delays);
expect(visible("batchSaveMaximumDelay")).toBe(delays);
});
it.each([
[true, false],
[false, true],
])("retains both batching choices across mode changes: %j", async (batchSave, batchSaveInLiveSync) => {
const { tab, callbacks } = setup(false, batchSave, batchSaveInLiveSync);
const switchMode = callbacks.get("syncMode")!;
for (const mode of ["LIVESYNC", "PERIODIC", "ONEVENTS", "LIVESYNC"]) {
await switchMode(mode);
expect(tab.editingSettings).toMatchObject({ batchSave, batchSaveInLiveSync });
}
expect(tab.services.control.applySettings).toHaveBeenCalledTimes(4);
});
});