mirror of
https://github.com/vrtmrz/obsidian-livesync.git
synced 2026-09-21 18:17:05 +00:00
Compose replication scheduling as a service feature
This commit is contained in:
@@ -19,7 +19,6 @@ 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 = {
|
||||
@@ -89,6 +88,17 @@ const baseContext = {
|
||||
},
|
||||
} as any;
|
||||
|
||||
function createDaemonContext(core: ReturnType<typeof createCoreMock>) {
|
||||
return {
|
||||
...baseContext,
|
||||
core,
|
||||
replicationScheduling: {
|
||||
setExternalPollingMode: vi.fn(),
|
||||
markInitialOneShotSatisfied: vi.fn(),
|
||||
},
|
||||
} as any;
|
||||
}
|
||||
|
||||
describe("daemon command", () => {
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
@@ -103,7 +113,7 @@ describe("daemon command", () => {
|
||||
const core = createCoreMock();
|
||||
vi.mocked(offlineScanner.performFullScan).mockResolvedValue(true);
|
||||
|
||||
await runCommand(makeDaemonOptions(), { ...baseContext, core });
|
||||
await runCommand(makeDaemonOptions(), createDaemonContext(core));
|
||||
|
||||
expect(offlineScanner.performFullScan).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
@@ -112,7 +122,7 @@ describe("daemon command", () => {
|
||||
const core = createCoreMock();
|
||||
vi.mocked(offlineScanner.performFullScan).mockResolvedValue(false);
|
||||
|
||||
const result = await runCommand(makeDaemonOptions(), { ...baseContext, core });
|
||||
const result = await runCommand(makeDaemonOptions(), createDaemonContext(core));
|
||||
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
@@ -122,10 +132,11 @@ describe("daemon command", () => {
|
||||
vi.mocked(offlineScanner.performFullScan).mockResolvedValue(true);
|
||||
const setTimeoutSpy = vi.spyOn(globalThis, "setTimeout");
|
||||
|
||||
await runCommand(makeDaemonOptions(30), { ...baseContext, core });
|
||||
const context = createDaemonContext(core);
|
||||
await runCommand(makeDaemonOptions(30), context);
|
||||
|
||||
expect(setTimeoutSpy).toHaveBeenCalledTimes(1);
|
||||
expect(getReplicationSchedulingControl(core).externalPolling).toBe(true);
|
||||
expect(context.replicationScheduling.setExternalPollingMode).toHaveBeenCalledWith(true);
|
||||
// Interval should be in milliseconds (30s → 30000ms)
|
||||
expect(setTimeoutSpy).toHaveBeenCalledWith(expect.any(Function), 30000);
|
||||
});
|
||||
@@ -134,7 +145,7 @@ describe("daemon command", () => {
|
||||
const core = createCoreMock();
|
||||
vi.mocked(offlineScanner.performFullScan).mockResolvedValue(true);
|
||||
|
||||
await runCommand(makeDaemonOptions(10), { ...baseContext, core });
|
||||
await runCommand(makeDaemonOptions(10), createDaemonContext(core));
|
||||
|
||||
expect(core.services.setting.applyPartial).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ suspendFileWatching: false }),
|
||||
@@ -147,7 +158,7 @@ describe("daemon command", () => {
|
||||
const core = createCoreMock();
|
||||
vi.mocked(offlineScanner.performFullScan).mockResolvedValue(true);
|
||||
|
||||
await runCommand(makeDaemonOptions(), { ...baseContext, core });
|
||||
await runCommand(makeDaemonOptions(), createDaemonContext(core));
|
||||
|
||||
expect(core.services.setting.applyPartial).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
@@ -167,7 +178,7 @@ describe("daemon command", () => {
|
||||
}));
|
||||
vi.mocked(offlineScanner.performFullScan).mockResolvedValue(true);
|
||||
|
||||
const result = await runCommand(makeDaemonOptions(), { ...baseContext, core });
|
||||
const result = await runCommand(makeDaemonOptions(), createDaemonContext(core));
|
||||
|
||||
expect(result).toBe(true);
|
||||
const warningCalls = core.services.context.standardIo.writeStderr.mock.calls.filter(
|
||||
@@ -185,7 +196,7 @@ describe("daemon command", () => {
|
||||
}));
|
||||
vi.mocked(offlineScanner.performFullScan).mockResolvedValue(true);
|
||||
|
||||
await runCommand(makeDaemonOptions(), { ...baseContext, core });
|
||||
await runCommand(makeDaemonOptions(), createDaemonContext(core));
|
||||
|
||||
const warningCalls = core.services.context.standardIo.writeStderr.mock.calls.filter(
|
||||
([chunk]: [string | Uint8Array]) =>
|
||||
@@ -206,14 +217,15 @@ describe("daemon command", () => {
|
||||
return true;
|
||||
});
|
||||
|
||||
await runCommand(makeDaemonOptions(), { ...baseContext, core });
|
||||
const context = createDaemonContext(core);
|
||||
await runCommand(makeDaemonOptions(), context);
|
||||
|
||||
expect(callOrder).toEqual(["replicate", "performFullScan"]);
|
||||
expect(core.services.replication.replicateUnattended).toHaveBeenCalledWith({
|
||||
trigger: "daemon",
|
||||
interaction: NO_INTERACTION,
|
||||
});
|
||||
expect(getReplicationSchedulingControl(core).initialOneShotSatisfied).toBe(true);
|
||||
expect(context.replicationScheduling.markInitialOneShotSatisfied).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("returns false when initial replication fails", async () => {
|
||||
@@ -224,7 +236,7 @@ describe("daemon command", () => {
|
||||
}));
|
||||
vi.mocked(offlineScanner.performFullScan).mockClear();
|
||||
|
||||
const result = await runCommand(makeDaemonOptions(), { ...baseContext, core });
|
||||
const result = await runCommand(makeDaemonOptions(), createDaemonContext(core));
|
||||
|
||||
expect(result).toBe(false);
|
||||
// performFullScan should NOT have been called
|
||||
@@ -239,7 +251,7 @@ describe("daemon command", () => {
|
||||
const core = createCoreMock();
|
||||
vi.mocked(offlineScanner.performFullScan).mockResolvedValue(true);
|
||||
|
||||
await runCommand(makeDaemonOptions(10), { ...baseContext, core });
|
||||
await runCommand(makeDaemonOptions(10), createDaemonContext(core));
|
||||
|
||||
// onUnload handler should have been registered
|
||||
expect(core.services.appLifecycle.onUnload.addHandler).toHaveBeenCalledTimes(1);
|
||||
@@ -267,7 +279,7 @@ describe("daemon command", () => {
|
||||
const baseMs = 30 * 1000;
|
||||
const setTimeoutSpy = vi.spyOn(globalThis, "setTimeout");
|
||||
|
||||
await runCommand(makeDaemonOptions(30), { ...baseContext, core });
|
||||
await runCommand(makeDaemonOptions(30), createDaemonContext(core));
|
||||
|
||||
// After runCommand returns the first setTimeout has been scheduled.
|
||||
// setTimeoutSpy.mock.calls[0] is the initial schedule (baseMs).
|
||||
@@ -319,7 +331,7 @@ describe("daemon command", () => {
|
||||
});
|
||||
|
||||
const intervalMs = 30 * 1000;
|
||||
await runCommand(makeDaemonOptions(30), { ...baseContext, core });
|
||||
await runCommand(makeDaemonOptions(30), createDaemonContext(core));
|
||||
|
||||
// Advance time to trigger the first poll callback and flush its async work.
|
||||
await vi.advanceTimersByTimeAsync(intervalMs);
|
||||
|
||||
@@ -31,7 +31,6 @@ import {
|
||||
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, "//***@");
|
||||
@@ -93,7 +92,7 @@ async function verifyRemoteState(
|
||||
}
|
||||
|
||||
export async function runCommand(options: CLIOptions, context: CLICommandContext): Promise<boolean> {
|
||||
const { databasePath, core, settingsPath } = context;
|
||||
const { databasePath, core, replicationScheduling, settingsPath } = context;
|
||||
const { standardIo } = core.services.context;
|
||||
const vaultPath = context.vaultPath || databasePath;
|
||||
|
||||
@@ -103,7 +102,7 @@ export async function runCommand(options: CLIOptions, context: CLICommandContext
|
||||
|
||||
// The daemon owns its own recurring poller. Suppress the application
|
||||
// resume starter and generic periodic timer before restoring settings.
|
||||
setExternalPollingMode(core, !!options.interval);
|
||||
replicationScheduling.setExternalPollingMode(!!options.interval);
|
||||
|
||||
// Skip the config mismatch dialog — the daemon cannot resolve it interactively
|
||||
// and the default "Dismiss" action would block replication. The daemon should
|
||||
@@ -121,7 +120,7 @@ export async function runCommand(options: CLIOptions, context: CLICommandContext
|
||||
writeStderrLine(standardIo, "[Daemon] Initial replication failed, cannot continue");
|
||||
return false;
|
||||
}
|
||||
markInitialOneShotSatisfied(core);
|
||||
replicationScheduling.markInitialOneShotSatisfied();
|
||||
log("Initial replication complete");
|
||||
|
||||
// 2. Mirror scan to reconcile PouchDB ↔ local filesystem.
|
||||
@@ -144,7 +143,7 @@ export async function runCommand(options: CLIOptions, context: CLICommandContext
|
||||
true
|
||||
);
|
||||
// applySettings fires the full lifecycle: onSuspending → onResumed.
|
||||
// The provider-independent lifecycle coordinator owns any eligible
|
||||
// The provider-independent scheduling feature owns any eligible
|
||||
// Continuous start; the daemon marker suppresses a duplicate
|
||||
// sync-on-start OneShot.
|
||||
await core.services.control.applySettings();
|
||||
@@ -207,7 +206,7 @@ export async function runCommand(options: CLIOptions, context: CLICommandContext
|
||||
log("LiveSync mode: restoring sync settings and starting continuous synchronisation where supported");
|
||||
await restoreSyncSettings();
|
||||
// The applySettings() lifecycle fires onResumed → the provider-
|
||||
// independent lifecycle coordinator, which starts Continuous when
|
||||
// independent scheduling feature, which starts Continuous when
|
||||
// supported. Do not call a concrete Replicator directly.
|
||||
log("LiveSync active");
|
||||
const currentSettings = core.services.setting.currentSettings();
|
||||
|
||||
@@ -2,6 +2,7 @@ import { LiveSyncBaseCore } from "@/LiveSyncBaseCore";
|
||||
import type { ObsidianLiveSyncSettings } from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
import type { NodeServiceContext } from "@/apps/cli/services/NodeServiceContext";
|
||||
import type { UseP2PReplicatorResult } from "@vrtmrz/livesync-commonlib/p2p";
|
||||
import type { ReplicationSchedulingControl } from "@/serviceFeatures/replicationScheduling";
|
||||
|
||||
export type CLICommand =
|
||||
| "daemon"
|
||||
@@ -50,6 +51,8 @@ export interface CLICommandContext {
|
||||
databasePath: string;
|
||||
vaultPath: string;
|
||||
core: LiveSyncBaseCore<NodeServiceContext, never>;
|
||||
/** Host-composition view used only to coordinate daemon-owned recurring work. */
|
||||
replicationScheduling: ReplicationSchedulingControl;
|
||||
/** Current-result contract owned by the P2P service feature. */
|
||||
p2pReplicator?: UseP2PReplicatorResult;
|
||||
settingsPath: string;
|
||||
|
||||
Reference in New Issue
Block a user