fix(cli): prepare daemon and mirror Vaults during startup

This commit is contained in:
vorotamoroz
2026-09-15 12:06:35 +00:00
parent 7c1c913f1d
commit 70cfbd43a9
12 changed files with 462 additions and 67 deletions
@@ -4,7 +4,7 @@ import { NO_INTERACTION } from "@vrtmrz/livesync-commonlib/replication";
import { runCommand } from "./runCommand";
import type { CLIOptions } from "./types";
// Mock performFullScan so daemon tests don't require a real CouchDB connection.
// Track explicit scans: database preparation owns the daemon startup scan.
vi.mock("@vrtmrz/livesync-commonlib/compat/serviceFeatures/offlineScanner", () => ({
performFullScan: vi.fn(async () => true),
}));
@@ -102,6 +102,7 @@ function createDaemonContext(core: ReturnType<typeof createCoreMock>) {
describe("daemon command", () => {
beforeEach(() => {
vi.restoreAllMocks();
vi.mocked(offlineScanner.performFullScan).mockClear();
vi.useFakeTimers();
});
@@ -109,27 +110,16 @@ describe("daemon command", () => {
vi.useRealTimers();
});
it("calls performFullScan during startup", async () => {
it("does not repeat the startup scan after initial replication", async () => {
const core = createCoreMock();
vi.mocked(offlineScanner.performFullScan).mockResolvedValue(true);
await runCommand(makeDaemonOptions(), createDaemonContext(core));
expect(await runCommand(makeDaemonOptions(), createDaemonContext(core))).toBe(true);
expect(offlineScanner.performFullScan).toHaveBeenCalledTimes(1);
});
it("returns false when performFullScan fails", async () => {
const core = createCoreMock();
vi.mocked(offlineScanner.performFullScan).mockResolvedValue(false);
const result = await runCommand(makeDaemonOptions(), createDaemonContext(core));
expect(result).toBe(false);
expect(offlineScanner.performFullScan).not.toHaveBeenCalled();
});
it("polling mode: calls setTimeout when interval option is set", async () => {
const core = createCoreMock();
vi.mocked(offlineScanner.performFullScan).mockResolvedValue(true);
const setTimeoutSpy = vi.spyOn(globalThis, "setTimeout");
const context = createDaemonContext(core);
@@ -143,7 +133,6 @@ describe("daemon command", () => {
it("polling mode: applies settings with suspendFileWatching=false before setting interval", async () => {
const core = createCoreMock();
vi.mocked(offlineScanner.performFullScan).mockResolvedValue(true);
await runCommand(makeDaemonOptions(10), createDaemonContext(core));
@@ -156,7 +145,6 @@ describe("daemon command", () => {
it("liveSync mode: calls applyPartial and applySettings", async () => {
const core = createCoreMock();
vi.mocked(offlineScanner.performFullScan).mockResolvedValue(true);
await runCommand(makeDaemonOptions(), createDaemonContext(core));
@@ -176,7 +164,6 @@ describe("daemon command", () => {
liveSync: false,
syncOnStart: false,
}));
vi.mocked(offlineScanner.performFullScan).mockResolvedValue(true);
const result = await runCommand(makeDaemonOptions(), createDaemonContext(core));
@@ -194,7 +181,6 @@ describe("daemon command", () => {
liveSync: true,
syncOnStart: false,
}));
vi.mocked(offlineScanner.performFullScan).mockResolvedValue(true);
await runCommand(makeDaemonOptions(), createDaemonContext(core));
@@ -205,22 +191,21 @@ describe("daemon command", () => {
expect(warningCalls.length).toBe(0);
});
it("calls replicate before performFullScan", async () => {
it("completes initial replication before restoring automatic synchronisation", async () => {
const core = createCoreMock();
const callOrder: string[] = [];
core.services.replication.replicateUnattended = vi.fn(async () => {
callOrder.push("replicate");
return { status: "completed" as const };
});
vi.mocked(offlineScanner.performFullScan).mockImplementation(async () => {
callOrder.push("performFullScan");
return true;
core.services.control.applySettings.mockImplementation(async () => {
callOrder.push("restoreSettings");
});
const context = createDaemonContext(core);
await runCommand(makeDaemonOptions(), context);
expect(callOrder).toEqual(["replicate", "performFullScan"]);
expect(callOrder).toEqual(["replicate", "restoreSettings"]);
expect(core.services.replication.replicateUnattended).toHaveBeenCalledWith({
trigger: "daemon",
interaction: NO_INTERACTION,
@@ -234,12 +219,11 @@ describe("daemon command", () => {
status: "failed" as const,
error: new Error("initial replication failed"),
}));
vi.mocked(offlineScanner.performFullScan).mockClear();
const result = await runCommand(makeDaemonOptions(), createDaemonContext(core));
expect(result).toBe(false);
// performFullScan should NOT have been called
expect(core.services.control.applySettings).not.toHaveBeenCalled();
expect(offlineScanner.performFullScan).not.toHaveBeenCalled();
expect(core.services.replication.replicateUnattended).toHaveBeenCalledWith({
trigger: "daemon",
@@ -249,7 +233,6 @@ describe("daemon command", () => {
it("polling mode: registers onUnload handler that clears timeout", async () => {
const core = createCoreMock();
vi.mocked(offlineScanner.performFullScan).mockResolvedValue(true);
await runCommand(makeDaemonOptions(10), createDaemonContext(core));
@@ -265,7 +248,6 @@ describe("daemon command", () => {
it("polling backoff: interval escalates on failure, caps at 300000ms, then halves on recovery", async () => {
const core = createCoreMock();
vi.mocked(offlineScanner.performFullScan).mockResolvedValue(true);
// startup replicate (call 1) succeeds; poll calls 2–7 fail; call 8 succeeds.
let callCount = 0;
@@ -320,7 +302,6 @@ describe("daemon command", () => {
it("polling error handling: replicate rejection is caught and written to standard error", async () => {
const core = createCoreMock();
vi.mocked(offlineScanner.performFullScan).mockResolvedValue(true);
// Make replicate succeed on the initial call (startup), then fail on the poll.
let callCount = 0;
+6 -22
View File
@@ -15,11 +15,6 @@ import { stripAllPrefixes } from "@vrtmrz/livesync-commonlib/compat/string_and_b
import type { CLICommandContext, CLIOptions } from "./types";
import { toArrayBuffer, toDatabaseRelativePath } from "./utils";
import { collectPeers, openP2PHost, parseTimeoutSeconds, syncWithPeer } from "./p2p";
import {
performFullScan,
VaultScanResults,
} from "@vrtmrz/livesync-commonlib/compat/serviceFeatures/offlineScanner";
import { UnresolvedErrorManager } from "@vrtmrz/livesync-commonlib/compat/services/base/UnresolvedErrorManager";
import { compatGlobal } from "@vrtmrz/livesync-commonlib/compat/common/coreEnvFunctions";
import { fsPromises as fs, path } from "@vrtmrz/livesync-commonlib/node";
import { writeStderrLine, writeStdoutLine } from "@/apps/cli/cliOutput";
@@ -59,9 +54,9 @@ export async function runCommand(options: CLIOptions, context: CLICommandContext
// accept whatever configuration the remote has.
await core.services.setting.applyPartial({ disableCheckingConfigMismatch: true }, true);
// 1. Replicate the configured remote into the local database so the
// mirror scan has content to work with.
log("Replicating from remote...");
// Database preparation has already reconciled the local database and Vault.
// Replicate before restoring automatic synchronisation.
log("Replicating with remote...");
const replResult = await core.services.replication.replicateUnattended({
trigger: "daemon",
interaction: NO_INTERACTION,
@@ -73,17 +68,7 @@ export async function runCommand(options: CLIOptions, context: CLICommandContext
replicationScheduling.markInitialOneShotSatisfied();
log("Initial replication complete");
// 2. Mirror scan to reconcile PouchDB ↔ local filesystem.
const errorManager = new UnresolvedErrorManager(core.services.appLifecycle, core.services.context.events);
log("Running mirror scan...");
const scanOk = await performFullScan(core, log, errorManager, false, true);
if (!scanOk) {
writeStderrLine(standardIo, "[Daemon] Mirror scan failed, cannot continue");
return false;
}
log("Mirror scan complete");
// 3. Re-enable sync.
// Re-enable sync.
const restoreSyncSettings = async () => {
await core.services.setting.applyPartial(
{
@@ -530,9 +515,8 @@ export async function runCommand(options: CLIOptions, context: CLICommandContext
if (options.command === "mirror") {
writeStderrLine(standardIo, "[Command] mirror");
const log = (msg: unknown) => writeStderrLine(standardIo, `[Mirror] ${String(msg)}`);
const errorManager = new UnresolvedErrorManager(core.services.appLifecycle, core.services.context.events);
return (await performFullScan(core, log, errorManager, false, true)) === VaultScanResults.COMPLETED;
// Database preparation has already completed the mirror scan.
return true;
}
if (options.command === "remote-add") {