mirror of
https://github.com/vrtmrz/obsidian-livesync.git
synced 2026-11-05 07:48: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
@@ -11,6 +11,7 @@ import {
|
||||
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
|
||||
import { runCommand } from "./runCommand";
|
||||
import type { CLIOptions } from "./types";
|
||||
import { LiveSyncJournalReplicator } from "@vrtmrz/livesync-commonlib/compat/replication/journal/LiveSyncJournalReplicator";
|
||||
import {
|
||||
CENTRAL_REMOTE_ADMINISTRATION_ACTIONS,
|
||||
CENTRAL_REMOTE_ADMINISTRATION_FAILURE_REASONS,
|
||||
@@ -20,6 +21,7 @@ import {
|
||||
CENTRAL_COMPATIBILITY_REJECTION_REASONS,
|
||||
REPLICATION_COMPLETED,
|
||||
REPLICATION_PROGRESS_PRESENTATIONS,
|
||||
USER_INITIATED_REPLICATION_AUTHORITY,
|
||||
replicationFailed,
|
||||
} from "@vrtmrz/livesync-commonlib/replication";
|
||||
|
||||
@@ -137,6 +139,17 @@ function makeOptions(command: CLIOptions["command"], commandArgs: string[]): CLI
|
||||
};
|
||||
}
|
||||
|
||||
function makeJournalSkipOptions(journalKey: string): CLIOptions {
|
||||
return makeOptions("journal-skip", [journalKey]);
|
||||
}
|
||||
|
||||
function createJournalReplicatorMock(stoppedJournal: unknown) {
|
||||
const replicator = Object.create(LiveSyncJournalReplicator.prototype) as any;
|
||||
replicator.getStoppedJournal = vi.fn(() => stoppedJournal);
|
||||
replicator.skipStoppedJournalAndResume = vi.fn(async () => true);
|
||||
return replicator;
|
||||
}
|
||||
|
||||
async function createSetupURI(passphrase: string): Promise<string> {
|
||||
const settings = {
|
||||
...DEFAULT_SETTINGS,
|
||||
@@ -354,6 +367,108 @@ describe("runCommand abnormal cases", () => {
|
||||
);
|
||||
});
|
||||
|
||||
describe("journal-skip command", () => {
|
||||
const snapshot = { key: "7-docs.jsonl.gz", stage: "parse" } as const;
|
||||
|
||||
it("skips only the exact stopped Journal after a normal user-initiated replication", async () => {
|
||||
const core = createCoreMock();
|
||||
const replicator = createJournalReplicatorMock(snapshot);
|
||||
core.services.replicator.getActiveReplicator.mockReturnValue(replicator);
|
||||
core.services.replication.replicateUserInitiated.mockResolvedValue(
|
||||
replicationFailed(new Error("stopped Journal"))
|
||||
);
|
||||
|
||||
await expect(runCommand(makeJournalSkipOptions(snapshot.key), { ...context, core })).resolves.toBe(true);
|
||||
|
||||
expect(core.services.replication.replicateUserInitiated).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
trigger: "manual",
|
||||
progressPresentation: REPLICATION_PROGRESS_PRESENTATIONS.NOTICE,
|
||||
interaction: USER_INITIATED_REPLICATION_AUTHORITY,
|
||||
})
|
||||
);
|
||||
expect(replicator.getStoppedJournal).toHaveBeenCalledOnce();
|
||||
expect(replicator.skipStoppedJournalAndResume).toHaveBeenCalledWith(
|
||||
core.services.setting.currentSettings(),
|
||||
snapshot,
|
||||
true
|
||||
);
|
||||
});
|
||||
|
||||
it("refuses a key which does not match the stopped Journal", async () => {
|
||||
const core = createCoreMock();
|
||||
const replicator = createJournalReplicatorMock(snapshot);
|
||||
core.services.replicator.getActiveReplicator.mockReturnValue(replicator);
|
||||
core.services.replication.replicateUserInitiated.mockResolvedValue(
|
||||
replicationFailed(new Error("stopped Journal"))
|
||||
);
|
||||
|
||||
await expect(runCommand(makeJournalSkipOptions("8-docs.jsonl.gz"), { ...context, core })).resolves.toBe(
|
||||
false
|
||||
);
|
||||
|
||||
expect(replicator.skipStoppedJournalAndResume).not.toHaveBeenCalled();
|
||||
expect(core.services.context.standardIo.writeStderr).toHaveBeenCalledWith(
|
||||
expect.stringContaining("7-docs.jsonl.gz")
|
||||
);
|
||||
});
|
||||
|
||||
it("refuses when normal replication does not establish a stopped Journal", async () => {
|
||||
const core = createCoreMock();
|
||||
const replicator = createJournalReplicatorMock(undefined);
|
||||
core.services.replicator.getActiveReplicator.mockReturnValue(replicator);
|
||||
|
||||
await expect(runCommand(makeJournalSkipOptions(snapshot.key), { ...context, core })).resolves.toBe(false);
|
||||
|
||||
expect(replicator.skipStoppedJournalAndResume).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("refuses when the active replicator is not a Journal replicator", async () => {
|
||||
const core = createCoreMock();
|
||||
core.services.replicator.getActiveReplicator.mockReturnValue({ getStoppedJournal: vi.fn(() => snapshot) });
|
||||
core.services.replication.replicateUserInitiated.mockResolvedValue(
|
||||
replicationFailed(new Error("stopped Journal"))
|
||||
);
|
||||
|
||||
await expect(runCommand(makeJournalSkipOptions(snapshot.key), { ...context, core })).resolves.toBe(false);
|
||||
|
||||
expect(core.services.context.standardIo.writeStderr).toHaveBeenCalledWith(
|
||||
expect.stringContaining("Journal replicator")
|
||||
);
|
||||
});
|
||||
|
||||
it("propagates a failure while resuming after the requested Journal was skipped", async () => {
|
||||
const core = createCoreMock();
|
||||
const replicator = createJournalReplicatorMock(snapshot);
|
||||
replicator.skipStoppedJournalAndResume.mockResolvedValue(false);
|
||||
core.services.replicator.getActiveReplicator.mockReturnValue(replicator);
|
||||
core.services.replication.replicateUserInitiated.mockResolvedValue(
|
||||
replicationFailed(new Error("stopped Journal"))
|
||||
);
|
||||
|
||||
await expect(runCommand(makeJournalSkipOptions(snapshot.key), { ...context, core })).resolves.toBe(false);
|
||||
|
||||
expect(replicator.skipStoppedJournalAndResume).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("refuses if the active replicator changes during the awaited replication", async () => {
|
||||
const core = createCoreMock();
|
||||
const replicator = createJournalReplicatorMock(snapshot);
|
||||
const replacement = createJournalReplicatorMock(snapshot);
|
||||
core.services.replicator.getActiveReplicator
|
||||
.mockReturnValueOnce(replicator)
|
||||
.mockReturnValueOnce(replacement);
|
||||
core.services.replication.replicateUserInitiated.mockResolvedValue(
|
||||
replicationFailed(new Error("stopped Journal"))
|
||||
);
|
||||
|
||||
await expect(runCommand(makeJournalSkipOptions(snapshot.key), { ...context, core })).resolves.toBe(false);
|
||||
|
||||
expect(replicator.skipStoppedJournalAndResume).not.toHaveBeenCalled();
|
||||
expect(replacement.skipStoppedJournalAndResume).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it("reports a lock from the exact sync outcome without inspecting a replacement Replicator", async () => {
|
||||
const core = createCoreMock();
|
||||
core.services.replication.replicateUserInitiated.mockResolvedValue(
|
||||
@@ -546,9 +661,9 @@ describe("runCommand abnormal cases", () => {
|
||||
clock.mockReturnValue(end);
|
||||
const outOfWindow = createCoreMock();
|
||||
outOfWindow.services.context.standardIo.prompt.mockResolvedValue(passphrase);
|
||||
await expect(runCommand(makeOptions("setup", [uri.trim()]), { ...context, core: outOfWindow })).rejects.toThrow(
|
||||
"Cannot open Setup URI"
|
||||
);
|
||||
await expect(
|
||||
runCommand(makeOptions("setup", [uri.trim()]), { ...context, core: outOfWindow })
|
||||
).rejects.toThrow("Cannot open Setup URI");
|
||||
expect(outOfWindow.services.setting.applyExternalSettings).not.toHaveBeenCalled();
|
||||
expect(outOfWindow.services.control.applySettings).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
@@ -559,8 +674,12 @@ describe("runCommand abnormal cases", () => {
|
||||
it("setup imports managed TURN through the existing encrypted URI", async () => {
|
||||
const core = createCoreMock();
|
||||
const profiles = {
|
||||
turn: { id: "turn", name: "TURN", isEncrypted: false,
|
||||
uri: "sls+p2p://room?managedType=CF&managedId=turn-key&token=private-token" },
|
||||
turn: {
|
||||
id: "turn",
|
||||
name: "TURN",
|
||||
isEncrypted: false,
|
||||
uri: "sls+p2p://room?managedType=CF&managedId=turn-key&token=private-token",
|
||||
},
|
||||
};
|
||||
const passphrase = "setup-passphrase";
|
||||
const setupURI = await processSetting.encodeSettingsToSetupURI(
|
||||
|
||||
Reference in new issue
Block a user