From bd0581cdc955d376ead6a152d7e3685d4bbf7841 Mon Sep 17 00:00:00 2001 From: vorotamoroz Date: Tue, 1 Sep 2026 01:23:34 +0000 Subject: [PATCH] Report incomplete Object Storage wipes --- docs/settings.md | 6 +- .../ObsidianLiveSyncSettingTab.ts | 13 +- .../ObsidianLiveSyncSettingTab.unit.spec.ts | 20 +++ .../SettingDialogue/PaneMaintenance.ts | 9 +- .../PaneMaintenance.unit.spec.ts | 143 ++++++++++++++++++ updates.md | 2 + 6 files changed, 188 insertions(+), 5 deletions(-) create mode 100644 src/modules/features/SettingDialogue/PaneMaintenance.unit.spec.ts diff --git a/docs/settings.md b/docs/settings.md index 7619ee74..feddbb73 100644 --- a/docs/settings.md +++ b/docs/settings.md @@ -1113,7 +1113,11 @@ Purge all download/upload cache. #### Fresh Start Wipe -Delete all data on the remote server. +Delete all data on the remote server in batches; this operation is not +transactional. Stop all synchronising devices before starting. If the +operation is interrupted or reports failure, keep them stopped, rerun Fresh +Start Wipe, and then use **Overwrite Server Data with This Device's Files** +from an authoritative Vault. ### 6. Garbage Collection V3 (CouchDB only) diff --git a/src/modules/features/SettingDialogue/ObsidianLiveSyncSettingTab.ts b/src/modules/features/SettingDialogue/ObsidianLiveSyncSettingTab.ts index 2b7402b5..032caf76 100644 --- a/src/modules/features/SettingDialogue/ObsidianLiveSyncSettingTab.ts +++ b/src/modules/features/SettingDialogue/ObsidianLiveSyncSettingTab.ts @@ -1209,8 +1209,17 @@ export class ObsidianLiveSyncSettingTab extends PluginSettingTab { new MinioStorageAdapter(this.core.settings, this.core) ); } - async resetRemoteBucket() { + /** + * Wipe the remote bucket through a short-lived Journal client. + * Journal wipes are batched and non-transactional, so a false result may + * leave a partial wipe which can be retried after all devices are stopped. + */ + async resetRemoteBucket(): Promise { const minioJournal = this.getMinioJournalSyncClient(); - await minioJournal.resetBucket(); + try { + return await minioJournal.resetBucket(); + } finally { + minioJournal.dispose(); + } } } diff --git a/src/modules/features/SettingDialogue/ObsidianLiveSyncSettingTab.unit.spec.ts b/src/modules/features/SettingDialogue/ObsidianLiveSyncSettingTab.unit.spec.ts index cb667137..3f39d081 100644 --- a/src/modules/features/SettingDialogue/ObsidianLiveSyncSettingTab.unit.spec.ts +++ b/src/modules/features/SettingDialogue/ObsidianLiveSyncSettingTab.unit.spec.ts @@ -237,6 +237,26 @@ describe("ObsidianLiveSyncSettingTab connection testing", () => { }); }); +describe("ObsidianLiveSyncSettingTab Fresh Start Wipe", () => { + it("returns the remote wipe result and disposes its temporary Journal client", async () => { + const resetBucket = vi.fn(async () => false); + const dispose = vi.fn(); + const tab = new ObsidianLiveSyncSettingTab( + {} as never, + { + app: {}, + core: {}, + } as never + ); + vi.spyOn(tab, "getMinioJournalSyncClient").mockReturnValue({ resetBucket, dispose } as never); + + await expect(tab.resetRemoteBucket()).resolves.toBe(false); + + expect(resetBucket).toHaveBeenCalledOnce(); + expect(dispose).toHaveBeenCalledOnce(); + }); +}); + describe("ObsidianLiveSyncSettingTab pending-setting initialisation", () => { function createSettingsTab() { const saveSettingData = vi.fn(async () => undefined); diff --git a/src/modules/features/SettingDialogue/PaneMaintenance.ts b/src/modules/features/SettingDialogue/PaneMaintenance.ts index aed4e8ad..a016bb8f 100644 --- a/src/modules/features/SettingDialogue/PaneMaintenance.ts +++ b/src/modules/features/SettingDialogue/PaneMaintenance.ts @@ -367,8 +367,13 @@ export function paneMaintenance( sentIDs: new Set(), sentFiles: new Set(), })); - await this.resetRemoteBucket(); - Logger(`Deleted all data on remote server`, LOG_LEVEL_NOTICE); + const reset = await this.resetRemoteBucket(); + Logger( + reset + ? `Deleted all data on remote server` + : `Fresh Start Wipe did not complete. Keep all synchronising devices stopped and run it again.`, + LOG_LEVEL_NOTICE + ); }) ) .addOnUpdate(this.onlyOnMinIO); diff --git a/src/modules/features/SettingDialogue/PaneMaintenance.unit.spec.ts b/src/modules/features/SettingDialogue/PaneMaintenance.unit.spec.ts new file mode 100644 index 00000000..8b6e8f1e --- /dev/null +++ b/src/modules/features/SettingDialogue/PaneMaintenance.unit.spec.ts @@ -0,0 +1,143 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; + +const maintenanceHarness = vi.hoisted(() => ({ + createdSettings: [] as Array<{ name: string; click?: () => Promise }>, + logger: vi.fn(), +})); + +vi.mock("@/common/events.ts", () => ({ + EVENT_REQUEST_PERFORM_GC_V3: "request-gc-v3", + eventHub: { emitEvent: vi.fn() }, +})); +vi.mock("@/common/translation", () => ({ + $msg: (message: string) => message, +})); +vi.mock("@/serviceFeatures/setupObsidian/settingsReset.ts", () => ({ + createCoreSettingsAfterFullReset: vi.fn(), + createEditingSettingsAfterFullReset: vi.fn(), +})); +vi.mock("@vrtmrz/livesync-commonlib/compat/common/logger", () => ({ + LOG_LEVEL_NOTICE: "notice", + Logger: maintenanceHarness.logger, +})); +vi.mock("@vrtmrz/livesync-commonlib/compat/common/types", () => ({ + FlagFilesHumanReadable: { + FETCH_ALL: "fetch-all", + REBUILD_ALL: "rebuild-all", + }, + FlagFilesOriginal: { SUSPEND_ALL: "suspend-all" }, +})); +vi.mock("@vrtmrz/livesync-commonlib/compat/common/utils", () => ({ + fireAndForget: (operation: Promise) => operation, +})); +vi.mock("@vrtmrz/livesync-commonlib/compat/replication/couchdb/LiveSyncReplicator", () => ({ + LiveSyncCouchDBReplicator: class {}, +})); +vi.mock("./LiveSyncSetting.ts", () => ({ + LiveSyncSetting: class { + name = ""; + click?: () => Promise; + + constructor() { + maintenanceHarness.createdSettings.push(this); + } + + setName(name: string) { + this.name = name; + return this; + } + + setDesc() { + return this; + } + + addButton(callback: (button: this) => void) { + callback(this); + return this; + } + + setButtonText() { + return this; + } + + setDisabled() { + return this; + } + + setCta() { + return this; + } + + onClick(callback: () => Promise) { + this.click = callback; + return this; + } + + addOnUpdate() { + return this; + } + }, +})); +vi.mock("./SettingPane", () => ({ + visibleOnly: vi.fn(() => vi.fn()), +})); +vi.mock("./settingComponentStyles.ts", () => ({ + setButtonDestructiveState: (button: T) => button, +})); + +import { paneMaintenance } from "./PaneMaintenance.ts"; + +afterEach(() => { + maintenanceHarness.createdSettings.length = 0; + maintenanceHarness.logger.mockClear(); + vi.clearAllMocks(); +}); + +describe("paneMaintenance Fresh Start Wipe", () => { + it("does not announce success when the remote wipe reports failure", async () => { + const updateCheckPointInfo = vi.fn(async () => undefined); + const resetRemoteBucket = vi.fn(async () => false); + const addPanel = vi.fn((_parent: HTMLElement, heading: string) => ({ + then(callback: (paneEl: HTMLElement) => void) { + if (heading === "Rebuilding Operations (Remote Only)") { + callback({} as HTMLElement); + } + return Promise.resolve(); + }, + })); + const host = { + core: { + replicator: {}, + storageAccess: {}, + }, + createEl: vi.fn(), + getMinioJournalSyncClient: vi.fn(() => ({ updateCheckPointInfo })), + onlyOnCouchDB: vi.fn(), + onlyOnCouchDBOrMinIO: vi.fn(), + onlyOnMinIO: vi.fn(), + resetRemoteBucket, + services: { + appLifecycle: { performRestart: vi.fn() }, + database: { resetDatabase: vi.fn() }, + databaseEvents: { initialiseDatabase: vi.fn() }, + replication: { markLocked: vi.fn(), markUnlocked: vi.fn() }, + setting: { saveSettingData: vi.fn() }, + }, + }; + + paneMaintenance.call(host as never, {} as HTMLElement, { addPanel } as never); + const freshStartWipe = maintenanceHarness.createdSettings.find(({ name }) => name === "Fresh Start Wipe"); + if (!freshStartWipe?.click) { + throw new Error("Fresh Start Wipe action was not registered"); + } + + await freshStartWipe.click(); + + expect(resetRemoteBucket).toHaveBeenCalledOnce(); + expect(maintenanceHarness.logger).toHaveBeenCalledWith( + "Fresh Start Wipe did not complete. Keep all synchronising devices stopped and run it again.", + "notice" + ); + expect(maintenanceHarness.logger).not.toHaveBeenCalledWith("Deleted all data on remote server", "notice"); + }); +}); diff --git a/updates.md b/updates.md index c336574f..8109f0d6 100644 --- a/updates.md +++ b/updates.md @@ -19,6 +19,7 @@ Earlier releases remain available in the 1.0 release history, the 1.0 preview hi - **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. +- **Fresh Start Wipe** now reports an incomplete Object Storage deletion instead of announcing success, and releases its temporary storage client after each attempt. ### Peer-to-peer synchronisation @@ -26,6 +27,7 @@ Earlier releases remain available in the 1.0 release history, the 1.0 preview hi - The P2P Setup connection test no longer interrupts an active P2P room. It observes an active compatible relay binding, blocks a test which would add another relay until P2P is disconnected, and uses a short-lived trial only while P2P is idle. - Unattended P2P synchronisation no longer raises Notice-level messages for missing configured targets, authentication rejection, configuration mismatch, or an overlapping transfer. User-initiated operations retain their existing feedback. +- P2P replication failure reasons now survive the JSON RPC boundary instead of reaching the requesting device as an empty object. ### Command-line interface