mirror of
https://github.com/vrtmrz/obsidian-livesync.git
synced 2026-11-06 16:28:18 +00:00
Add explicit stopped-Journal recovery to Obsidian and CLI
Offer a confirmation in Maintenance and the journal-skip command to skip only the current stopped Journal and resume receipt. Keep Commonlib notices visible on stderr during ordinary CLI use. Document the missing-content risk and replay procedure, and cover cancellation, stale failures, subsequent failures, and receipt with unit and plain and encrypted CLI and Obsidian end-to-end tests. Requires the Commonlib stopped-Journal recovery API. Adopt its exact published package version before integrating this consumer change.
This commit is contained in:
1 parent
bf2241e5ee
commit
a94c3fef90
18 files changed
+1321
-234
No files matched your search
@@ -1,8 +1,14 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const maintenanceHarness = vi.hoisted(() => ({
|
||||
createdSettings: [] as Array<{ name: string; click?: () => Promise<void> }>,
|
||||
createdSettings: [] as Array<{
|
||||
name: string;
|
||||
click?: () => Promise<void>;
|
||||
toggleKey?: string;
|
||||
onUpdate?: () => { visibility?: boolean };
|
||||
}>,
|
||||
logger: vi.fn(),
|
||||
notice: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/common/events.ts", () => ({
|
||||
@@ -10,7 +16,19 @@ vi.mock("@/common/events.ts", () => ({
|
||||
eventHub: { emitEvent: vi.fn() },
|
||||
}));
|
||||
vi.mock("@/common/translation", () => ({
|
||||
$msg: (message: string) => message,
|
||||
$msg: (message: string, replacements?: Record<string, unknown>) => {
|
||||
for (const [key, value] of Object.entries(replacements ?? {})) {
|
||||
message = message.split(String.fromCharCode(36) + "{" + key + "}").join(String(value));
|
||||
}
|
||||
return message;
|
||||
},
|
||||
}));
|
||||
vi.mock("@/deps.ts", () => ({
|
||||
Notice: class {
|
||||
constructor(message: string) {
|
||||
maintenanceHarness.notice(message);
|
||||
}
|
||||
},
|
||||
}));
|
||||
vi.mock("@/serviceFeatures/setupObsidian/settingsReset.ts", () => ({
|
||||
createCoreSettingsAfterFullReset: vi.fn(),
|
||||
@@ -33,10 +51,15 @@ vi.mock("@vrtmrz/livesync-commonlib/compat/common/utils", () => ({
|
||||
vi.mock("@vrtmrz/livesync-commonlib/compat/replication/couchdb/LiveSyncReplicator", () => ({
|
||||
LiveSyncCouchDBReplicator: class {},
|
||||
}));
|
||||
vi.mock("@vrtmrz/livesync-commonlib/compat/replication/journal/LiveSyncJournalReplicator", () => ({
|
||||
LiveSyncJournalReplicator: class {},
|
||||
}));
|
||||
vi.mock("./LiveSyncSetting.ts", () => ({
|
||||
LiveSyncSetting: class {
|
||||
name = "";
|
||||
click?: () => Promise<void>;
|
||||
toggleKey?: string;
|
||||
onUpdate?: () => { visibility?: boolean };
|
||||
|
||||
constructor() {
|
||||
maintenanceHarness.createdSettings.push(this);
|
||||
@@ -51,6 +74,11 @@ vi.mock("./LiveSyncSetting.ts", () => ({
|
||||
return this;
|
||||
}
|
||||
|
||||
autoWireToggle(key: string) {
|
||||
this.toggleKey = key;
|
||||
return this;
|
||||
}
|
||||
|
||||
addButton(callback: (button: this) => void) {
|
||||
callback(this);
|
||||
return this;
|
||||
@@ -73,7 +101,8 @@ vi.mock("./LiveSyncSetting.ts", () => ({
|
||||
return this;
|
||||
}
|
||||
|
||||
addOnUpdate() {
|
||||
addOnUpdate(onUpdate: () => { visibility?: boolean }) {
|
||||
this.onUpdate = onUpdate;
|
||||
return this;
|
||||
}
|
||||
},
|
||||
@@ -85,14 +114,61 @@ vi.mock("./settingComponentStyles.ts", () => ({
|
||||
setButtonDestructiveState: <T>(button: T) => button,
|
||||
}));
|
||||
|
||||
import { LiveSyncJournalReplicator } from "@vrtmrz/livesync-commonlib/compat/replication/journal/LiveSyncJournalReplicator";
|
||||
import { paneMaintenance } from "./PaneMaintenance.ts";
|
||||
|
||||
afterEach(() => {
|
||||
maintenanceHarness.createdSettings.length = 0;
|
||||
maintenanceHarness.logger.mockClear();
|
||||
maintenanceHarness.notice.mockClear();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
type TestStoppedJournal = Readonly<{ key: string; stage: string }>;
|
||||
type ConfirmationOptions = { title: string; defaultOption: "No" };
|
||||
type AskYesNoDialog = (message: string, options: ConfirmationOptions) => Promise<"yes" | "no">;
|
||||
|
||||
function createJournalReplicator(stoppedJournal: TestStoppedJournal | undefined, result = true) {
|
||||
const getStoppedJournal = vi.fn(() => stoppedJournal);
|
||||
const skipStoppedJournalAndResume = vi.fn(async (_settings: unknown, _snapshot: unknown) => result);
|
||||
const replicator = Object.assign(new LiveSyncJournalReplicator({} as never), {
|
||||
getStoppedJournal,
|
||||
skipStoppedJournalAndResume,
|
||||
});
|
||||
return { replicator, getStoppedJournal, skipStoppedJournalAndResume };
|
||||
}
|
||||
|
||||
function createSyncingPane(replicator: unknown, askYesNoDialog: AskYesNoDialog = async () => "no") {
|
||||
const settings = { databaseName: "test-database" };
|
||||
const onlyOnMinIO = vi.fn(() => ({ visibility: true }));
|
||||
const confirmation = vi.fn(askYesNoDialog);
|
||||
const host = {
|
||||
core: { replicator, settings, confirm: { askYesNoDialog: confirmation } },
|
||||
createEl: vi.fn(),
|
||||
getMinioJournalSyncClient: vi.fn(),
|
||||
onlyOnCouchDB: vi.fn(),
|
||||
onlyOnCouchDBOrMinIO: vi.fn(),
|
||||
onlyOnMinIO,
|
||||
services: { replication: { markLocked: vi.fn(), markUnlocked: vi.fn() } },
|
||||
};
|
||||
const addPanel = vi.fn((_parent: HTMLElement, heading: string) => ({
|
||||
then(callback: (paneEl: HTMLElement) => void) {
|
||||
if (heading === "Syncing") {
|
||||
callback({} as HTMLElement);
|
||||
}
|
||||
return Promise.resolve();
|
||||
},
|
||||
}));
|
||||
|
||||
paneMaintenance.call(host as never, {} as HTMLElement, { addPanel } as never);
|
||||
|
||||
const setting = maintenanceHarness.createdSettings.find(({ name }) => name === "Skip stopped Journal and resume");
|
||||
if (!setting?.click) {
|
||||
throw new Error("Skip stopped Journal action was not registered");
|
||||
}
|
||||
return { setting, click: setting.click, host, settings, askYesNoDialog: confirmation, onlyOnMinIO };
|
||||
}
|
||||
|
||||
describe("paneMaintenance", () => {
|
||||
it("does not announce success when the remote wipe reports failure", async () => {
|
||||
const updateCheckPointInfo = vi.fn(async () => undefined);
|
||||
@@ -180,9 +256,139 @@ describe("paneMaintenance", () => {
|
||||
|
||||
expect(resetDatabase).toHaveBeenCalledOnce();
|
||||
expect(initialiseDatabase).toHaveBeenCalledOnce();
|
||||
expect(maintenanceHarness.logger).toHaveBeenCalledWith(
|
||||
"Ui.Common.LocalDatabaseInitialisationFailed",
|
||||
"notice"
|
||||
expect(maintenanceHarness.logger).toHaveBeenCalledWith("Ui.Common.LocalDatabaseInitialisationFailed", "notice");
|
||||
});
|
||||
|
||||
it("removes the broken journal toggle and preserves received history reset semantics", async () => {
|
||||
const checkpoint = {
|
||||
receivedFiles: new Set(["received-file"]),
|
||||
knownIDs: new Set(["known-id"]),
|
||||
sentIDs: new Set(["sent-id"]),
|
||||
sentFiles: new Set(["sent-file"]),
|
||||
lastLocalSeq: 23,
|
||||
};
|
||||
let updatedCheckpoint: typeof checkpoint | undefined;
|
||||
const updateCheckPointInfo = vi.fn(async (update: (info: typeof checkpoint) => typeof checkpoint) => {
|
||||
updatedCheckpoint = update(checkpoint);
|
||||
});
|
||||
const onlyOnMinIO = vi.fn(() => ({ visibility: true }));
|
||||
const addPanel = vi.fn((_parent: HTMLElement, heading: string) => ({
|
||||
then(callback: (paneEl: HTMLElement) => void) {
|
||||
if (heading === "Syncing") {
|
||||
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,
|
||||
services: { replication: { markLocked: vi.fn(), markUnlocked: vi.fn() } },
|
||||
};
|
||||
|
||||
paneMaintenance.call(host as never, {} as HTMLElement, { addPanel } as never);
|
||||
|
||||
expect(maintenanceHarness.createdSettings.some(({ toggleKey }) => toggleKey === "ignoreBrokenJournals")).toBe(
|
||||
false
|
||||
);
|
||||
const resetReceivedHistory = maintenanceHarness.createdSettings.find(
|
||||
({ name }) => name === "Reset journal received history"
|
||||
);
|
||||
if (!resetReceivedHistory?.click) {
|
||||
throw new Error("Reset journal received history action was not registered");
|
||||
}
|
||||
await resetReceivedHistory.click();
|
||||
|
||||
expect(updateCheckPointInfo).toHaveBeenCalledOnce();
|
||||
expect(updatedCheckpoint).toEqual({
|
||||
...checkpoint,
|
||||
receivedFiles: new Set(),
|
||||
knownIDs: new Set(),
|
||||
});
|
||||
expect(maintenanceHarness.logger).toHaveBeenCalledWith("Journal received history has been cleared.", "notice");
|
||||
});
|
||||
|
||||
it("asks the user to retry when there is no stopped Journal", async () => {
|
||||
const { replicator, getStoppedJournal, skipStoppedJournalAndResume } = createJournalReplicator(undefined);
|
||||
const rendered = createSyncingPane(replicator);
|
||||
|
||||
expect(rendered.setting.onUpdate).toBe(rendered.onlyOnMinIO);
|
||||
await rendered.click();
|
||||
|
||||
expect(getStoppedJournal).toHaveBeenCalledOnce();
|
||||
expect(rendered.askYesNoDialog).not.toHaveBeenCalled();
|
||||
expect(skipStoppedJournalAndResume).not.toHaveBeenCalled();
|
||||
expect(maintenanceHarness.notice).toHaveBeenCalledWith(
|
||||
"No stopped Journal is available. Retry replication to identify the stopped Journal."
|
||||
);
|
||||
});
|
||||
|
||||
it("does not skip the stopped Journal when confirmation is cancelled", async () => {
|
||||
const stoppedJournal = { key: "journal/one", stage: "save-records" } as const;
|
||||
const { replicator, skipStoppedJournalAndResume } = createJournalReplicator(stoppedJournal);
|
||||
const rendered = createSyncingPane(replicator);
|
||||
|
||||
await rendered.click();
|
||||
|
||||
expect(rendered.askYesNoDialog).toHaveBeenCalledOnce();
|
||||
const call = rendered.askYesNoDialog.mock.calls[0];
|
||||
if (!call) {
|
||||
throw new Error("Stopped Journal confirmation was not requested");
|
||||
}
|
||||
const [message, options] = call;
|
||||
expect(message).toContain(stoppedJournal.key);
|
||||
expect(message).toContain(stoppedJournal.stage);
|
||||
expect(options).toEqual({ title: "Skip stopped Journal and resume", defaultOption: "No" });
|
||||
expect(skipStoppedJournalAndResume).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("skips and resumes with the exact confirmed stopped Journal snapshot", async () => {
|
||||
const stoppedJournal = { key: "journal/one", stage: "save-records" } as const;
|
||||
const { replicator, skipStoppedJournalAndResume } = createJournalReplicator(stoppedJournal);
|
||||
const confirm = vi.fn(async () => "yes" as const);
|
||||
const rendered = createSyncingPane(replicator, confirm);
|
||||
|
||||
await rendered.click();
|
||||
|
||||
expect(skipStoppedJournalAndResume).toHaveBeenCalledOnce();
|
||||
expect(skipStoppedJournalAndResume.mock.calls[0]?.[0]).toBe(rendered.settings);
|
||||
expect(skipStoppedJournalAndResume.mock.calls[0]?.[1]).toBe(stoppedJournal);
|
||||
});
|
||||
|
||||
it("does not skip after the active replicator changes during confirmation", async () => {
|
||||
const stoppedJournal = { key: "journal/one", stage: "save-records" } as const;
|
||||
const active = createJournalReplicator(stoppedJournal);
|
||||
const replacement = createJournalReplicator(stoppedJournal);
|
||||
const confirm = vi.fn(async () => "yes" as const);
|
||||
const rendered = createSyncingPane(active.replicator, confirm);
|
||||
confirm.mockImplementation(async () => {
|
||||
rendered.host.core.replicator = replacement.replicator;
|
||||
return "yes";
|
||||
});
|
||||
|
||||
await rendered.click();
|
||||
|
||||
expect(active.skipStoppedJournalAndResume).not.toHaveBeenCalled();
|
||||
expect(replacement.skipStoppedJournalAndResume).not.toHaveBeenCalled();
|
||||
expect(maintenanceHarness.notice).toHaveBeenCalledWith(
|
||||
"The active replicator changed. Retry replication to identify the stopped Journal."
|
||||
);
|
||||
});
|
||||
|
||||
it("reports when receipt does not complete after skip and resume", async () => {
|
||||
const stoppedJournal = { key: "journal/one", stage: "save-records" } as const;
|
||||
const { replicator, skipStoppedJournalAndResume } = createJournalReplicator(stoppedJournal, false);
|
||||
const rendered = createSyncingPane(replicator, async () => "yes");
|
||||
|
||||
await rendered.click();
|
||||
|
||||
expect(skipStoppedJournalAndResume).toHaveBeenCalledOnce();
|
||||
expect(maintenanceHarness.notice).toHaveBeenCalledWith(
|
||||
"Journal receipt did not complete after this action. Check the current failure before retrying."
|
||||
);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user