From 40a4987b0be525773cd2d71cbca24d03808bb302 Mon Sep 17 00:00:00 2001 From: vorotamoroz Date: Mon, 20 Jul 2026 14:20:10 +0000 Subject: [PATCH] Reserve setup initialisation before enabling settings --- docs/settings.md | 4 + src/modules/features/SetupManager.ts | 54 +++++++++---- .../features/SetupManager.unit.spec.ts | 75 ++++++++++++++++++- .../setupObsidian/setupActivationLifecycle.ts | 35 +++++++++ .../setupActivationLifecycle.unit.spec.ts | 66 ++++++++++++++++ updates.md | 4 + 6 files changed, 222 insertions(+), 16 deletions(-) create mode 100644 src/serviceFeatures/setupObsidian/setupActivationLifecycle.ts create mode 100644 src/serviceFeatures/setupObsidian/setupActivationLifecycle.unit.spec.ts diff --git a/docs/settings.md b/docs/settings.md index e574fb64..ab24c417 100644 --- a/docs/settings.md +++ b/docs/settings.md @@ -29,6 +29,10 @@ Internal database or settings compatibility reviews use a separate safety dialog This pane is used for setting up Self-hosted LiveSync. There are several options to set up Self-hosted LiveSync. +An unconfigured installation does not open the onboarding dialogue automatically or scan the Vault into the local database. A long-lived Notice offers the onboarding action, and **Open onboarding wizard** remains available from the command palette after that Notice closes. + +Choose the new-device path when this device owns the files which should initialise synchronisation. Choose the existing-device path when it should receive an established remote state. The wizard reserves Rebuild or Fetch respectively before enabling the settings and requesting a restart, so the selected initialisation runs before the ordinary start-up scan. + ### 1. Quick Setup Most preferred method to setup Self-hosted LiveSync. You can setup Self-hosted LiveSync with a few clicks. diff --git a/src/modules/features/SetupManager.ts b/src/modules/features/SetupManager.ts index fc4ae05d..5cbc3b23 100644 --- a/src/modules/features/SetupManager.ts +++ b/src/modules/features/SetupManager.ts @@ -40,6 +40,10 @@ import type { SetupRemoteResultType, UseSetupURIResultType, } from "./SetupWizard/dialogs/setupDialogTypes.ts"; +import { + applySettingsAndFetchOnActivation, + applySettingsWithScheduledInitialisation, +} from "@/serviceFeatures/setupObsidian/setupActivationLifecycle.ts"; /** * User modes for onboarding and setup @@ -341,9 +345,9 @@ export class SetupManager extends AbstractModule { // console.dir(patch); if (!activate) { extra(); - await this.applySetting(newConf, UserMode.ExistingUser); - this._log("Setting Applied", LOG_LEVEL_NOTICE); - return true; + const applied = await this.applySettingAndScheduleFetchOnActivation(newConf, UserMode.ExistingUser); + if (applied) this._log("Setting Applied", LOG_LEVEL_NOTICE); + return applied; } // Check virtual changes const original = { ...this.settings, P2P_DevicePeerName: "" } as ObsidianLiveSyncSettings; @@ -351,9 +355,9 @@ export class SetupManager extends AbstractModule { const isOnlyVirtualChange = isObjectDifferent(original, modified, true) === false; if (isOnlyVirtualChange) { extra(); - await this.applySetting(newConf, UserMode.ExistingUser); - this._log("Settings from wizard applied.", LOG_LEVEL_NOTICE); - return true; + const applied = await this.applySettingAndScheduleFetchOnActivation(newConf, UserMode.ExistingUser); + if (applied) this._log("Settings from wizard applied.", LOG_LEVEL_NOTICE); + return applied; } else { const userModeResult = await this.dialogManager.openWithExplicitCancel(OutroAskUserMode); @@ -363,9 +367,12 @@ export class SetupManager extends AbstractModule { userMode = UserMode.ExistingUser; } else if (userModeResult === "compatible-existing-user") { extra(); - await this.applySetting(newConf, UserMode.ExistingUser); - this._log("Settings from wizard applied.", LOG_LEVEL_NOTICE); - return true; + const applied = await this.applySettingAndScheduleFetchOnActivation( + newConf, + UserMode.ExistingUser + ); + if (applied) this._log("Settings from wizard applied.", LOG_LEVEL_NOTICE); + return applied; } else if (userModeResult === "cancelled") { this._log("User cancelled applying settings from wizard.", LOG_LEVEL_NOTICE); return false; @@ -382,13 +389,17 @@ export class SetupManager extends AbstractModule { } if (confirm) { extra(); - await this.applySetting(newConf, userMode); if (userMode === UserMode.NewUser) { - // For new users, schedule a rebuild everything. - await this.core.rebuilder.scheduleRebuild(); + // Reserve Rebuild before enabling the imported settings, so + // the current runtime cannot begin ordinary processing first. + await applySettingsWithScheduledInitialisation(this.core.rebuilder, "rebuild", async () => { + await this.applySetting(newConf, userMode); + }); } else { - // For existing users, schedule a fetch. - await this.core.rebuilder.scheduleFetch(); + // Existing data must be fetched before the ordinary startup scan. + await applySettingsWithScheduledInitialisation(this.core.rebuilder, "fetch", async () => { + await this.applySetting(newConf, userMode); + }); } } // Settings applied, but may require rebuild to take effect. @@ -430,4 +441,19 @@ export class SetupManager extends AbstractModule { await this.services.setting.applyExternalSettings(newConf, true); return true; } + + private async applySettingAndScheduleFetchOnActivation( + newConf: ObsidianLiveSyncSettings, + userMode: UserMode + ): Promise { + const wasConfigured = this.settings.isConfigured; + return await applySettingsAndFetchOnActivation( + this.core.rebuilder, + wasConfigured, + newConf.isConfigured, + async () => { + await this.applySetting(newConf, userMode); + } + ); + } } diff --git a/src/modules/features/SetupManager.unit.spec.ts b/src/modules/features/SetupManager.unit.spec.ts index a676db23..3aa7e133 100644 --- a/src/modules/features/SetupManager.unit.spec.ts +++ b/src/modules/features/SetupManager.unit.spec.ts @@ -98,8 +98,14 @@ function createSetupManager() { const core: any = { _services: services, rebuilder: { - scheduleRebuild: vi.fn(() => Promise.resolve()), - scheduleFetch: vi.fn(() => Promise.resolve()), + scheduleRebuild: vi.fn(async (prepareBeforeRestart?: () => Promise) => { + await prepareBeforeRestart?.(); + return true; + }), + scheduleFetch: vi.fn(async (prepareBeforeRestart?: () => Promise) => { + await prepareBeforeRestart?.(); + return true; + }), }, }; Object.defineProperty(core, "services", { @@ -169,4 +175,69 @@ describe("SetupManager", () => { ); expect(setting.currentSettings().activeConfigurationId).toBe("legacy-couchdb"); }); + + it("reserves Rebuild before saving a new-user configuration", async () => { + const { manager, setting, dialogManager, core } = createSetupManager(); + setting.settings = { ...setting.currentSettings(), isConfigured: false }; + const applyExternalSettings = vi.spyOn(setting, "applyExternalSettings"); + dialogManager.openWithExplicitCancel.mockResolvedValueOnce(true); + + await manager.onConfirmApplySettingsFromWizard( + { ...createLegacyRemoteSetting(), isConfigured: true }, + UserMode.NewUser + ); + + expect(core.rebuilder.scheduleRebuild).toHaveBeenCalledWith(expect.any(Function)); + expect(core.rebuilder.scheduleRebuild.mock.invocationCallOrder[0]).toBeLessThan( + applyExternalSettings.mock.invocationCallOrder[0] + ); + expect(setting.currentSettings().isConfigured).toBe(true); + }); + + it("reserves Fetch when compatible imported settings activate an unconfigured device", async () => { + const { manager, setting, dialogManager, core } = createSetupManager(); + setting.settings = { ...setting.currentSettings(), isConfigured: false }; + const applyExternalSettings = vi.spyOn(setting, "applyExternalSettings"); + dialogManager.openWithExplicitCancel + .mockResolvedValueOnce({ ...createLegacyRemoteSetting(), isConfigured: true }) + .mockResolvedValueOnce("compatible-existing-user"); + + await manager.onUseSetupURI(UserMode.Unknown, "mock-config://settings"); + + expect(core.rebuilder.scheduleFetch).toHaveBeenCalledWith(expect.any(Function)); + expect(core.rebuilder.scheduleFetch.mock.invocationCallOrder[0]).toBeLessThan( + applyExternalSettings.mock.invocationCallOrder[0] + ); + expect(setting.currentSettings().isConfigured).toBe(true); + }); + + it("applies compatible settings to an already configured device without scheduling Fetch", async () => { + const { manager, setting, dialogManager, core } = createSetupManager(); + setting.settings = { ...setting.currentSettings(), isConfigured: true }; + dialogManager.openWithExplicitCancel + .mockResolvedValueOnce({ ...createLegacyRemoteSetting(), isConfigured: true }) + .mockResolvedValueOnce("compatible-existing-user"); + + await manager.onUseSetupURI(UserMode.Unknown, "mock-config://settings"); + + expect(core.rebuilder.scheduleFetch).not.toHaveBeenCalled(); + expect(setting.currentSettings().isConfigured).toBe(true); + }); + + it("does not enable imported settings when the initialisation flag cannot be reserved", async () => { + const { manager, setting, dialogManager, core } = createSetupManager(); + setting.settings = { ...setting.currentSettings(), isConfigured: false }; + const applyExternalSettings = vi.spyOn(setting, "applyExternalSettings"); + core.rebuilder.scheduleRebuild.mockResolvedValueOnce(false); + dialogManager.openWithExplicitCancel.mockResolvedValueOnce(true); + + await manager.onConfirmApplySettingsFromWizard( + { ...createLegacyRemoteSetting(), isConfigured: true }, + UserMode.NewUser + ); + + expect(core.rebuilder.scheduleRebuild).toHaveBeenCalledWith(expect.any(Function)); + expect(applyExternalSettings).not.toHaveBeenCalled(); + expect(setting.currentSettings().isConfigured).toBe(false); + }); }); diff --git a/src/serviceFeatures/setupObsidian/setupActivationLifecycle.ts b/src/serviceFeatures/setupObsidian/setupActivationLifecycle.ts new file mode 100644 index 00000000..3b4f2a25 --- /dev/null +++ b/src/serviceFeatures/setupObsidian/setupActivationLifecycle.ts @@ -0,0 +1,35 @@ +export type SetupInitialisationMode = "fetch" | "rebuild"; + +export interface SetupInitialisationScheduler { + scheduleFetch(prepareBeforeRestart?: () => Promise): Promise; + scheduleRebuild(prepareBeforeRestart?: () => Promise): Promise; +} + +/** + * Reserves the next-start initialisation operation before enabling settings. + * The scheduler owns suspension, rollback, and restart ordering. + */ +export function applySettingsWithScheduledInitialisation( + scheduler: SetupInitialisationScheduler, + mode: SetupInitialisationMode, + applySettings: () => Promise +): Promise { + return mode === "fetch" ? scheduler.scheduleFetch(applySettings) : scheduler.scheduleRebuild(applySettings); +} + +/** + * Uses Fetch only for the transition from an unconfigured device to an + * explicitly configured existing device. Ordinary edits apply immediately. + */ +export async function applySettingsAndFetchOnActivation( + scheduler: SetupInitialisationScheduler, + wasConfigured: boolean | undefined, + willBeConfigured: boolean | undefined, + applySettings: () => Promise +): Promise { + if (!wasConfigured && willBeConfigured) { + return await applySettingsWithScheduledInitialisation(scheduler, "fetch", applySettings); + } + await applySettings(); + return true; +} diff --git a/src/serviceFeatures/setupObsidian/setupActivationLifecycle.unit.spec.ts b/src/serviceFeatures/setupObsidian/setupActivationLifecycle.unit.spec.ts new file mode 100644 index 00000000..89b1bc74 --- /dev/null +++ b/src/serviceFeatures/setupObsidian/setupActivationLifecycle.unit.spec.ts @@ -0,0 +1,66 @@ +import { describe, expect, it, vi } from "vitest"; +import { + applySettingsAndFetchOnActivation, + applySettingsWithScheduledInitialisation, + type SetupInitialisationScheduler, +} from "./setupActivationLifecycle"; + +function createScheduler() { + const events: string[] = []; + const scheduler: SetupInitialisationScheduler = { + scheduleFetch: vi.fn(async (prepare) => { + events.push("fetch-reserved"); + await prepare?.(); + return true; + }), + scheduleRebuild: vi.fn(async (prepare) => { + events.push("rebuild-reserved"); + await prepare?.(); + return true; + }), + }; + const applySettings = vi.fn(async () => { + events.push("settings-applied"); + }); + return { applySettings, events, scheduler }; +} + +describe("setup activation lifecycle", () => { + it.each([ + ["fetch", "fetch-reserved"], + ["rebuild", "rebuild-reserved"], + ] as const)("reserves %s before applying settings", async (mode, reservedEvent) => { + const { applySettings, events, scheduler } = createScheduler(); + + await expect(applySettingsWithScheduledInitialisation(scheduler, mode, applySettings)).resolves.toBe(true); + + expect(events).toEqual([reservedEvent, "settings-applied"]); + }); + + it("reserves Fetch when existing settings activate an unconfigured device", async () => { + const { applySettings, events, scheduler } = createScheduler(); + + await expect(applySettingsAndFetchOnActivation(scheduler, false, true, applySettings)).resolves.toBe(true); + + expect(events).toEqual(["fetch-reserved", "settings-applied"]); + }); + + it("applies an ordinary configured-device edit without scheduling initialisation", async () => { + const { applySettings, events, scheduler } = createScheduler(); + + await expect(applySettingsAndFetchOnActivation(scheduler, true, true, applySettings)).resolves.toBe(true); + + expect(events).toEqual(["settings-applied"]); + expect(scheduler.scheduleFetch).not.toHaveBeenCalled(); + expect(scheduler.scheduleRebuild).not.toHaveBeenCalled(); + }); + + it("does not apply settings when the scheduler cannot reserve its flag", async () => { + const { applySettings, scheduler } = createScheduler(); + vi.mocked(scheduler.scheduleFetch).mockResolvedValueOnce(false); + + await expect(applySettingsWithScheduledInitialisation(scheduler, "fetch", applySettings)).resolves.toBe(false); + + expect(applySettings).not.toHaveBeenCalled(); + }); +}); diff --git a/updates.md b/updates.md index 8321536f..4d66926d 100644 --- a/updates.md +++ b/updates.md @@ -12,6 +12,10 @@ Earlier releases remain available in the [0.25 release history](docs/releases/0. ## Unreleased +### Improved + +- Wizard-driven new-device and existing-device setup now reserves Rebuild or Fetch before enabling imported settings, preventing ordinary start-up work from running ahead of the selected initialisation. + ## 1.0.0-rc.0 19th July, 2026