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:
+13
-8
@@ -24,9 +24,7 @@ import { useRemoteConfigurationMigration } from "@vrtmrz/livesync-commonlib/comp
|
||||
import type { ServiceContext } from "@vrtmrz/livesync-commonlib/context";
|
||||
import type { InjectableServiceHub } from "@vrtmrz/livesync-commonlib/compat/services/implements/injectable/InjectableServiceHub";
|
||||
import { AbstractModule } from "./modules/AbstractModule";
|
||||
import { ModulePeriodicProcess } from "./modules/core/ModulePeriodicProcess";
|
||||
import { ModuleReplicator } from "./modules/core/ModuleReplicator";
|
||||
import { ModuleReplicationLifecycle } from "./modules/core/ModuleReplicationLifecycle";
|
||||
import { ModuleConflictChecker } from "./modules/coreFeatures/ModuleConflictChecker";
|
||||
import { ModuleConflictResolver } from "./modules/coreFeatures/ModuleConflictResolver";
|
||||
import { ModuleResolvingMismatchedTweaks } from "./modules/coreFeatures/ModuleResolveMismatchedTweaks";
|
||||
@@ -46,6 +44,12 @@ import {
|
||||
} from "@vrtmrz/livesync-commonlib/replication";
|
||||
import { LiveSyncCouchDBReplicator } from "@vrtmrz/livesync-commonlib/compat/replication/couchdb/LiveSyncReplicator";
|
||||
import { LiveSyncJournalReplicator } from "@vrtmrz/livesync-commonlib/compat/replication/journal/LiveSyncJournalReplicator";
|
||||
import { useReplicationScheduling, type ReplicationSchedulingControl } from "./serviceFeatures/replicationScheduling";
|
||||
|
||||
/** Focused views returned by serviceFeatures which the host may consume during composition. */
|
||||
export interface LiveSyncCoreFeatureViews {
|
||||
readonly replicationScheduling: ReplicationSchedulingControl;
|
||||
}
|
||||
|
||||
export class LiveSyncBaseCore<
|
||||
T extends ServiceContext = ServiceContext,
|
||||
@@ -90,15 +94,15 @@ export class LiveSyncBaseCore<
|
||||
) => ServiceModules,
|
||||
extraModuleInitialiser: (core: LiveSyncBaseCore<T, TCommands>) => AbstractModule[],
|
||||
addOnsInitialiser: (core: LiveSyncBaseCore<T, TCommands>) => TCommands[],
|
||||
featuresInitialiser: (core: LiveSyncBaseCore<T, TCommands>) => void
|
||||
featuresInitialiser: (core: LiveSyncBaseCore<T, TCommands>, coreFeatureViews: LiveSyncCoreFeatureViews) => void
|
||||
) {
|
||||
this._services = serviceHub;
|
||||
this.registerReplicatorProviders();
|
||||
this._serviceModules = serviceModuleInitialiser(this, serviceHub);
|
||||
const extraModules = extraModuleInitialiser(this);
|
||||
this.registerModules(extraModules);
|
||||
this.initialiseServiceFeatures();
|
||||
featuresInitialiser(this);
|
||||
const coreFeatureViews = this.initialiseServiceFeatures();
|
||||
featuresInitialiser(this, coreFeatureViews);
|
||||
const addOns = addOnsInitialiser(this);
|
||||
for (const addOn of addOns) {
|
||||
this._registerAddOn(addOn);
|
||||
@@ -190,9 +194,7 @@ export class LiveSyncBaseCore<
|
||||
this._registerModule(new ModuleLiveSyncMain(this));
|
||||
this._registerModule(new ModuleConflictChecker(this));
|
||||
this._registerModule(new ModuleReplicator(this));
|
||||
this._registerModule(new ModuleReplicationLifecycle(this));
|
||||
this._registerModule(new ModuleConflictResolver(this));
|
||||
this._registerModule(new ModulePeriodicProcess(this));
|
||||
this._registerModule(new ModuleResolvingMismatchedTweaks(this));
|
||||
this._registerModule(new ModuleBasicMenu(this));
|
||||
|
||||
@@ -322,12 +324,15 @@ export class LiveSyncBaseCore<
|
||||
* Initialise ServiceFeatures.
|
||||
* (Please refer `serviceFeatures` for more details)
|
||||
*/
|
||||
initialiseServiceFeatures() {
|
||||
initialiseServiceFeatures(): LiveSyncCoreFeatureViews {
|
||||
useTargetFilters(this);
|
||||
// enable target filter feature.
|
||||
usePrepareDatabaseForUse(this);
|
||||
// Migration to multiple remote configurations
|
||||
useRemoteConfigurationMigration(this);
|
||||
return Object.freeze({
|
||||
replicationScheduling: useReplicationScheduling(this),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -24,6 +24,7 @@ import { getPathFromUXFileInfo } from "@vrtmrz/livesync-commonlib/compat/common/
|
||||
import { stripAllPrefixes } from "@vrtmrz/livesync-commonlib/compat/string_and_binary/path";
|
||||
import { IgnoreRules } from "./serviceModules/IgnoreRules";
|
||||
import { useP2PReplicatorFeature, type UseP2PReplicatorResult } from "@vrtmrz/livesync-commonlib/p2p";
|
||||
import type { ReplicationSchedulingControl } from "@/serviceFeatures/replicationScheduling";
|
||||
import { createNodeStandardIo, fsPromises as fs, path } from "@vrtmrz/livesync-commonlib/node";
|
||||
import type { StandardIo } from "@vrtmrz/livesync-commonlib/context";
|
||||
import { writeStderrLine, writeStdoutLine } from "./cliOutput";
|
||||
@@ -477,6 +478,7 @@ export async function main(
|
||||
|
||||
// Create LiveSync core
|
||||
let p2pReplicator: UseP2PReplicatorResult | undefined;
|
||||
let replicationScheduling: ReplicationSchedulingControl | undefined;
|
||||
const core = new LiveSyncBaseCore(
|
||||
serviceHubInstance,
|
||||
(core: LiveSyncBaseCore<NodeServiceContext, never>, serviceHub: InjectableServiceHub<NodeServiceContext>) => {
|
||||
@@ -484,7 +486,8 @@ export async function main(
|
||||
},
|
||||
(core) => [],
|
||||
() => [], // No add-ons
|
||||
(core) => {
|
||||
(core, coreFeatureViews) => {
|
||||
replicationScheduling = coreFeatureViews.replicationScheduling;
|
||||
// Register P2P replicator feature.
|
||||
p2pReplicator = useP2PReplicatorFeature(core);
|
||||
// Add target filter to prevent internal files are handled
|
||||
@@ -516,6 +519,9 @@ export async function main(
|
||||
}
|
||||
}
|
||||
);
|
||||
if (!replicationScheduling) {
|
||||
throw new Error("Replication scheduling was not provided during core feature composition.");
|
||||
}
|
||||
|
||||
// Setup signal handlers for graceful shutdown
|
||||
const shutdown = async (signal: string) => {
|
||||
@@ -622,6 +628,7 @@ export async function main(
|
||||
databasePath,
|
||||
vaultPath,
|
||||
core,
|
||||
replicationScheduling,
|
||||
p2pReplicator,
|
||||
settingsPath,
|
||||
originalSyncSettings,
|
||||
|
||||
@@ -16,8 +16,12 @@ vi.mock("octagonal-wheels/concurrency/task", () => taskMocks);
|
||||
|
||||
import { ModuleConflictResolver } from "../coreFeatures/ModuleConflictResolver";
|
||||
import { ModuleObsidianEvents } from "../essentialObsidian/ModuleObsidianEvents";
|
||||
import { ModulePeriodicProcess } from "./ModulePeriodicProcess";
|
||||
import { ModuleReplicationLifecycle } from "./ModuleReplicationLifecycle";
|
||||
import {
|
||||
createReplicationSchedulingContext,
|
||||
realiseReplicationScheduling,
|
||||
resumeReplicationScheduling,
|
||||
runPeriodicReplication,
|
||||
} from "@/serviceFeatures/replicationScheduling";
|
||||
import { ModuleReplicator } from "./ModuleReplicator";
|
||||
|
||||
function createApi() {
|
||||
@@ -97,11 +101,20 @@ describe("automatic replication triggers while P2P is active", () => {
|
||||
const core = {
|
||||
_services: services,
|
||||
services,
|
||||
settings: p2pSettings({ periodicReplication: true }),
|
||||
settings: p2pSettings({ periodicReplication: true, syncOnStart: false }),
|
||||
} as any;
|
||||
const module = new ModulePeriodicProcess(core);
|
||||
const context = createReplicationSchedulingContext({
|
||||
isReady: vi.fn(() => true),
|
||||
isSuspended: vi.fn(() => false),
|
||||
currentSettings: vi.fn(() => core.settings),
|
||||
replicateUnattended,
|
||||
startContinuous: vi.fn(async () => ({ status: "completed" as const })),
|
||||
timer: { enable: vi.fn(), disable: vi.fn() },
|
||||
log: vi.fn(),
|
||||
});
|
||||
|
||||
await module.periodicSyncProcessor.process();
|
||||
resumeReplicationScheduling(context);
|
||||
await runPeriodicReplication(context);
|
||||
|
||||
expect(replicateUnattended).toHaveBeenCalledOnce();
|
||||
expect(replicateUnattended).toHaveBeenCalledWith({
|
||||
@@ -203,8 +216,6 @@ describe("recurring replication scheduling precedence", () => {
|
||||
});
|
||||
|
||||
function createRecurringSchedulingHarness() {
|
||||
const resumeHandlers: Array<() => Promise<boolean>> = [];
|
||||
const settingRealisedHandlers: Array<() => Promise<boolean>> = [];
|
||||
let resolveContinuous!: (
|
||||
outcome: { status: "completed" } | { status: "blocked"; reason: "capability-not-applicable" }
|
||||
) => void;
|
||||
@@ -225,42 +236,36 @@ describe("recurring replication scheduling precedence", () => {
|
||||
periodicReplication: true,
|
||||
periodicReplicationInterval: 60,
|
||||
};
|
||||
const services = {
|
||||
API,
|
||||
appLifecycle: {
|
||||
isReady: vi.fn(() => true),
|
||||
isSuspended: vi.fn(() => false),
|
||||
onResumed: { addHandler: vi.fn((handler: () => Promise<boolean>) => resumeHandlers.push(handler)) },
|
||||
onSuspending: { addHandler: vi.fn() },
|
||||
onUnload: { addHandler: vi.fn() },
|
||||
},
|
||||
control: { hasUnloaded: vi.fn(() => false) },
|
||||
replication: {
|
||||
startContinuous,
|
||||
replicateUnattended: vi.fn(async () => ({ status: "completed" as const })),
|
||||
},
|
||||
setting: {
|
||||
currentSettings: vi.fn(() => settings),
|
||||
onBeforeRealiseSetting: { addHandler: vi.fn() },
|
||||
onSettingRealised: {
|
||||
addHandler: vi.fn((handler: () => Promise<boolean>) => settingRealisedHandlers.push(handler)),
|
||||
const context = createReplicationSchedulingContext({
|
||||
isReady: vi.fn(() => true),
|
||||
isSuspended: vi.fn(() => false),
|
||||
currentSettings: vi.fn(() => settings),
|
||||
startContinuous,
|
||||
replicateUnattended: vi.fn(async () => ({ status: "completed" as const })),
|
||||
timer: {
|
||||
enable: (interval) => {
|
||||
API.setInterval(vi.fn(), interval);
|
||||
},
|
||||
disable: () => {
|
||||
API.clearInterval(0);
|
||||
},
|
||||
},
|
||||
};
|
||||
const core = { _services: services, services, settings } as any;
|
||||
const lifecycle = new ModuleReplicationLifecycle(core);
|
||||
const periodic = new ModulePeriodicProcess(core);
|
||||
lifecycle.onBindFunction(core, services as never);
|
||||
periodic.onBindFunction(core, services as never);
|
||||
log: vi.fn(),
|
||||
});
|
||||
|
||||
return {
|
||||
API,
|
||||
resolveContinuous: (
|
||||
outcome: { status: "completed" } | { status: "blocked"; reason: "capability-not-applicable" }
|
||||
) => resolveContinuous(outcome),
|
||||
resume: async () => await Promise.all(resumeHandlers.map(async (handler) => await handler())),
|
||||
realiseSettings: async () =>
|
||||
await Promise.all(settingRealisedHandlers.map(async (handler) => await handler())),
|
||||
resume: async () => {
|
||||
resumeReplicationScheduling(context);
|
||||
await Promise.resolve();
|
||||
},
|
||||
realiseSettings: async () => {
|
||||
realiseReplicationScheduling(context);
|
||||
await Promise.resolve();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1,62 +0,0 @@
|
||||
import { PeriodicProcessor } from "@/common/PeriodicProcessor";
|
||||
import type { LiveSyncCore } from "@/main";
|
||||
import { AbstractModule } from "@/modules/AbstractModule";
|
||||
import { NO_INTERACTION } from "@vrtmrz/livesync-commonlib/replication";
|
||||
import { getReplicationSchedulingControl } from "./ReplicationScheduling";
|
||||
|
||||
export class ModulePeriodicProcess extends AbstractModule {
|
||||
private readonly schedulingControl = getReplicationSchedulingControl(this.core);
|
||||
periodicSyncProcessor = new PeriodicProcessor(this.core, async () => {
|
||||
await this.services.replication.replicateUnattended({
|
||||
trigger: "periodic",
|
||||
interaction: NO_INTERACTION,
|
||||
});
|
||||
});
|
||||
|
||||
disablePeriodic() {
|
||||
this.periodicSyncProcessor?.disable();
|
||||
return Promise.resolve(true);
|
||||
}
|
||||
resumePeriodic() {
|
||||
if (this.schedulingControl.externalPolling || this.schedulingControl.continuousOwnsRecurring) {
|
||||
void this.disablePeriodic();
|
||||
return Promise.resolve(true);
|
||||
}
|
||||
this.periodicSyncProcessor.enable(
|
||||
this.settings.periodicReplication ? this.settings.periodicReplicationInterval * 1000 : 0
|
||||
);
|
||||
return Promise.resolve(true);
|
||||
}
|
||||
private _allOnUnload() {
|
||||
return this.disablePeriodic();
|
||||
}
|
||||
private _everyBeforeRealizeSetting(): Promise<boolean> {
|
||||
return this.disablePeriodic();
|
||||
}
|
||||
private _everyBeforeSuspendProcess(): Promise<boolean> {
|
||||
return this.disablePeriodic();
|
||||
}
|
||||
private _everyAfterResumeProcess(): Promise<boolean> {
|
||||
return this.resumePeriodic();
|
||||
}
|
||||
private _everyAfterRealizeSetting(): Promise<boolean> {
|
||||
return this.resumePeriodic();
|
||||
}
|
||||
|
||||
override onBindFunction(core: LiveSyncCore, services: typeof core.services): void {
|
||||
this.schedulingControl.disablePeriodic = () => {
|
||||
void this.disablePeriodic();
|
||||
};
|
||||
this.schedulingControl.refreshPeriodic = () => {
|
||||
void this.resumePeriodic();
|
||||
};
|
||||
if (this.schedulingControl.externalPolling || this.schedulingControl.continuousOwnsRecurring) {
|
||||
void this.disablePeriodic();
|
||||
}
|
||||
services.appLifecycle.onUnload.addHandler(this._allOnUnload.bind(this));
|
||||
services.setting.onBeforeRealiseSetting.addHandler(this._everyBeforeRealizeSetting.bind(this));
|
||||
services.setting.onSettingRealised.addHandler(this._everyAfterRealizeSetting.bind(this));
|
||||
services.appLifecycle.onSuspending.addHandler(this._everyBeforeSuspendProcess.bind(this));
|
||||
services.appLifecycle.onResumed.addHandler(this._everyAfterResumeProcess.bind(this));
|
||||
}
|
||||
}
|
||||
@@ -1,113 +0,0 @@
|
||||
import { LOG_LEVEL_VERBOSE } from "octagonal-wheels/common/logger";
|
||||
import {
|
||||
isReplicationCompleted,
|
||||
NO_INTERACTION,
|
||||
type ReplicationOutcome,
|
||||
} from "@vrtmrz/livesync-commonlib/replication";
|
||||
import { AbstractModule } from "@/modules/AbstractModule";
|
||||
import type { LiveSyncCore } from "@/main";
|
||||
import {
|
||||
getReplicationSchedulingControl,
|
||||
markInitialOneShotSatisfied,
|
||||
setContinuousSchedulingOwnership,
|
||||
setExternalPollingMode,
|
||||
} from "./ReplicationScheduling";
|
||||
|
||||
function isCapabilityUnavailable(result: ReplicationOutcome): boolean {
|
||||
return (
|
||||
result.status === "blocked" &&
|
||||
(result.reason === "capability-not-applicable" || result.reason === "capability-not-implemented")
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Coordinates application resume with the active provider's typed roles.
|
||||
* Provider implementations do not subscribe to the application lifecycle.
|
||||
*/
|
||||
export class ModuleReplicationLifecycle extends AbstractModule {
|
||||
private readonly schedulingControl = getReplicationSchedulingControl(this.core);
|
||||
private resumePromise?: Promise<boolean>;
|
||||
|
||||
private async runAfterResume(): Promise<boolean> {
|
||||
if (this.schedulingControl.externalPolling) return true;
|
||||
if (this.services.appLifecycle.isSuspended()) return true;
|
||||
if (!this.services.appLifecycle.isReady()) return true;
|
||||
|
||||
const settings = this.services.setting.currentSettings();
|
||||
if (!settings.isConfigured) {
|
||||
setContinuousSchedulingOwnership(this.core, false);
|
||||
return true;
|
||||
}
|
||||
|
||||
const skipOneShot = this.schedulingControl.initialOneShotSatisfied;
|
||||
if (settings.liveSync) {
|
||||
// Reserve recurring ownership before the asynchronous start so a
|
||||
// later resume handler cannot enable Periodic in the meantime.
|
||||
setContinuousSchedulingOwnership(this.core, true);
|
||||
const result = await this.services.replication.startContinuous({
|
||||
trigger: "resume",
|
||||
interaction: NO_INTERACTION,
|
||||
});
|
||||
if (!isReplicationCompleted(result)) {
|
||||
setContinuousSchedulingOwnership(this.core, false);
|
||||
}
|
||||
// The daemon's initial finite convergence must not suppress a
|
||||
// supported Continuous start. It only suppresses the fallback
|
||||
// OneShot when Continuous is unavailable.
|
||||
this.schedulingControl.initialOneShotSatisfied = false;
|
||||
if (isCapabilityUnavailable(result) && settings.syncOnStart && !skipOneShot) {
|
||||
await this.services.replication.replicateUnattended({
|
||||
trigger: "resume",
|
||||
interaction: NO_INTERACTION,
|
||||
});
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
setContinuousSchedulingOwnership(this.core, false);
|
||||
if (settings.syncOnStart && !skipOneShot) {
|
||||
await this.services.replication.replicateUnattended({
|
||||
trigger: "resume",
|
||||
interaction: NO_INTERACTION,
|
||||
});
|
||||
}
|
||||
this.schedulingControl.initialOneShotSatisfied = false;
|
||||
return true;
|
||||
}
|
||||
|
||||
private _everyAfterResumeProcess(): Promise<boolean> {
|
||||
if (!this.resumePromise) {
|
||||
// The lifecycle event is a short notification boundary. Keep the
|
||||
// long-running OneShot/Continuous start coalesced internally, but
|
||||
// let later resume handlers (P2P, periodic scheduling, and other
|
||||
// modules) continue without waiting for network work to settle.
|
||||
this.resumePromise = this.runAfterResume()
|
||||
.catch((error) => {
|
||||
this._log(error, LOG_LEVEL_VERBOSE);
|
||||
return true;
|
||||
})
|
||||
.finally(() => {
|
||||
this.resumePromise = undefined;
|
||||
});
|
||||
}
|
||||
return Promise.resolve(true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Let a CLI daemon own recurring polling without a duplicate lifecycle or
|
||||
* generic periodic scheduler. This is intentionally narrower than a
|
||||
* provider or Replicator control API.
|
||||
*/
|
||||
setExternalPollingMode(enabled: boolean): void {
|
||||
setExternalPollingMode(this.core, enabled);
|
||||
}
|
||||
|
||||
/** Mark the daemon's initial finite convergence for the next resume. */
|
||||
markInitialOneShotSatisfied(): void {
|
||||
markInitialOneShotSatisfied(this.core);
|
||||
}
|
||||
|
||||
override onBindFunction(core: LiveSyncCore, services: typeof core.services): void {
|
||||
services.appLifecycle.onResumed.addHandler(this._everyAfterResumeProcess.bind(this));
|
||||
}
|
||||
}
|
||||
@@ -1,200 +0,0 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { REMOTE_P2P } from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
import { NO_INTERACTION, type ReplicationOutcome } from "@vrtmrz/livesync-commonlib/replication";
|
||||
import { createServiceContext } from "@vrtmrz/livesync-commonlib/context";
|
||||
import { ModuleReplicationLifecycle } from "./ModuleReplicationLifecycle";
|
||||
import { getReplicationSchedulingControl, setExternalPollingMode } from "./ReplicationScheduling";
|
||||
|
||||
type ResumeHandler = () => Promise<boolean>;
|
||||
|
||||
function createResumeHarness(settings: {
|
||||
liveSync: boolean;
|
||||
syncOnStart: boolean;
|
||||
isConfigured?: boolean;
|
||||
remoteType?: string;
|
||||
P2P_Enabled?: boolean;
|
||||
}) {
|
||||
const resumeHandlers: ResumeHandler[] = [];
|
||||
const replicateUnattended = vi.fn(async (): Promise<ReplicationOutcome> => ({ status: "completed" }));
|
||||
const startContinuous = vi.fn(async (): Promise<ReplicationOutcome> => ({ status: "completed" }));
|
||||
const currentSettings = {
|
||||
isConfigured: true,
|
||||
periodicReplication: false,
|
||||
...settings,
|
||||
};
|
||||
const services = {
|
||||
context: createServiceContext(),
|
||||
API: {
|
||||
addLog: vi.fn(),
|
||||
addCommand: vi.fn(),
|
||||
registerWindow: vi.fn(),
|
||||
addRibbonIcon: vi.fn(),
|
||||
registerProtocolHandler: vi.fn(),
|
||||
isOnline: true,
|
||||
},
|
||||
appLifecycle: {
|
||||
isReady: vi.fn(() => true),
|
||||
isSuspended: vi.fn(() => false),
|
||||
onResumed: {
|
||||
addHandler: vi.fn((handler: ResumeHandler) => resumeHandlers.push(handler)),
|
||||
},
|
||||
},
|
||||
replication: {
|
||||
replicateUnattended,
|
||||
startContinuous,
|
||||
},
|
||||
setting: {
|
||||
currentSettings: vi.fn(() => currentSettings),
|
||||
},
|
||||
};
|
||||
const core = {
|
||||
_services: services,
|
||||
services,
|
||||
settings: currentSettings,
|
||||
} as any;
|
||||
const module = new ModuleReplicationLifecycle(core);
|
||||
module.onBindFunction(core, services as never);
|
||||
|
||||
return {
|
||||
core,
|
||||
module,
|
||||
replicateUnattended,
|
||||
startContinuous,
|
||||
resume: async () => await Promise.all(resumeHandlers.map((handler) => handler())),
|
||||
};
|
||||
}
|
||||
|
||||
describe("provider-independent replication resume lifecycle", () => {
|
||||
it("starts one unattended OneShot when sync-on-start is enabled", async () => {
|
||||
const harness = createResumeHarness({ liveSync: false, syncOnStart: true });
|
||||
|
||||
await harness.resume();
|
||||
|
||||
expect(harness.replicateUnattended).toHaveBeenCalledOnce();
|
||||
expect(harness.replicateUnattended).toHaveBeenCalledWith({
|
||||
trigger: "resume",
|
||||
interaction: NO_INTERACTION,
|
||||
});
|
||||
expect(harness.startContinuous).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("falls back to sync-on-start when Continuous is not applicable", async () => {
|
||||
const harness = createResumeHarness({ liveSync: true, syncOnStart: true });
|
||||
harness.startContinuous.mockResolvedValue({
|
||||
status: "blocked",
|
||||
reason: "capability-not-applicable",
|
||||
});
|
||||
|
||||
await harness.resume();
|
||||
|
||||
expect(harness.startContinuous).toHaveBeenCalledWith({
|
||||
trigger: "resume",
|
||||
interaction: NO_INTERACTION,
|
||||
});
|
||||
expect(harness.replicateUnattended).toHaveBeenCalledWith({
|
||||
trigger: "resume",
|
||||
interaction: NO_INTERACTION,
|
||||
});
|
||||
});
|
||||
|
||||
it("starts Continuous without a finite fallback when it is supported", async () => {
|
||||
const harness = createResumeHarness({ liveSync: true, syncOnStart: true });
|
||||
|
||||
await harness.resume();
|
||||
|
||||
expect(harness.startContinuous).toHaveBeenCalledOnce();
|
||||
expect(harness.replicateUnattended).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not fall back after an actual Continuous failure", async () => {
|
||||
const harness = createResumeHarness({ liveSync: true, syncOnStart: true });
|
||||
harness.startContinuous.mockResolvedValue({
|
||||
status: "failed",
|
||||
error: new Error("connection failed"),
|
||||
});
|
||||
|
||||
await harness.resume();
|
||||
|
||||
expect(harness.replicateUnattended).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("coalesces concurrent resume callbacks", async () => {
|
||||
const harness = createResumeHarness({ liveSync: false, syncOnStart: true });
|
||||
let resolveReplication!: (value: { status: "completed" }) => void;
|
||||
harness.replicateUnattended.mockImplementationOnce(
|
||||
() => new Promise((resolve) => (resolveReplication = resolve))
|
||||
);
|
||||
|
||||
const first = harness.resume();
|
||||
const second = harness.resume();
|
||||
resolveReplication({ status: "completed" });
|
||||
await Promise.all([first, second]);
|
||||
|
||||
expect(harness.replicateUnattended).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("does not block later resume handlers while a OneShot is running", async () => {
|
||||
const harness = createResumeHarness({ liveSync: false, syncOnStart: true });
|
||||
let resolveReplication!: (value: { status: "completed" }) => void;
|
||||
harness.replicateUnattended.mockImplementationOnce(
|
||||
() => new Promise((resolve) => (resolveReplication = resolve))
|
||||
);
|
||||
|
||||
const resumed = harness.resume();
|
||||
await expect(resumed).resolves.toEqual([true]);
|
||||
expect(harness.replicateUnattended).toHaveBeenCalledOnce();
|
||||
|
||||
resolveReplication({ status: "completed" });
|
||||
await resumed;
|
||||
});
|
||||
|
||||
it("skips only the daemon-satisfied OneShot while allowing Continuous", async () => {
|
||||
const harness = createResumeHarness({ liveSync: true, syncOnStart: true });
|
||||
getReplicationSchedulingControl(harness.core).initialOneShotSatisfied = true;
|
||||
harness.startContinuous.mockResolvedValue({
|
||||
status: "blocked",
|
||||
reason: "capability-not-applicable",
|
||||
});
|
||||
|
||||
await harness.resume();
|
||||
|
||||
expect(harness.startContinuous).toHaveBeenCalledOnce();
|
||||
expect(harness.replicateUnattended).not.toHaveBeenCalled();
|
||||
expect(getReplicationSchedulingControl(harness.core).initialOneShotSatisfied).toBe(false);
|
||||
});
|
||||
|
||||
it("does not start lifecycle replication while an external poller owns scheduling", async () => {
|
||||
const harness = createResumeHarness({ liveSync: false, syncOnStart: true });
|
||||
setExternalPollingMode(harness.core, true);
|
||||
|
||||
await harness.resume();
|
||||
|
||||
expect(harness.startContinuous).not.toHaveBeenCalled();
|
||||
expect(harness.replicateUnattended).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("requests the generic finite fallback for P2P when Continuous is not applicable", async () => {
|
||||
const harness = createResumeHarness({
|
||||
remoteType: REMOTE_P2P,
|
||||
P2P_Enabled: true,
|
||||
liveSync: true,
|
||||
syncOnStart: true,
|
||||
});
|
||||
harness.startContinuous.mockResolvedValue({
|
||||
status: "blocked",
|
||||
reason: "capability-not-applicable",
|
||||
});
|
||||
harness.replicateUnattended.mockResolvedValue({
|
||||
status: "blocked",
|
||||
reason: "capability-not-implemented",
|
||||
});
|
||||
|
||||
await harness.resume();
|
||||
|
||||
expect(harness.startContinuous).toHaveBeenCalledOnce();
|
||||
expect(harness.replicateUnattended).toHaveBeenCalledWith({
|
||||
trigger: "resume",
|
||||
interaction: NO_INTERACTION,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,49 +0,0 @@
|
||||
/**
|
||||
* Host-owned scheduling state shared by the lifecycle coordinator and the
|
||||
* CLI daemon. It deliberately contains policy state only; provider choice and
|
||||
* replication execution remain in ReplicationService.
|
||||
*/
|
||||
export interface ReplicationSchedulingControl {
|
||||
/** The daemon owns recurring polling and suppresses host automation. */
|
||||
externalPolling: boolean;
|
||||
/** A Continuous start is pending or accepted and therefore owns recurring synchronisation. */
|
||||
continuousOwnsRecurring: boolean;
|
||||
/** The daemon's initial convergence satisfies the next resume OneShot. */
|
||||
initialOneShotSatisfied: boolean;
|
||||
/** Registered by the periodic module so the daemon can remove an old timer. */
|
||||
disablePeriodic?: () => void;
|
||||
/** Reconcile the periodic timer after recurring ownership changes. */
|
||||
refreshPeriodic?: () => void;
|
||||
}
|
||||
|
||||
const controls = new WeakMap<object, ReplicationSchedulingControl>();
|
||||
|
||||
export function getReplicationSchedulingControl(owner: object): ReplicationSchedulingControl {
|
||||
let control = controls.get(owner);
|
||||
if (!control) {
|
||||
control = {
|
||||
externalPolling: false,
|
||||
continuousOwnsRecurring: false,
|
||||
initialOneShotSatisfied: false,
|
||||
};
|
||||
controls.set(owner, control);
|
||||
}
|
||||
return control;
|
||||
}
|
||||
|
||||
export function setContinuousSchedulingOwnership(owner: object, ownsRecurring: boolean): void {
|
||||
const control = getReplicationSchedulingControl(owner);
|
||||
if (control.continuousOwnsRecurring === ownsRecurring) return;
|
||||
control.continuousOwnsRecurring = ownsRecurring;
|
||||
control.refreshPeriodic?.();
|
||||
}
|
||||
|
||||
export function setExternalPollingMode(owner: object, enabled: boolean): void {
|
||||
const control = getReplicationSchedulingControl(owner);
|
||||
control.externalPolling = enabled;
|
||||
if (enabled) control.disablePeriodic?.();
|
||||
}
|
||||
|
||||
export function markInitialOneShotSatisfied(owner: object): void {
|
||||
getReplicationSchedulingControl(owner).initialOneShotSatisfied = true;
|
||||
}
|
||||
@@ -0,0 +1,339 @@
|
||||
import { LOG_LEVEL_VERBOSE } from "octagonal-wheels/common/logger";
|
||||
import type { ObsidianLiveSyncSettings } from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
import type { NecessaryServices } from "@vrtmrz/livesync-commonlib/compat/interfaces/ServiceModule";
|
||||
import { createInstanceLogFunction } from "@vrtmrz/livesync-commonlib/compat/services/lib/logUtils";
|
||||
import {
|
||||
isReplicationCompleted,
|
||||
NO_INTERACTION,
|
||||
type ContinuousReplicationRequest,
|
||||
type ReplicationOutcome,
|
||||
type UnattendedOneShotRequest,
|
||||
} from "@vrtmrz/livesync-commonlib/replication";
|
||||
import { PeriodicProcessor } from "@/common/PeriodicProcessor";
|
||||
|
||||
type ReplicationSchedulingSettings = Pick<
|
||||
ObsidianLiveSyncSettings,
|
||||
"isConfigured" | "liveSync" | "syncOnStart" | "periodicReplication" | "periodicReplicationInterval"
|
||||
>;
|
||||
|
||||
/** Timer operations required by the scheduling state owner. */
|
||||
export interface ReplicationSchedulingTimer {
|
||||
enable(intervalMs: number): void;
|
||||
disable(): void;
|
||||
}
|
||||
|
||||
/** Daemon-only controls which do not expose mutable scheduling state. */
|
||||
export interface ReplicationSchedulingControl {
|
||||
/** Let an external daemon poller become, or cease to be, the recurring-work owner. */
|
||||
setExternalPollingMode(enabled: boolean): void;
|
||||
/** Consume the next resume-triggered OneShot because the daemon has already converged once. */
|
||||
markInitialOneShotSatisfied(): void;
|
||||
}
|
||||
|
||||
interface ReplicationSchedulingDependencies {
|
||||
isReady(): boolean;
|
||||
isSuspended(): boolean;
|
||||
currentSettings(): ReplicationSchedulingSettings;
|
||||
replicateUnattended(request: UnattendedOneShotRequest): Promise<ReplicationOutcome>;
|
||||
startContinuous(request: ContinuousReplicationRequest): Promise<ReplicationOutcome>;
|
||||
timer: ReplicationSchedulingTimer;
|
||||
log(error: unknown): void;
|
||||
}
|
||||
|
||||
interface ReplicationSchedulingState {
|
||||
externalPolling: boolean;
|
||||
continuousOwnsRecurring: boolean;
|
||||
initialOneShotSatisfied: boolean;
|
||||
lifecycleAllowsScheduling: boolean;
|
||||
lifecycleGeneration: number;
|
||||
resumeOperation: Promise<void> | undefined;
|
||||
runningResumeGeneration: number | undefined;
|
||||
queuedResumeGeneration: number | undefined;
|
||||
}
|
||||
|
||||
/** Private state and collaborators owned by the replication scheduling serviceFeature. */
|
||||
interface ReplicationSchedulingContext {
|
||||
readonly dependencies: ReplicationSchedulingDependencies;
|
||||
readonly state: ReplicationSchedulingState;
|
||||
}
|
||||
|
||||
function isCapabilityUnavailable(result: ReplicationOutcome): boolean {
|
||||
return (
|
||||
result.status === "blocked" &&
|
||||
(result.reason === "capability-not-applicable" || result.reason === "capability-not-implemented")
|
||||
);
|
||||
}
|
||||
|
||||
/** Construct the independently testable context owned by the serviceFeature. */
|
||||
export function createReplicationSchedulingContext(
|
||||
dependencies: ReplicationSchedulingDependencies
|
||||
): ReplicationSchedulingContext {
|
||||
return {
|
||||
dependencies,
|
||||
state: {
|
||||
externalPolling: false,
|
||||
continuousOwnsRecurring: false,
|
||||
initialOneShotSatisfied: false,
|
||||
// AppLifecycleService does not expose physical visibility as
|
||||
// isSuspended(). Keep the observed state in this private context.
|
||||
lifecycleAllowsScheduling: false,
|
||||
lifecycleGeneration: 0,
|
||||
resumeOperation: undefined,
|
||||
runningResumeGeneration: undefined,
|
||||
queuedResumeGeneration: undefined,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function canRunPeriodic(context: ReplicationSchedulingContext, settings: ReplicationSchedulingSettings): boolean {
|
||||
const { dependencies, state } = context;
|
||||
return (
|
||||
state.lifecycleAllowsScheduling &&
|
||||
!state.externalPolling &&
|
||||
!state.continuousOwnsRecurring &&
|
||||
dependencies.isReady() &&
|
||||
!dependencies.isSuspended() &&
|
||||
settings.isConfigured === true &&
|
||||
settings.periodicReplication === true
|
||||
);
|
||||
}
|
||||
|
||||
function reconcilePeriodic(context: ReplicationSchedulingContext): void {
|
||||
const { dependencies } = context;
|
||||
const settings = dependencies.currentSettings();
|
||||
if (canRunPeriodic(context, settings)) {
|
||||
dependencies.timer.enable(settings.periodicReplicationInterval * 1000);
|
||||
} else {
|
||||
dependencies.timer.disable();
|
||||
}
|
||||
}
|
||||
|
||||
function setContinuousOwnership(context: ReplicationSchedulingContext, ownsRecurring: boolean): void {
|
||||
const { state } = context;
|
||||
if (state.continuousOwnsRecurring === ownsRecurring) return;
|
||||
state.continuousOwnsRecurring = ownsRecurring;
|
||||
reconcilePeriodic(context);
|
||||
}
|
||||
|
||||
function isCurrentLifecycleGeneration(context: ReplicationSchedulingContext, generation: number): boolean {
|
||||
return generation === context.state.lifecycleGeneration;
|
||||
}
|
||||
|
||||
function canRunResume(context: ReplicationSchedulingContext, generation: number): boolean {
|
||||
const { dependencies, state } = context;
|
||||
return (
|
||||
isCurrentLifecycleGeneration(context, generation) &&
|
||||
state.lifecycleAllowsScheduling &&
|
||||
!state.externalPolling &&
|
||||
dependencies.isReady() &&
|
||||
!dependencies.isSuspended()
|
||||
);
|
||||
}
|
||||
|
||||
async function runAfterResume(context: ReplicationSchedulingContext, generation: number): Promise<void> {
|
||||
if (!canRunResume(context, generation)) return;
|
||||
|
||||
const { dependencies, state } = context;
|
||||
const settings = dependencies.currentSettings();
|
||||
if (!settings.isConfigured) {
|
||||
setContinuousOwnership(context, false);
|
||||
return;
|
||||
}
|
||||
|
||||
const skipOneShot = state.initialOneShotSatisfied;
|
||||
// This marker belongs to one resume attempt. Consume it before any network
|
||||
// await so an exceptional Continuous start cannot suppress a later retry.
|
||||
state.initialOneShotSatisfied = false;
|
||||
if (settings.liveSync) {
|
||||
setContinuousOwnership(context, true);
|
||||
let result: ReplicationOutcome;
|
||||
try {
|
||||
result = await dependencies.startContinuous({
|
||||
trigger: "resume",
|
||||
interaction: NO_INTERACTION,
|
||||
});
|
||||
} catch (error) {
|
||||
if (isCurrentLifecycleGeneration(context, generation)) {
|
||||
setContinuousOwnership(context, false);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
if (!isReplicationCompleted(result) && isCurrentLifecycleGeneration(context, generation)) {
|
||||
setContinuousOwnership(context, false);
|
||||
}
|
||||
// A suspend/resume may have started a new lifecycle generation while
|
||||
// Continuous was settling. Do not let the obsolete result schedule a
|
||||
// finite fallback for the new generation.
|
||||
if (isCapabilityUnavailable(result) && canRunResume(context, generation)) {
|
||||
const currentSettings = dependencies.currentSettings();
|
||||
if (
|
||||
currentSettings.isConfigured &&
|
||||
currentSettings.liveSync &&
|
||||
currentSettings.syncOnStart &&
|
||||
!skipOneShot
|
||||
) {
|
||||
await dependencies.replicateUnattended({
|
||||
trigger: "resume",
|
||||
interaction: NO_INTERACTION,
|
||||
});
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
setContinuousOwnership(context, false);
|
||||
if (settings.syncOnStart && !skipOneShot) {
|
||||
await dependencies.replicateUnattended({
|
||||
trigger: "resume",
|
||||
interaction: NO_INTERACTION,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function scheduleAfterResume(context: ReplicationSchedulingContext): void {
|
||||
const { dependencies, state } = context;
|
||||
const requestedGeneration = state.lifecycleGeneration;
|
||||
if (state.resumeOperation) {
|
||||
// Duplicate notifications within one generation share the current
|
||||
// operation. A later lifecycle generation must run after it.
|
||||
if (state.runningResumeGeneration !== requestedGeneration) {
|
||||
state.queuedResumeGeneration = requestedGeneration;
|
||||
}
|
||||
return;
|
||||
}
|
||||
state.runningResumeGeneration = requestedGeneration;
|
||||
state.resumeOperation = runAfterResume(context, requestedGeneration)
|
||||
.catch((error: unknown) => {
|
||||
dependencies.log(error);
|
||||
})
|
||||
.finally(() => {
|
||||
state.resumeOperation = undefined;
|
||||
state.runningResumeGeneration = undefined;
|
||||
const queuedGeneration = state.queuedResumeGeneration;
|
||||
state.queuedResumeGeneration = undefined;
|
||||
if (queuedGeneration === state.lifecycleGeneration && state.lifecycleAllowsScheduling) {
|
||||
scheduleAfterResume(context);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/** Schedule eligible work after the application has resumed. */
|
||||
export function resumeReplicationScheduling(context: ReplicationSchedulingContext): void {
|
||||
const { state } = context;
|
||||
if (!state.lifecycleAllowsScheduling) {
|
||||
state.lifecycleGeneration += 1;
|
||||
}
|
||||
state.lifecycleAllowsScheduling = true;
|
||||
// runAfterResume executes synchronously until its first await. A Continuous
|
||||
// request therefore reserves ownership before Periodic is reconciled.
|
||||
scheduleAfterResume(context);
|
||||
reconcilePeriodic(context);
|
||||
}
|
||||
|
||||
/** Stop generic Periodic scheduling before the application suspends. */
|
||||
export function suspendReplicationScheduling(context: ReplicationSchedulingContext): void {
|
||||
context.state.lifecycleAllowsScheduling = false;
|
||||
context.state.queuedResumeGeneration = undefined;
|
||||
context.dependencies.timer.disable();
|
||||
}
|
||||
|
||||
/** Stop generic Periodic scheduling while settings and provider bindings change. */
|
||||
export function prepareReplicationSchedulingForSettings(context: ReplicationSchedulingContext): void {
|
||||
context.dependencies.timer.disable();
|
||||
}
|
||||
|
||||
/** Reconcile generic Periodic scheduling after settings have settled. */
|
||||
export function realiseReplicationScheduling(context: ReplicationSchedulingContext): void {
|
||||
reconcilePeriodic(context);
|
||||
}
|
||||
|
||||
/** Prevent later timer callbacks from scheduling new work during unload. */
|
||||
export function unloadReplicationScheduling(context: ReplicationSchedulingContext): void {
|
||||
context.state.lifecycleAllowsScheduling = false;
|
||||
context.state.queuedResumeGeneration = undefined;
|
||||
context.dependencies.timer.disable();
|
||||
}
|
||||
|
||||
/** Execute one timer callback if Periodic still owns recurring work. */
|
||||
export async function runPeriodicReplication(context: ReplicationSchedulingContext): Promise<void> {
|
||||
const { dependencies } = context;
|
||||
// Clearing an interval does not retract a callback which is already queued.
|
||||
// Recheck ownership and lifecycle state at execution time.
|
||||
if (!canRunPeriodic(context, dependencies.currentSettings())) return;
|
||||
await dependencies.replicateUnattended({
|
||||
trigger: "periodic",
|
||||
interaction: NO_INTERACTION,
|
||||
});
|
||||
}
|
||||
|
||||
/** Declare that an external poller has become, or ceased to be, the recurring-work owner. */
|
||||
export function setExternalPollingMode(context: ReplicationSchedulingContext, enabled: boolean): void {
|
||||
if (context.state.externalPolling === enabled) return;
|
||||
context.state.externalPolling = enabled;
|
||||
reconcilePeriodic(context);
|
||||
}
|
||||
|
||||
/** Consume the next resume-triggered OneShot because the daemon has already converged once. */
|
||||
export function markInitialOneShotSatisfied(context: ReplicationSchedulingContext): void {
|
||||
context.state.initialOneShotSatisfied = true;
|
||||
}
|
||||
|
||||
type ReplicationSchedulingHost = NecessaryServices<
|
||||
"API" | "appLifecycle" | "control" | "replication" | "setting",
|
||||
never
|
||||
>;
|
||||
|
||||
type ReplicationSchedulingTimerFactory = (process: () => Promise<void>) => ReplicationSchedulingTimer;
|
||||
|
||||
/**
|
||||
* Compose host lifecycle bindings around one private scheduling context.
|
||||
*
|
||||
* The returned view is intentionally limited to daemon scheduling controls.
|
||||
* @param host Narrow service container used to bind scheduling to the host lifecycle.
|
||||
* @param createTimer Timer adapter factory, replaceable by focused tests.
|
||||
* @returns Commands which let the CLI daemon declare its scheduling ownership.
|
||||
*/
|
||||
export function useReplicationScheduling(
|
||||
host: ReplicationSchedulingHost,
|
||||
createTimer: ReplicationSchedulingTimerFactory = (process) => new PeriodicProcessor(host, process)
|
||||
): ReplicationSchedulingControl {
|
||||
const services = host.services;
|
||||
const log = createInstanceLogFunction("SF:ReplicationScheduling", services.API);
|
||||
let context!: ReplicationSchedulingContext;
|
||||
const timer = createTimer(async () => await runPeriodicReplication(context));
|
||||
context = createReplicationSchedulingContext({
|
||||
isReady: () => services.appLifecycle.isReady(),
|
||||
isSuspended: () => services.appLifecycle.isSuspended(),
|
||||
currentSettings: () => services.setting.currentSettings(),
|
||||
replicateUnattended: (request) => services.replication.replicateUnattended(request),
|
||||
startContinuous: (request) => services.replication.startContinuous(request),
|
||||
timer,
|
||||
log: (error) => log(error, LOG_LEVEL_VERBOSE),
|
||||
});
|
||||
|
||||
services.appLifecycle.onUnload.addHandler(() => {
|
||||
unloadReplicationScheduling(context);
|
||||
return Promise.resolve(true);
|
||||
});
|
||||
services.setting.onBeforeRealiseSetting.addHandler(() => {
|
||||
prepareReplicationSchedulingForSettings(context);
|
||||
return Promise.resolve(true);
|
||||
});
|
||||
services.setting.onSettingRealised.addHandler(() => {
|
||||
realiseReplicationScheduling(context);
|
||||
return Promise.resolve(true);
|
||||
});
|
||||
services.appLifecycle.onSuspending.addHandler(() => {
|
||||
suspendReplicationScheduling(context);
|
||||
return Promise.resolve(true);
|
||||
});
|
||||
services.appLifecycle.onResumed.addHandler(() => {
|
||||
resumeReplicationScheduling(context);
|
||||
return Promise.resolve(true);
|
||||
});
|
||||
|
||||
return Object.freeze({
|
||||
setExternalPollingMode: (enabled: boolean) => setExternalPollingMode(context, enabled),
|
||||
markInitialOneShotSatisfied: () => markInitialOneShotSatisfied(context),
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,349 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { DEFAULT_SETTINGS } from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
import { NO_INTERACTION, type ReplicationOutcome } from "@vrtmrz/livesync-commonlib/replication";
|
||||
import {
|
||||
createReplicationSchedulingContext,
|
||||
markInitialOneShotSatisfied,
|
||||
resumeReplicationScheduling,
|
||||
runPeriodicReplication,
|
||||
setExternalPollingMode,
|
||||
suspendReplicationScheduling,
|
||||
useReplicationScheduling,
|
||||
type ReplicationSchedulingTimer,
|
||||
} from "./replicationScheduling";
|
||||
|
||||
function createDeferred<T>() {
|
||||
let resolve!: (value: T) => void;
|
||||
const promise = new Promise<T>((res) => {
|
||||
resolve = res;
|
||||
});
|
||||
return { promise, resolve };
|
||||
}
|
||||
|
||||
function createControllerHarness(
|
||||
overrides: Partial<{
|
||||
liveSync: boolean;
|
||||
syncOnStart: boolean;
|
||||
periodicReplication: boolean;
|
||||
periodicReplicationInterval: number;
|
||||
}> = {}
|
||||
) {
|
||||
const settings = {
|
||||
...DEFAULT_SETTINGS,
|
||||
isConfigured: true,
|
||||
liveSync: false,
|
||||
syncOnStart: true,
|
||||
periodicReplication: false,
|
||||
periodicReplicationInterval: 60,
|
||||
...overrides,
|
||||
};
|
||||
const timer: ReplicationSchedulingTimer = {
|
||||
enable: vi.fn(),
|
||||
disable: vi.fn(),
|
||||
};
|
||||
const replicateUnattended = vi.fn(async (): Promise<ReplicationOutcome> => ({ status: "completed" }));
|
||||
const startContinuous = vi.fn(async (): Promise<ReplicationOutcome> => ({ status: "completed" }));
|
||||
const log = vi.fn();
|
||||
const context = createReplicationSchedulingContext({
|
||||
isReady: vi.fn(() => true),
|
||||
isSuspended: vi.fn(() => false),
|
||||
currentSettings: vi.fn(() => settings),
|
||||
replicateUnattended,
|
||||
startContinuous,
|
||||
timer,
|
||||
log,
|
||||
});
|
||||
return { context, log, replicateUnattended, settings, startContinuous, timer };
|
||||
}
|
||||
|
||||
describe("replication scheduling context", () => {
|
||||
it("starts a configured unattended OneShot without exposing the operation to the lifecycle handler", async () => {
|
||||
const { context, replicateUnattended } = createControllerHarness();
|
||||
|
||||
expect(resumeReplicationScheduling(context)).toBeUndefined();
|
||||
|
||||
await vi.waitFor(() => expect(replicateUnattended).toHaveBeenCalledOnce());
|
||||
expect(replicateUnattended).toHaveBeenCalledWith({
|
||||
trigger: "resume",
|
||||
interaction: NO_INTERACTION,
|
||||
});
|
||||
});
|
||||
|
||||
it("reserves Continuous ownership before reconciling the periodic timer", async () => {
|
||||
const timeline: string[] = [];
|
||||
const continuous = createDeferred<ReplicationOutcome>();
|
||||
const { context, startContinuous, timer } = createControllerHarness({
|
||||
liveSync: true,
|
||||
periodicReplication: true,
|
||||
});
|
||||
vi.mocked(timer.disable).mockImplementation(() => {
|
||||
timeline.push("timer-disabled");
|
||||
});
|
||||
startContinuous.mockImplementation(() => {
|
||||
timeline.push("continuous-started");
|
||||
return continuous.promise;
|
||||
});
|
||||
|
||||
resumeReplicationScheduling(context);
|
||||
|
||||
expect(timeline[0]).toBe("timer-disabled");
|
||||
expect(timeline).toContain("continuous-started");
|
||||
expect(timer.enable).not.toHaveBeenCalled();
|
||||
|
||||
continuous.resolve({ status: "completed" });
|
||||
await vi.waitFor(() => expect(startContinuous).toHaveBeenCalledOnce());
|
||||
});
|
||||
|
||||
it("restores Periodic and falls back to OneShot when Continuous is not applicable", async () => {
|
||||
const { context, replicateUnattended, startContinuous, timer } = createControllerHarness({
|
||||
liveSync: true,
|
||||
periodicReplication: true,
|
||||
periodicReplicationInterval: 45,
|
||||
});
|
||||
startContinuous.mockResolvedValue({
|
||||
status: "blocked",
|
||||
reason: "capability-not-applicable",
|
||||
});
|
||||
|
||||
resumeReplicationScheduling(context);
|
||||
|
||||
await vi.waitFor(() => expect(replicateUnattended).toHaveBeenCalledOnce());
|
||||
expect(timer.enable).toHaveBeenCalledWith(45_000);
|
||||
});
|
||||
|
||||
it("does not run a finite fallback after Continuous starts successfully", async () => {
|
||||
const { context, replicateUnattended, startContinuous } = createControllerHarness({ liveSync: true });
|
||||
|
||||
resumeReplicationScheduling(context);
|
||||
|
||||
await vi.waitFor(() => expect(startContinuous).toHaveBeenCalledOnce());
|
||||
expect(replicateUnattended).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not run a finite fallback after an actual Continuous failure", async () => {
|
||||
const { context, replicateUnattended, startContinuous } = createControllerHarness({ liveSync: true });
|
||||
startContinuous.mockResolvedValue({
|
||||
status: "failed",
|
||||
error: new Error("connection failed"),
|
||||
});
|
||||
|
||||
resumeReplicationScheduling(context);
|
||||
|
||||
await vi.waitFor(() => expect(startContinuous).toHaveBeenCalledOnce());
|
||||
expect(replicateUnattended).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("coalesces concurrent resume notifications", async () => {
|
||||
const replication = createDeferred<ReplicationOutcome>();
|
||||
const { context, replicateUnattended } = createControllerHarness();
|
||||
replicateUnattended.mockImplementation(() => replication.promise);
|
||||
|
||||
resumeReplicationScheduling(context);
|
||||
resumeReplicationScheduling(context);
|
||||
|
||||
expect(replicateUnattended).toHaveBeenCalledOnce();
|
||||
replication.resolve({ status: "completed" });
|
||||
await vi.waitFor(() => expect(replicateUnattended).toHaveBeenCalledOnce());
|
||||
});
|
||||
|
||||
it("runs a fresh lifecycle generation instead of applying a stale Continuous fallback", async () => {
|
||||
const firstContinuous = createDeferred<ReplicationOutcome>();
|
||||
const { context, replicateUnattended, startContinuous, timer } = createControllerHarness({
|
||||
liveSync: true,
|
||||
periodicReplication: true,
|
||||
});
|
||||
startContinuous
|
||||
.mockImplementationOnce(() => firstContinuous.promise)
|
||||
.mockResolvedValueOnce({ status: "completed" });
|
||||
|
||||
resumeReplicationScheduling(context);
|
||||
suspendReplicationScheduling(context);
|
||||
resumeReplicationScheduling(context);
|
||||
firstContinuous.resolve({
|
||||
status: "blocked",
|
||||
reason: "capability-not-applicable",
|
||||
});
|
||||
|
||||
await vi.waitFor(() => expect(startContinuous).toHaveBeenCalledTimes(2));
|
||||
expect(replicateUnattended).not.toHaveBeenCalled();
|
||||
expect(timer.enable).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("lets the daemon suppress scheduling through the focused control view", async () => {
|
||||
const { context, replicateUnattended, startContinuous, timer } = createControllerHarness({
|
||||
liveSync: true,
|
||||
periodicReplication: true,
|
||||
});
|
||||
|
||||
setExternalPollingMode(context, true);
|
||||
resumeReplicationScheduling(context);
|
||||
|
||||
expect(timer.disable).toHaveBeenCalled();
|
||||
expect(startContinuous).not.toHaveBeenCalled();
|
||||
expect(replicateUnattended).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("consumes the daemon's initial OneShot marker without suppressing a Continuous attempt", async () => {
|
||||
const { context, replicateUnattended, startContinuous } = createControllerHarness({ liveSync: true });
|
||||
startContinuous.mockResolvedValue({
|
||||
status: "blocked",
|
||||
reason: "capability-not-applicable",
|
||||
});
|
||||
|
||||
markInitialOneShotSatisfied(context);
|
||||
resumeReplicationScheduling(context);
|
||||
|
||||
await vi.waitFor(() => expect(startContinuous).toHaveBeenCalledOnce());
|
||||
expect(replicateUnattended).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("consumes the daemon marker even when the first Continuous attempt throws", async () => {
|
||||
const { context, log, replicateUnattended, startContinuous } = createControllerHarness({ liveSync: true });
|
||||
startContinuous.mockRejectedValueOnce(new Error("start failed")).mockResolvedValueOnce({
|
||||
status: "blocked",
|
||||
reason: "capability-not-applicable",
|
||||
});
|
||||
|
||||
markInitialOneShotSatisfied(context);
|
||||
resumeReplicationScheduling(context);
|
||||
await vi.waitFor(() => expect(log).toHaveBeenCalledOnce());
|
||||
|
||||
resumeReplicationScheduling(context);
|
||||
|
||||
await vi.waitFor(() => expect(startContinuous).toHaveBeenCalledTimes(2));
|
||||
expect(replicateUnattended).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("runs the periodic callback through the unattended replication boundary", async () => {
|
||||
const { context, replicateUnattended } = createControllerHarness({
|
||||
syncOnStart: false,
|
||||
periodicReplication: true,
|
||||
});
|
||||
|
||||
resumeReplicationScheduling(context);
|
||||
|
||||
await runPeriodicReplication(context);
|
||||
|
||||
expect(replicateUnattended).toHaveBeenCalledWith({
|
||||
trigger: "periodic",
|
||||
interaction: NO_INTERACTION,
|
||||
});
|
||||
});
|
||||
|
||||
it("ignores a queued periodic callback before resume and after suspension", async () => {
|
||||
const { context, replicateUnattended, timer } = createControllerHarness({
|
||||
syncOnStart: false,
|
||||
periodicReplication: true,
|
||||
});
|
||||
|
||||
await runPeriodicReplication(context);
|
||||
expect(replicateUnattended).not.toHaveBeenCalled();
|
||||
|
||||
resumeReplicationScheduling(context);
|
||||
expect(timer.enable).toHaveBeenCalledWith(60_000);
|
||||
|
||||
suspendReplicationScheduling(context);
|
||||
await runPeriodicReplication(context);
|
||||
expect(replicateUnattended).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("ignores a queued periodic callback while external polling owns recurring work", async () => {
|
||||
const { context, replicateUnattended } = createControllerHarness({
|
||||
syncOnStart: false,
|
||||
periodicReplication: true,
|
||||
});
|
||||
resumeReplicationScheduling(context);
|
||||
|
||||
setExternalPollingMode(context, true);
|
||||
await runPeriodicReplication(context);
|
||||
|
||||
expect(replicateUnattended).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("ignores a queued periodic callback while Continuous owns recurring work", async () => {
|
||||
const continuous = createDeferred<ReplicationOutcome>();
|
||||
const { context, replicateUnattended, startContinuous } = createControllerHarness({
|
||||
liveSync: true,
|
||||
syncOnStart: false,
|
||||
periodicReplication: true,
|
||||
});
|
||||
startContinuous.mockImplementation(() => continuous.promise);
|
||||
resumeReplicationScheduling(context);
|
||||
|
||||
await runPeriodicReplication(context);
|
||||
|
||||
expect(replicateUnattended).not.toHaveBeenCalled();
|
||||
continuous.resolve({ status: "completed" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("replication scheduling serviceFeature", () => {
|
||||
it("binds lifecycle handlers and exposes only daemon scheduling controls", async () => {
|
||||
const handlers: Record<string, () => Promise<boolean>> = {};
|
||||
const timer: ReplicationSchedulingTimer = {
|
||||
enable: vi.fn(),
|
||||
disable: vi.fn(),
|
||||
};
|
||||
let periodicProcess!: () => Promise<void>;
|
||||
const replicateUnattended = vi.fn(async (): Promise<ReplicationOutcome> => ({ status: "completed" }));
|
||||
const addHandler = (name: string) =>
|
||||
vi.fn((handler: () => Promise<boolean>) => {
|
||||
handlers[name] = handler;
|
||||
return () => undefined;
|
||||
});
|
||||
const services = {
|
||||
context: {},
|
||||
API: { addLog: vi.fn() },
|
||||
appLifecycle: {
|
||||
isReady: vi.fn(() => true),
|
||||
isSuspended: vi.fn(() => false),
|
||||
onResumed: { addHandler: addHandler("resumed") },
|
||||
onSuspending: { addHandler: addHandler("suspending") },
|
||||
onUnload: { addHandler: addHandler("unload") },
|
||||
},
|
||||
control: { hasUnloaded: vi.fn(() => false) },
|
||||
replication: {
|
||||
replicateUnattended,
|
||||
startContinuous: vi.fn(async (): Promise<ReplicationOutcome> => ({ status: "completed" })),
|
||||
},
|
||||
setting: {
|
||||
currentSettings: vi.fn(() => ({
|
||||
...DEFAULT_SETTINGS,
|
||||
isConfigured: true,
|
||||
liveSync: false,
|
||||
syncOnStart: true,
|
||||
periodicReplication: true,
|
||||
})),
|
||||
onBeforeRealiseSetting: { addHandler: addHandler("before-setting") },
|
||||
onSettingRealised: { addHandler: addHandler("setting-realised") },
|
||||
},
|
||||
};
|
||||
|
||||
const control = useReplicationScheduling({ services, serviceModules: {} } as never, (process) => {
|
||||
periodicProcess = process;
|
||||
return timer;
|
||||
});
|
||||
|
||||
expect(Object.keys(control).sort()).toEqual(["markInitialOneShotSatisfied", "setExternalPollingMode"]);
|
||||
expect(Object.keys(handlers).sort()).toEqual([
|
||||
"before-setting",
|
||||
"resumed",
|
||||
"setting-realised",
|
||||
"suspending",
|
||||
"unload",
|
||||
]);
|
||||
|
||||
await expect(handlers.resumed()).resolves.toBe(true);
|
||||
await vi.waitFor(() => expect(replicateUnattended).toHaveBeenCalledOnce());
|
||||
|
||||
replicateUnattended.mockClear();
|
||||
await periodicProcess();
|
||||
expect(replicateUnattended).toHaveBeenCalledWith({
|
||||
trigger: "periodic",
|
||||
interaction: NO_INTERACTION,
|
||||
});
|
||||
|
||||
control.setExternalPollingMode(true);
|
||||
expect(timer.disable).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user