Route replication through provider capabilities

This commit is contained in:
vorotamoroz
2026-08-27 11:16:30 +00:00
parent 7aa41baf08
commit 56444bb98b
23 changed files with 918 additions and 205 deletions
@@ -1,5 +1,6 @@
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
import { createServiceContext } from "@vrtmrz/livesync-commonlib/context";
import { NO_INTERACTION } from "@vrtmrz/livesync-commonlib/replication";
import { runCommand } from "./runCommand";
import type { CLIOptions } from "./types";
@@ -18,6 +19,7 @@ vi.mock("@vrtmrz/livesync-commonlib/compat/services/base/UnresolvedErrorManager"
}));
import * as offlineScanner from "@vrtmrz/livesync-commonlib/compat/serviceFeatures/offlineScanner";
import { getReplicationSchedulingControl } from "@/modules/core/ReplicationScheduling";
function createCoreMock() {
const standardIo = {
@@ -38,7 +40,7 @@ function createCoreMock() {
currentSettings: vi.fn(() => ({ liveSync: true, syncOnStart: false })),
},
replication: {
replicate: vi.fn(async () => true),
replicateUnattended: vi.fn(async () => ({ status: "completed" as const })),
},
appLifecycle: {
onUnload: {
@@ -123,6 +125,7 @@ describe("daemon command", () => {
await runCommand(makeDaemonOptions(30), { ...baseContext, core });
expect(setTimeoutSpy).toHaveBeenCalledTimes(1);
expect(getReplicationSchedulingControl(core).externalPolling).toBe(true);
// Interval should be in milliseconds (30s → 30000ms)
expect(setTimeoutSpy).toHaveBeenCalledWith(expect.any(Function), 30000);
});
@@ -194,9 +197,9 @@ describe("daemon command", () => {
it("calls replicate before performFullScan", async () => {
const core = createCoreMock();
const callOrder: string[] = [];
core.services.replication.replicate = vi.fn(async () => {
core.services.replication.replicateUnattended = vi.fn(async () => {
callOrder.push("replicate");
return true;
return { status: "completed" as const };
});
vi.mocked(offlineScanner.performFullScan).mockImplementation(async () => {
callOrder.push("performFullScan");
@@ -206,11 +209,19 @@ describe("daemon command", () => {
await runCommand(makeDaemonOptions(), { ...baseContext, core });
expect(callOrder).toEqual(["replicate", "performFullScan"]);
expect(core.services.replication.replicateUnattended).toHaveBeenCalledWith({
trigger: "daemon",
interaction: NO_INTERACTION,
});
expect(getReplicationSchedulingControl(core).initialOneShotSatisfied).toBe(true);
});
it("returns false when initial replication fails", async () => {
const core = createCoreMock();
core.services.replication.replicate = vi.fn(async () => false);
core.services.replication.replicateUnattended = vi.fn(async () => ({
status: "failed" as const,
error: new Error("initial replication failed"),
}));
vi.mocked(offlineScanner.performFullScan).mockClear();
const result = await runCommand(makeDaemonOptions(), { ...baseContext, core });
@@ -218,6 +229,10 @@ describe("daemon command", () => {
expect(result).toBe(false);
// performFullScan should NOT have been called
expect(offlineScanner.performFullScan).not.toHaveBeenCalled();
expect(core.services.replication.replicateUnattended).toHaveBeenCalledWith({
trigger: "daemon",
interaction: NO_INTERACTION,
});
});
it("polling mode: registers onUnload handler that clears timeout", async () => {
@@ -242,11 +257,11 @@ describe("daemon command", () => {
// startup replicate (call 1) succeeds; poll calls 27 fail; call 8 succeeds.
let callCount = 0;
core.services.replication.replicate = vi.fn(async () => {
core.services.replication.replicateUnattended = vi.fn(async () => {
callCount++;
if (callCount === 1) return true; // initial startup replicate
if (callCount === 1) return { status: "completed" as const }; // initial startup replicate
if (callCount <= 7) throw new Error("network failure");
return true; // recovery
return { status: "completed" as const }; // recovery
});
const baseMs = 30 * 1000;
@@ -297,9 +312,9 @@ describe("daemon command", () => {
// Make replicate succeed on the initial call (startup), then fail on the poll.
let callCount = 0;
core.services.replication.replicate = vi.fn(async () => {
core.services.replication.replicateUnattended = vi.fn(async () => {
callCount++;
if (callCount === 1) return true; // startup replicate
if (callCount === 1) return { status: "completed" as const }; // startup replicate
throw new Error("network failure");
});
+41 -16
View File
@@ -26,6 +26,12 @@ import { fsPromises as fs, path } from "@vrtmrz/livesync-commonlib/node";
import type { LiveSyncCouchDBReplicator } from "@vrtmrz/livesync-commonlib/compat/replication/couchdb/LiveSyncReplicator";
import type { LiveSyncJournalReplicator } from "@vrtmrz/livesync-commonlib/compat/replication/journal/LiveSyncJournalReplicator";
import { writeStderrLine, writeStdoutLine } from "@/apps/cli/cliOutput";
import {
isReplicationCompleted,
NO_INTERACTION,
USER_INITIATED_REPLICATION_AUTHORITY,
} from "@vrtmrz/livesync-commonlib/replication";
import { markInitialOneShotSatisfied, setExternalPollingMode } from "@/modules/core/ReplicationScheduling";
function redactConnectionString(uri: string): string {
return uri.replace(/\/\/([^@/]+)@/u, "//***@");
@@ -95,19 +101,28 @@ export async function runCommand(options: CLIOptions, context: CLICommandContext
if (options.command === "daemon") {
const log = (msg: unknown) => writeStderrLine(standardIo, `[Daemon] ${String(msg)}`);
// The daemon owns its own recurring poller. Suppress the application
// resume starter and generic periodic timer before restoring settings.
setExternalPollingMode(core, !!options.interval);
// Skip the config mismatch dialog — the daemon cannot resolve it interactively
// and the default "Dismiss" action would block replication. The daemon should
// accept whatever configuration the remote has.
await core.services.setting.applyPartial({ disableCheckingConfigMismatch: true }, true);
// 1. Replicate CouchDB → local PouchDB so the mirror scan has content to work with.
log("Replicating from CouchDB...");
const replResult = await core.services.replication.replicate(true);
if (!replResult) {
writeStderrLine(standardIo, "[Daemon] Initial CouchDB replication failed, cannot continue");
// 1. Replicate the configured remote into the local database so the
// mirror scan has content to work with.
log("Replicating from remote...");
const replResult = await core.services.replication.replicateUnattended({
trigger: "daemon",
interaction: NO_INTERACTION,
});
if (!isReplicationCompleted(replResult)) {
writeStderrLine(standardIo, "[Daemon] Initial replication failed, cannot continue");
return false;
}
log("CouchDB replication complete");
markInitialOneShotSatisfied(core);
log("Initial replication complete");
// 2. Mirror scan to reconcile PouchDB ↔ local filesystem.
const errorManager = new UnresolvedErrorManager(core.services.appLifecycle, core.services.context.events);
@@ -129,8 +144,9 @@ export async function runCommand(options: CLIOptions, context: CLICommandContext
true
);
// applySettings fires the full lifecycle: onSuspending → onResumed.
// ModuleReplicatorCouchDB starts continuous replication on onResumed
// via fireAndForget.
// The provider-independent lifecycle coordinator owns any eligible
// Continuous start; the daemon marker suppresses a duplicate
// sync-on-start OneShot.
await core.services.control.applySettings();
// Lifecycle events (onSuspending) may re-enable suspension flags.
// Clear them explicitly after the lifecycle completes. applyPartial
@@ -153,7 +169,13 @@ export async function runCommand(options: CLIOptions, context: CLICommandContext
const poll = async () => {
try {
await core.services.replication.replicate(true);
const result = await core.services.replication.replicateUnattended({
trigger: "daemon",
interaction: NO_INTERACTION,
});
if (!isReplicationCompleted(result)) {
throw new Error(`Daemon polling replication did not complete (${result.status}).`);
}
if (consecutiveFailures > 0) {
consecutiveFailures--;
currentIntervalMs = Math.max(currentIntervalMs / 2, baseIntervalMs);
@@ -182,11 +204,11 @@ export async function runCommand(options: CLIOptions, context: CLICommandContext
return true;
});
} else {
log("LiveSync mode: restoring sync settings and starting _changes feed");
log("LiveSync mode: restoring sync settings and starting continuous synchronisation where supported");
await restoreSyncSettings();
// The applySettings() lifecycle fires onResumed → ModuleReplicatorCouchDB which
// starts continuous replication via fireAndForget(openReplication). Don't call
// openReplication directly — it races with the handler and causes dedup/termination.
// The applySettings() lifecycle fires onResumed → the provider-
// independent lifecycle coordinator, which starts Continuous when
// supported. Do not call a concrete Replicator directly.
log("LiveSync active");
const currentSettings = core.services.setting.currentSettings();
if (!currentSettings.liveSync && !currentSettings.syncOnStart) {
@@ -204,8 +226,11 @@ export async function runCommand(options: CLIOptions, context: CLICommandContext
if (options.command === "sync") {
writeStdoutLine(standardIo, "[Command] sync");
const result = await core.services.replication.replicate(true);
if (!result) {
const result = await core.services.replication.replicateUserInitiated({
trigger: "manual",
interaction: USER_INITIATED_REPLICATION_AUTHORITY,
});
if (!isReplicationCompleted(result)) {
// TODO: Standardise the logic for identifying the cause of replication
// failure so that every reason (locked DB, version mismatch, network
// error, etc.) is surfaced with a CLI-specific actionable message.
@@ -218,7 +243,7 @@ export async function runCommand(options: CLIOptions, context: CLICommandContext
);
}
}
return !!result;
return isReplicationCompleted(result);
}
if (options.command === "p2p-peers") {