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:
vorotamoroz committed 2026-11-04 15:25:30 +00:00
1 parent bf2241e5ee
commit a94c3fef90
18 files changed
+1321 -234

No files matched your search

+25
View File
@@ -80,6 +80,7 @@ livesync-cli [database-path] [command] [args...]
### Commands
- `sync`: Run one replication cycle with the remote CouchDB.
- `journal-skip <journal-key>`: Retry replication, skip only the exact currently stopped Object Storage Journal, and resume.
- `mirror [vault-path]`: Bidirectional sync between the local database and a local directory (**the actual vault**).
- If `vault-path` is provided, the CLI will synchronise the database with files in the vault directory.
- If `vault-path` is omitted, it defaults to `database-path` (compatibility mode).
@@ -108,6 +109,27 @@ livesync-cli [database-path] [command] [args...]
Remote-administration commands verify the resulting milestone state through the selected provider. The existing `[Verification]` lines remain suitable for scripts which inspect command output, while the default exit code now reflects whether that verification succeeded.
### Object Storage receive failures
`sync` stops at a Journal which cannot be processed or whose records cannot be
saved. After correcting the cause, run `sync` again with the same database and
settings to retry it. Existing partial writes are retained. `fetch --force`
replaces the local database instead and follows the reconstruction rules below.
If the Journal cannot be recovered and missing content or history is
acceptable, run `journal-skip <journal-key>`. The CLI first performs normal
replication with the retained local database. It skips only when that attempt
stops at the exact supplied key; if replication succeeds or stops at another
Journal, the command refuses the explicit skip. A successful normal attempt
can still acknowledge Journals which it processed successfully.
After the matching Journal is skipped, replication resumes. Any later failed
Journal still stops, so each additional skip requires a separate command with
that Journal's key. Normal identity, connectivity, authentication, and
readiness checks still apply. This action does not persist a setting for future
replication. Review the resulting file contents and retained history; a
completed receive does not guarantee that later file reflection or reads will
succeed.
### Interrupted reconstruction and older runtime state
An interrupted `fetch` or `rebuild` leaves a record in
@@ -146,6 +168,7 @@ scopes do not make concurrent writers to shared device settings supported.
```bash
# Basic sync with remote
livesync-cli ./my-db sync
livesync-cli ./my-db journal-skip 7-docs.jsonl.gz
# Mirroring to your actual Obsidian vault
livesync-cli ./my-db mirror /path/to/obsidian-vault
@@ -475,6 +498,8 @@ Commands:
daemon (default) Run mirror scan then continuously sync CouchDB <-> local filesystem
init-settings [path] Create unconfigured settings JSON with the new-Vault recommendations
sync Run one replication cycle and exit
journal-skip <journal-key>
Retry replication, skip only the matching stopped Journal, and resume
p2p-peers <timeout> Show discovered peers as [peer]<TAB><peer-id><TAB><peer-name>
p2p-sync <peer> <timeout> Synchronise with specified peer-id or peer-name
p2p-host Start P2P host mode and wait until interrupted (Ctrl+C)
+60 -4
View File
@@ -28,6 +28,7 @@ import {
USER_INITIATED_REPLICATION_AUTHORITY,
} from "@vrtmrz/livesync-commonlib/replication";
import { withOwnedRemoteResource } from "@/common/ownedRemoteResource";
import { LiveSyncJournalReplicator } from "@vrtmrz/livesync-commonlib/compat/replication/journal/LiveSyncJournalReplicator";
import {
isCentralRemoteAdministrationCommand,
runCentralRemoteAdministrationCommand,
@@ -45,10 +46,7 @@ export async function runCommand(options: CLIOptions, context: CLICommandContext
writeStderrLine(standardIo, `[Error] ${forceRequirementError}`);
return false;
}
if (
options.command === "fetch" &&
isRemediationModeActive(core.services.setting.currentSettings())
) {
if (options.command === "fetch" && isRemediationModeActive(core.services.setting.currentSettings())) {
writeStderrLine(
standardIo,
`[Error] The fetch command cannot run while remediation mode is active. Clear the modification-time limit before fetching.`
@@ -202,6 +200,64 @@ export async function runCommand(options: CLIOptions, context: CLICommandContext
return isReplicationCompleted(result);
}
if (options.command === "journal-skip") {
const [journalKey] = options.commandArgs;
if (options.commandArgs.length !== 1 || journalKey === undefined || journalKey.length === 0) {
writeStderrLine(standardIo, `[Error] journal-skip requires one argument: <journal-key>`);
return false;
}
writeStdoutLine(standardIo, `[Command] journal-skip ${journalKey}`);
const replicatorBeforeReplication = core.services.replicator.getActiveReplicator();
const result = await core.services.replication.replicateUserInitiated({
trigger: "manual",
progressPresentation: REPLICATION_PROGRESS_PRESENTATIONS.NOTICE,
interaction: USER_INITIATED_REPLICATION_AUTHORITY,
});
const activeReplicator = core.services.replicator.getActiveReplicator();
if (activeReplicator !== replicatorBeforeReplication) {
writeStderrLine(
standardIo,
`[Error] The active replicator changed during replication; no Journal was skipped.`
);
return false;
}
if (isReplicationCompleted(result)) {
writeStderrLine(
standardIo,
`[Error] Replication completed without a stopped Journal; no Journal was skipped.`
);
return false;
}
if (!(activeReplicator instanceof LiveSyncJournalReplicator)) {
writeStderrLine(
standardIo,
`[Error] The active replicator is not a Journal replicator; no Journal was skipped.`
);
return false;
}
const stoppedJournal = activeReplicator.getStoppedJournal();
if (!stoppedJournal) {
writeStderrLine(standardIo, `[Error] Replication did not stop at a Journal; no Journal was skipped.`);
return false;
}
if (stoppedJournal.key !== journalKey) {
writeStderrLine(
standardIo,
`[Error] Replication stopped at Journal '${stoppedJournal.key}' (${stoppedJournal.stage}), not '${journalKey}'; no Journal was skipped.`
);
return false;
}
return await activeReplicator.skipStoppedJournalAndResume(
core.services.setting.currentSettings(),
stoppedJournal,
true
);
}
if (options.command === "p2p-peers") {
if (options.commandArgs.length < 1) {
throw new Error("p2p-peers requires one argument: <timeout>");
+124 -5
View File
@@ -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(
+2
View File
@@ -7,6 +7,7 @@ import type { ReplicationSchedulingControl } from "@/serviceFeatures/replication
export type CLICommand =
| "daemon"
| "sync"
| "journal-skip"
| "p2p-peers"
| "p2p-sync"
| "p2p-host"
@@ -87,6 +88,7 @@ export interface CLICommandContext {
export const VALID_COMMANDS = new Set([
"daemon",
"sync",
"journal-skip",
"p2p-peers",
"p2p-sync",
"p2p-host",
@@ -2,6 +2,16 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import type { LOG_LEVEL } from "@vrtmrz/livesync-commonlib/compat/common/types";
import {
Logger,
LOG_LEVEL_DEBUG,
LOG_LEVEL_INFO,
LOG_LEVEL_NOTICE,
LOG_LEVEL_URGENT,
LOG_LEVEL_VERBOSE,
setGlobalLogFunction,
} from "octagonal-wheels/common/logger";
const mocks = vi.hoisted(() => ({
setLogHandler: vi.fn(),
@@ -29,6 +39,18 @@ function createStandardIoMock() {
};
}
async function invokeGlobalLogger(databasePath: string, message: string, level: LOG_LEVEL, debug = false) {
process.argv = ["node", "livesync-cli", databasePath, ...(debug ? ["--debug"] : []), "remote-ls"];
const standardIo = createStandardIoMock();
mocks.setLogHandler.mockImplementation(() => {
Logger(message, level);
throw new Error("__LOG_HANDLER_CONFIGURED__");
});
await expect(main(standardIo)).rejects.toThrow("__LOG_HANDLER_CONFIGURED__");
return standardIo;
}
describe("CLI log handler", () => {
const originalArgv = process.argv.slice();
let databasePath: string;
@@ -43,6 +65,7 @@ describe("CLI log handler", () => {
afterEach(async () => {
process.argv = originalArgv.slice();
setGlobalLogFunction(() => {});
await rm(databasePath, { recursive: true, force: true });
});
@@ -52,4 +75,50 @@ describe("CLI log handler", () => {
await expect(main(createStandardIoMock())).rejects.toThrow("__LOG_HANDLER_CONFIGURED__");
expect(mocks.setLogHandler).toHaveBeenCalledWith(expect.any(Function), true);
});
it.each([
["NOTICE", LOG_LEVEL_NOTICE],
["URGENT", LOG_LEVEL_URGENT],
] as const)("writes Commonlib %s diagnostics to stderr by default", async (name, level) => {
const message = `Commonlib ${name} diagnostic`;
const standardIo = await invokeGlobalLogger(databasePath, message, level);
expect(standardIo.writeStderr).toHaveBeenCalledWith(expect.stringContaining(`[${level}] ${message}\n`));
expect(standardIo.writeStdout).not.toHaveBeenCalledWith(expect.stringContaining(message));
});
it.each([
["DEBUG", LOG_LEVEL_DEBUG],
["INFO", LOG_LEVEL_INFO],
["VERBOSE", LOG_LEVEL_VERBOSE],
] as const)("keeps Commonlib %s diagnostics hidden by default", async (name, level) => {
const message = `Commonlib ${name} diagnostic`;
const standardIo = await invokeGlobalLogger(databasePath, message, level);
expect(standardIo.writeStderr).not.toHaveBeenCalledWith(expect.stringContaining(message));
expect(standardIo.writeStdout).not.toHaveBeenCalledWith(expect.stringContaining(message));
});
it("writes detailed Commonlib diagnostics to stderr in debug mode", async () => {
const messages = [
["DEBUG", LOG_LEVEL_DEBUG],
["INFO", LOG_LEVEL_INFO],
["VERBOSE", LOG_LEVEL_VERBOSE],
] as const;
process.argv = ["node", "livesync-cli", databasePath, "--debug", "remote-ls"];
const standardIo = createStandardIoMock();
mocks.setLogHandler.mockImplementation(() => {
for (const [name, level] of messages) Logger(`Commonlib ${name} diagnostic`, level);
throw new Error("__LOG_HANDLER_CONFIGURED__");
});
await expect(main(standardIo)).rejects.toThrow("__LOG_HANDLER_CONFIGURED__");
for (const [name, level] of messages) {
expect(standardIo.writeStderr).toHaveBeenCalledWith(
expect.stringContaining(`[${level}] Commonlib ${name} diagnostic\n`)
);
}
expect(standardIo.writeStdout).not.toHaveBeenCalledWith(expect.stringContaining("Commonlib"));
});
});
+6 -9
View File
@@ -108,6 +108,8 @@ Arguments:
Commands:
daemon (default) Run mirror scan then continuously sync CouchDB <-> local filesystem
sync Run one replication cycle and exit
journal-skip <journal-key>
Retry replication, skip only the matching stopped Journal, and resume
p2p-peers <timeout> Show discovered peers as [peer]\t<peer-id>\t<peer-name>
p2p-sync <peer> <timeout>
Sync with the specified peer-id or peer-name
@@ -376,15 +378,10 @@ export async function main(
const writeLine = avoidStdoutNoise ? writeStderrLine : writeStdoutLine;
writeLine(standardIo, ...values);
};
if (options.debug) {
setGlobalLogFunction((msg, level) => {
writeStderrLine(standardIo, `[${level}]`, msg);
});
} else {
setGlobalLogFunction((msg, level) => {
// NO OP, leave it to logFunction
});
}
setGlobalLogFunction((msg, level) => {
if (!options.debug && level !== LOG_LEVEL_NOTICE && level !== LOG_LEVEL_URGENT) return;
writeStderrLine(standardIo, `[${level}]`, msg);
});
if (options.command === "init-settings") {
await createDefaultSettingsFile(options, standardIo);
return;
+10
View File
@@ -72,6 +72,7 @@ describe("CLI parseArgs", () => {
expect(combined).toContain("Usage:");
expect(combined).toContain("livesync-cli <database-path> [options] <command> [command-args]");
expect(combined).toContain("--compat-remote-admin-exit-zero");
expect(combined).toContain("journal-skip <journal-key>");
});
it("parses p2p-peers command and timeout", () => {
@@ -101,6 +102,15 @@ describe("CLI parseArgs", () => {
expect(parsed.commandArgs).toEqual([]);
});
it("parses journal-skip and its exact Journal key", () => {
process.argv = ["node", "livesync-cli", "./databasePath", "journal-skip", "7-docs.jsonl.gz"];
const parsed = parseArgs();
expect(parsed.databasePath).toBe("./databasePath");
expect(parsed.command).toBe("journal-skip");
expect(parsed.commandArgs).toEqual(["7-docs.jsonl.gz"]);
});
it.each(["rebuild", "fetch"] as const)("parses %s and its force flag", (command) => {
process.argv = ["node", "livesync-cli", "./databasePath", "--force", command];
const parsed = parseArgs();
@@ -7,6 +7,18 @@
* remove it from this map in the same change.
*/
export const liveSyncProvisionalEnglishMessages = {
"Skip stopped Journal and resume": "Skip stopped Journal and resume",
"Object Storage only. Confirm to skip the currently stopped Journal and resume replication. The confirmation names the exact Journal key and failure stage. Skipping may leave content or history missing; the next Journal failure still stops replication.":
"Object Storage only. Confirm to skip the currently stopped Journal and resume replication. The confirmation names the exact Journal key and failure stage. Skipping may leave content or history missing; the next Journal failure still stops replication.",
"Skip and resume": "Skip and resume",
"No stopped Journal is available. Retry replication to identify the stopped Journal.":
"No stopped Journal is available. Retry replication to identify the stopped Journal.",
"Skip stopped Journal '${KEY}' at failure stage '${STAGE}' and resume replication? This may leave content or history missing.":
"Skip stopped Journal '${KEY}' at failure stage '${STAGE}' and resume replication? This may leave content or history missing.",
"The active replicator changed. Retry replication to identify the stopped Journal.":
"The active replicator changed. Retry replication to identify the stopped Journal.",
"Journal receipt did not complete after this action. Check the current failure before retrying.":
"Journal receipt did not complete after this action. Check the current failure before retrying.",
"File identity": "File identity",
"Version 0 preserves legacy path identity; version 1 uses Unicode NFC. Physical file names stay unchanged. Update every device and stop synchronisation, then Rebuild from a complete authoritative Vault and Fetch on the other devices. With P2P, Rebuild prepares only the source device's local database. Resolve colliding file names first.":
"Version 0 preserves legacy path identity; version 1 uses Unicode NFC. Physical file names stay unchanged. Update every device and stop synchronisation, then Rebuild from a complete authoritative Vault and Fetch on the other devices. With P2P, Rebuild prepares only the source device's local database. Resolve colliding file names first.",
@@ -1,5 +1,6 @@
import { EVENT_REQUEST_PERFORM_GC_V3, eventHub } from "@/common/events.ts";
import { $msg } from "@/common/translation";
import { Notice } from "@/deps.ts";
import {
createCoreSettingsAfterFullReset,
createEditingSettingsAfterFullReset,
@@ -8,6 +9,8 @@ import { LOG_LEVEL_NOTICE, Logger } from "@vrtmrz/livesync-commonlib/compat/comm
import { FlagFilesHumanReadable, FlagFilesOriginal } from "@vrtmrz/livesync-commonlib/compat/common/types";
import { fireAndForget } from "@vrtmrz/livesync-commonlib/compat/common/utils";
import { LiveSyncCouchDBReplicator } from "@vrtmrz/livesync-commonlib/compat/replication/couchdb/LiveSyncReplicator";
import { LiveSyncJournalReplicator } from "@vrtmrz/livesync-commonlib/compat/replication/journal/LiveSyncJournalReplicator";
import type { StoppedJournal } from "@vrtmrz/livesync-commonlib/compat/replication/journal/JournalSyncTypes";
import { LiveSyncSetting as Setting } from "./LiveSyncSetting.ts";
import type { ObsidianLiveSyncSettingTab } from "./ObsidianLiveSyncSettingTab";
import { visibleOnly, type PageFunctions } from "./SettingPane";
@@ -151,6 +154,73 @@ export function paneMaintenance(
)
.addOnUpdate(this.onlyOnCouchDB);
new Setting(paneEl)
.setName($msg("Skip stopped Journal and resume"))
.setDesc(
$msg(
"Object Storage only. Confirm to skip the currently stopped Journal and resume replication. The confirmation names the exact Journal key and failure stage. Skipping may leave content or history missing; the next Journal failure still stops replication."
)
)
.addButton((button) =>
setButtonDestructiveState(button)
.setButtonText($msg("Skip and resume"))
.setDisabled(false)
.onClick(async () => {
const replicator = this.core.replicator;
if (!(replicator instanceof LiveSyncJournalReplicator)) {
new Notice(
$msg(
"No stopped Journal is available. Retry replication to identify the stopped Journal."
)
);
return;
}
const stoppedJournal: Readonly<StoppedJournal> | undefined = replicator.getStoppedJournal();
if (!stoppedJournal) {
new Notice(
$msg(
"No stopped Journal is available. Retry replication to identify the stopped Journal."
)
);
return;
}
const confirmed =
(await this.core.confirm.askYesNoDialog(
$msg(
"Skip stopped Journal '${KEY}' at failure stage '${STAGE}' and resume replication? This may leave content or history missing.",
{ KEY: stoppedJournal.key, STAGE: stoppedJournal.stage }
),
{
title: $msg("Skip stopped Journal and resume"),
defaultOption: "No",
}
)) === "yes";
if (!confirmed) {
return;
}
if (this.core.replicator !== replicator) {
new Notice(
$msg(
"The active replicator changed. Retry replication to identify the stopped Journal."
)
);
return;
}
const resumed = await replicator.skipStoppedJournalAndResume(
this.core.settings,
stoppedJournal
);
if (!resumed) {
new Notice(
$msg(
"Journal receipt did not complete after this action. Check the current failure before retrying."
)
);
}
})
)
.addOnUpdate(this.onlyOnMinIO);
new Setting(paneEl)
.setName($msg("Reset journal received history"))
.setDesc(
@@ -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."
);
});
});