mirror of
https://github.com/vrtmrz/obsidian-livesync.git
synced 2026-07-25 05:53:00 +00:00
Prepare the 1.0 compatibility review flow
This commit is contained in:
@@ -0,0 +1,157 @@
|
||||
import { fireAndForget } from "octagonal-wheels/promises";
|
||||
import { VER } from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
import type { LiveSyncCore } from "@/main.ts";
|
||||
import {
|
||||
COMPATIBILITY_PAUSE_SETTING_MESSAGE,
|
||||
DATABASE_COMPATIBILITY_VERSION_KEY,
|
||||
evaluateCompatibilityPause,
|
||||
legacyDatabaseCompatibilityVersionKey,
|
||||
type CompatibilityPause,
|
||||
} from "@/common/databaseCompatibility.ts";
|
||||
|
||||
export type CompatibilityReviewSummaryAction = "details" | "resume" | "keep-paused" | false;
|
||||
export type CompatibilityReviewDetailsAction = "back" | false;
|
||||
|
||||
export interface CompatibilityReviewUi {
|
||||
showSummary(pause: CompatibilityPause): Promise<CompatibilityReviewSummaryAction>;
|
||||
showDetails(pause: CompatibilityPause): Promise<CompatibilityReviewDetailsAction>;
|
||||
showReminder(openReview: () => void): void;
|
||||
clearReminder(): void;
|
||||
}
|
||||
|
||||
export class CompatibilityReviewController {
|
||||
private pause: CompatibilityPause | undefined;
|
||||
private activeReview: Promise<void> | undefined;
|
||||
private disposed = false;
|
||||
|
||||
constructor(
|
||||
private readonly core: LiveSyncCore,
|
||||
private readonly ui: CompatibilityReviewUi,
|
||||
private readonly currentVersion: number = VER
|
||||
) {}
|
||||
|
||||
get pendingPause(): CompatibilityPause | undefined {
|
||||
return this.pause;
|
||||
}
|
||||
|
||||
private readAcknowledgedVersion(): string | null {
|
||||
const setting = this.core.services.setting;
|
||||
const currentMarker = setting.getSmallConfig(DATABASE_COMPATIBILITY_VERSION_KEY);
|
||||
if (currentMarker) return currentMarker;
|
||||
|
||||
const legacyKey = legacyDatabaseCompatibilityVersionKey(this.core.services.vault.getVaultName());
|
||||
const legacyMarker = setting.getDeviceLocalConfig(legacyKey);
|
||||
if (!legacyMarker) return null;
|
||||
|
||||
setting.setSmallConfig(DATABASE_COMPATIBILITY_VERSION_KEY, legacyMarker);
|
||||
setting.deleteDeviceLocalConfig(legacyKey);
|
||||
return legacyMarker;
|
||||
}
|
||||
|
||||
async initialise(): Promise<boolean> {
|
||||
if (this.disposed) return true;
|
||||
const setting = this.core.services.setting;
|
||||
const settings = setting.currentSettings();
|
||||
const acknowledgedVersion = this.readAcknowledgedVersion();
|
||||
const evaluation = evaluateCompatibilityPause({
|
||||
acknowledgedVersion,
|
||||
currentVersion: this.currentVersion,
|
||||
migrationState: setting.getSettingsMigrationState(),
|
||||
legacyReviewMessage: settings.versionUpFlash,
|
||||
});
|
||||
|
||||
this.pause = evaluation.pause;
|
||||
if (evaluation.initialiseAcknowledgedVersion) {
|
||||
setting.setSmallConfig(DATABASE_COMPATIBILITY_VERSION_KEY, `${this.currentVersion}`);
|
||||
return true;
|
||||
}
|
||||
if (!this.pause) return true;
|
||||
|
||||
if (settings.versionUpFlash === "") {
|
||||
settings.versionUpFlash = COMPATIBILITY_PAUSE_SETTING_MESSAGE;
|
||||
await setting.saveSettingData();
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private async acknowledge(): Promise<void> {
|
||||
if (!this.pause?.resumable) return;
|
||||
const setting = this.core.services.setting;
|
||||
const settings = setting.currentSettings();
|
||||
const previousMessage = settings.versionUpFlash;
|
||||
settings.versionUpFlash = "";
|
||||
try {
|
||||
await setting.saveSettingData();
|
||||
} catch (error) {
|
||||
settings.versionUpFlash = previousMessage || COMPATIBILITY_PAUSE_SETTING_MESSAGE;
|
||||
throw error;
|
||||
}
|
||||
|
||||
setting.setSmallConfig(DATABASE_COMPATIBILITY_VERSION_KEY, `${this.currentVersion}`);
|
||||
const legacyKey = legacyDatabaseCompatibilityVersionKey(this.core.services.vault.getVaultName());
|
||||
setting.deleteDeviceLocalConfig(legacyKey);
|
||||
this.pause = undefined;
|
||||
this.ui.clearReminder();
|
||||
await this.core.services.control.applySettings();
|
||||
}
|
||||
|
||||
private async runReview(): Promise<void> {
|
||||
while (this.pause) {
|
||||
const action = await this.ui.showSummary(this.pause);
|
||||
if (action === "details") {
|
||||
const detailsAction = await this.ui.showDetails(this.pause);
|
||||
if (detailsAction === "back") continue;
|
||||
break;
|
||||
}
|
||||
if (action === "resume" && this.pause.resumable) {
|
||||
await this.acknowledge();
|
||||
return;
|
||||
}
|
||||
break;
|
||||
}
|
||||
if (this.pause && !this.disposed) {
|
||||
this.ui.showReminder(() => {
|
||||
fireAndForget(() => this.openReview());
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
openReview(): Promise<void> {
|
||||
if (this.disposed || !this.pause) return Promise.resolve();
|
||||
if (this.activeReview) return this.activeReview;
|
||||
this.ui.clearReminder();
|
||||
this.activeReview = this.runReview().finally(() => {
|
||||
this.activeReview = undefined;
|
||||
});
|
||||
return this.activeReview;
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
this.disposed = true;
|
||||
this.pause = undefined;
|
||||
this.ui.clearReminder();
|
||||
}
|
||||
}
|
||||
|
||||
export function useCompatibilityReview(core: LiveSyncCore, ui: CompatibilityReviewUi): CompatibilityReviewController {
|
||||
const controller = new CompatibilityReviewController(core, ui);
|
||||
core.services.appLifecycle.onSettingLoaded.addHandler(() => controller.initialise());
|
||||
core.services.appLifecycle.onLayoutReady.addHandler(() => {
|
||||
fireAndForget(() => controller.openReview());
|
||||
return Promise.resolve(true);
|
||||
});
|
||||
core.services.appLifecycle.onUnload.addHandler(() => {
|
||||
controller.dispose();
|
||||
return Promise.resolve(true);
|
||||
});
|
||||
core.services.API.addCommand({
|
||||
id: "livesync-review-compatibility-pause",
|
||||
name: "Review why synchronisation is paused",
|
||||
checkCallback: (checking) => {
|
||||
if (!controller.pendingPause) return false;
|
||||
if (!checking) fireAndForget(() => controller.openReview());
|
||||
return true;
|
||||
},
|
||||
});
|
||||
return controller;
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
COMPATIBILITY_PAUSE_SETTING_MESSAGE,
|
||||
DATABASE_COMPATIBILITY_VERSION_KEY,
|
||||
legacyDatabaseCompatibilityVersionKey,
|
||||
} from "@/common/databaseCompatibility.ts";
|
||||
import { CompatibilityReviewController, type CompatibilityReviewUi } from "./compatibilityReview.ts";
|
||||
|
||||
function migrationState(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
sourceVersion: 2,
|
||||
targetVersion: 2,
|
||||
isNewVault: false,
|
||||
isFromFutureSchema: false,
|
||||
changed: false,
|
||||
requiresSyncReview: false,
|
||||
reviewReasons: [],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function createFixture(
|
||||
options: {
|
||||
marker?: string | null;
|
||||
legacyMarker?: string | null;
|
||||
versionUpFlash?: string;
|
||||
migration?: Record<string, unknown>;
|
||||
} = {}
|
||||
) {
|
||||
const local = new Map<string, string>();
|
||||
if (options.marker !== undefined && options.marker !== null) {
|
||||
local.set(DATABASE_COMPATIBILITY_VERSION_KEY, options.marker);
|
||||
}
|
||||
const legacyKey = legacyDatabaseCompatibilityVersionKey("Test Vault");
|
||||
if (options.legacyMarker !== undefined && options.legacyMarker !== null) {
|
||||
local.set(legacyKey, options.legacyMarker);
|
||||
}
|
||||
const settings = { versionUpFlash: options.versionUpFlash ?? "" };
|
||||
const saveSettingData = vi.fn().mockResolvedValue(undefined);
|
||||
const applySettings = vi.fn().mockResolvedValue(true);
|
||||
const setting = {
|
||||
currentSettings: () => settings,
|
||||
getSettingsMigrationState: () => migrationState(options.migration),
|
||||
getSmallConfig: (key: string) => local.get(key) ?? "",
|
||||
setSmallConfig: (key: string, value: string) => local.set(key, value),
|
||||
getDeviceLocalConfig: (key: string) => local.get(key) ?? null,
|
||||
deleteDeviceLocalConfig: (key: string) => local.delete(key),
|
||||
saveSettingData,
|
||||
};
|
||||
const core = {
|
||||
services: {
|
||||
setting,
|
||||
vault: { getVaultName: () => "Test Vault" },
|
||||
control: { applySettings },
|
||||
},
|
||||
} as never;
|
||||
const ui: CompatibilityReviewUi = {
|
||||
showSummary: vi.fn().mockResolvedValue("keep-paused"),
|
||||
showDetails: vi.fn().mockResolvedValue(false),
|
||||
showReminder: vi.fn(),
|
||||
clearReminder: vi.fn(),
|
||||
};
|
||||
const controller = new CompatibilityReviewController(core, ui, 12);
|
||||
return { controller, ui, local, legacyKey, settings, saveSettingData, applySettings };
|
||||
}
|
||||
|
||||
describe("compatibility review controller", () => {
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("initialises the acknowledged version for a new Vault without showing a pause", async () => {
|
||||
const fixture = createFixture({ marker: null, migration: { isNewVault: true } });
|
||||
|
||||
await expect(fixture.controller.initialise()).resolves.toBe(true);
|
||||
|
||||
expect(fixture.local.get(DATABASE_COMPATIBILITY_VERSION_KEY)).toBe("12");
|
||||
expect(fixture.controller.pendingPause).toBeUndefined();
|
||||
expect(fixture.saveSettingData).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("preserves preferences and advances the marker only after an upgrade review is resumed", async () => {
|
||||
const fixture = createFixture({ marker: "11" });
|
||||
vi.mocked(fixture.ui.showSummary).mockResolvedValue("resume");
|
||||
|
||||
await fixture.controller.initialise();
|
||||
|
||||
expect(fixture.settings.versionUpFlash).toBe(COMPATIBILITY_PAUSE_SETTING_MESSAGE);
|
||||
expect(fixture.local.get(DATABASE_COMPATIBILITY_VERSION_KEY)).toBe("11");
|
||||
expect(fixture.saveSettingData).toHaveBeenCalledTimes(1);
|
||||
|
||||
await fixture.controller.openReview();
|
||||
|
||||
expect(fixture.settings.versionUpFlash).toBe("");
|
||||
expect(fixture.local.get(DATABASE_COMPATIBILITY_VERSION_KEY)).toBe("12");
|
||||
expect(fixture.saveSettingData).toHaveBeenCalledTimes(2);
|
||||
expect(fixture.applySettings).toHaveBeenCalledOnce();
|
||||
expect(fixture.controller.pendingPause).toBeUndefined();
|
||||
expect(fixture.ui.clearReminder).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not allow a downgrade pause to be resumed", async () => {
|
||||
const fixture = createFixture({ marker: "13" });
|
||||
vi.mocked(fixture.ui.showSummary).mockResolvedValue("resume");
|
||||
|
||||
await fixture.controller.initialise();
|
||||
await fixture.controller.openReview();
|
||||
|
||||
expect(fixture.controller.pendingPause?.resumable).toBe(false);
|
||||
expect(fixture.settings.versionUpFlash).toBe(COMPATIBILITY_PAUSE_SETTING_MESSAGE);
|
||||
expect(fixture.local.get(DATABASE_COMPATIBILITY_VERSION_KEY)).toBe("13");
|
||||
expect(fixture.applySettings).not.toHaveBeenCalled();
|
||||
expect(fixture.ui.showReminder).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("returns from details to the reason dialogue and leaves a persistent reminder", async () => {
|
||||
const fixture = createFixture({ marker: "11" });
|
||||
vi.mocked(fixture.ui.showSummary).mockResolvedValueOnce("details").mockResolvedValueOnce("keep-paused");
|
||||
vi.mocked(fixture.ui.showDetails).mockResolvedValue("back");
|
||||
|
||||
await fixture.controller.initialise();
|
||||
await fixture.controller.openReview();
|
||||
|
||||
expect(fixture.ui.showSummary).toHaveBeenCalledTimes(2);
|
||||
expect(fixture.ui.showDetails).toHaveBeenCalledOnce();
|
||||
expect(fixture.ui.showReminder).toHaveBeenCalledOnce();
|
||||
expect(fixture.local.get(DATABASE_COMPATIBILITY_VERSION_KEY)).toBe("11");
|
||||
});
|
||||
|
||||
it("migrates the old Vault-scoped marker to Commonlib device-local storage", async () => {
|
||||
const fixture = createFixture({ legacyMarker: "11" });
|
||||
|
||||
await fixture.controller.initialise();
|
||||
|
||||
expect(fixture.local.get(DATABASE_COMPATIBILITY_VERSION_KEY)).toBe("11");
|
||||
expect(fixture.local.has(fixture.legacyKey)).toBe(false);
|
||||
expect(fixture.controller.pendingPause).toBeDefined();
|
||||
});
|
||||
|
||||
it("restores the runtime gate if persisting an acknowledgement fails", async () => {
|
||||
const fixture = createFixture({ marker: "11" });
|
||||
vi.mocked(fixture.ui.showSummary).mockResolvedValue("resume");
|
||||
await fixture.controller.initialise();
|
||||
fixture.saveSettingData.mockRejectedValueOnce(new Error("save failed"));
|
||||
|
||||
await expect(fixture.controller.openReview()).rejects.toThrow("save failed");
|
||||
|
||||
expect(fixture.settings.versionUpFlash).toBe(COMPATIBILITY_PAUSE_SETTING_MESSAGE);
|
||||
expect(fixture.local.get(DATABASE_COMPATIBILITY_VERSION_KEY)).toBe("11");
|
||||
expect(fixture.applySettings).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not open a delayed review after the controller has been disposed", async () => {
|
||||
const fixture = createFixture({ marker: "11" });
|
||||
await fixture.controller.initialise();
|
||||
|
||||
fixture.controller.dispose();
|
||||
await fixture.controller.openReview();
|
||||
|
||||
expect(fixture.controller.pendingPause).toBeUndefined();
|
||||
expect(fixture.ui.showSummary).not.toHaveBeenCalled();
|
||||
expect(fixture.ui.clearReminder).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,131 @@
|
||||
import { Notice } from "@/deps.ts";
|
||||
import type { Confirm } from "@vrtmrz/livesync-commonlib/compat/interfaces/Confirm";
|
||||
import type { CompatibilityPause, CompatibilityPauseReason } from "@/common/databaseCompatibility.ts";
|
||||
import type {
|
||||
CompatibilityReviewDetailsAction,
|
||||
CompatibilityReviewSummaryAction,
|
||||
CompatibilityReviewUi,
|
||||
} from "./compatibilityReview.ts";
|
||||
|
||||
const REVIEW_DETAILS = "Review compatibility details";
|
||||
const KEEP_PAUSED = "Keep synchronisation paused";
|
||||
const RESUME = "Resume synchronisation";
|
||||
const BACK = "Back to compatibility review";
|
||||
|
||||
function summaryMarkdown(pause: CompatibilityPause): string {
|
||||
const action = pause.resumable
|
||||
? "Before resuming, review the compatibility details and update Self-hosted LiveSync on every device which uses this remote database."
|
||||
: "This installation cannot safely acknowledge the detected state. Update Self-hosted LiveSync before attempting to synchronise again.";
|
||||
return `Remote synchronisation is paused on this device because its compatibility state requires attention.
|
||||
|
||||
${action}
|
||||
|
||||
Your automatic synchronisation preferences have not been changed. Closing this dialogue keeps synchronisation paused.`;
|
||||
}
|
||||
|
||||
function reasonMarkdown(reason: CompatibilityPauseReason): string {
|
||||
if (reason.source === "database-version") {
|
||||
if (reason.state === "upgrade") {
|
||||
return `- The last acknowledged internal database version was **${reason.acknowledgedVersion}** and this installation uses **${reason.currentVersion}**.`;
|
||||
}
|
||||
if (reason.state === "downgrade") {
|
||||
return `- This installation uses internal database version **${reason.currentVersion}**, but this device previously acknowledged newer version **${reason.acknowledgedVersion}**. An older installation must not resume synchronisation.`;
|
||||
}
|
||||
if (reason.state === "missing") {
|
||||
return `- No previously acknowledged internal database version was found for this existing Vault. This installation uses version **${reason.currentVersion}**.`;
|
||||
}
|
||||
return `- The saved internal database version marker is invalid. This installation uses version **${reason.currentVersion}**.`;
|
||||
}
|
||||
if (reason.source === "settings-schema") {
|
||||
if (reason.isFromFutureSchema) {
|
||||
return `- The saved settings use schema **${reason.sourceVersion}**, which is newer than schema **${reason.currentVersion}** supported by this installation.`;
|
||||
}
|
||||
return `- The settings were migrated from schema **${reason.sourceVersion}** to **${reason.currentVersion}** and require review before synchronisation resumes.`;
|
||||
}
|
||||
const escapedMessage = reason.message.replace(/[\\`*_{}[\]()<>#+.!|-]/gu, "\\$&");
|
||||
return `- An earlier compatibility review remains pending: ${escapedMessage}`;
|
||||
}
|
||||
|
||||
function detailsMarkdown(pause: CompatibilityPause): string {
|
||||
const resolution = pause.resumable
|
||||
? "After all devices have been updated, return to the compatibility review summary and explicitly resume synchronisation. The current internal version will only then be recorded as acknowledged."
|
||||
: "Install a compatible current version of Self-hosted LiveSync. This pause cannot be dismissed by the current installation.";
|
||||
return `## Why synchronisation is paused
|
||||
|
||||
${pause.reasons.map(reasonMarkdown).join("\n")}
|
||||
|
||||
## What the pause changes
|
||||
|
||||
- Remote replication is blocked before work begins.
|
||||
- Your saved automatic synchronisation preferences remain unchanged.
|
||||
- Closing either dialogue leaves the safety gate active.
|
||||
|
||||
## What to do next
|
||||
|
||||
${resolution}`;
|
||||
}
|
||||
|
||||
export class ObsidianCompatibilityReviewUi implements CompatibilityReviewUi {
|
||||
private reminder: Notice | undefined;
|
||||
|
||||
constructor(private readonly confirm: Confirm) {}
|
||||
|
||||
async showSummary(pause: CompatibilityPause): Promise<CompatibilityReviewSummaryAction> {
|
||||
const buttons = pause.resumable
|
||||
? ([REVIEW_DETAILS, RESUME, KEEP_PAUSED] as const)
|
||||
: ([REVIEW_DETAILS, KEEP_PAUSED] as const);
|
||||
const result = await this.confirm.confirmWithMessage(
|
||||
"Synchronisation paused for compatibility review",
|
||||
summaryMarkdown(pause),
|
||||
[...buttons],
|
||||
KEEP_PAUSED,
|
||||
undefined,
|
||||
"vertical"
|
||||
);
|
||||
if (result === REVIEW_DETAILS) return "details";
|
||||
if (result === RESUME) return "resume";
|
||||
if (result === KEEP_PAUSED) return "keep-paused";
|
||||
return false;
|
||||
}
|
||||
|
||||
async showDetails(pause: CompatibilityPause): Promise<CompatibilityReviewDetailsAction> {
|
||||
const result = await this.confirm.confirmWithMessage(
|
||||
"Compatibility review details",
|
||||
detailsMarkdown(pause),
|
||||
[BACK],
|
||||
BACK,
|
||||
undefined,
|
||||
"vertical"
|
||||
);
|
||||
if (result === BACK) return "back";
|
||||
return false;
|
||||
}
|
||||
|
||||
showReminder(openReview: () => void): void {
|
||||
this.clearReminder();
|
||||
let reminderAnchor: HTMLAnchorElement | undefined;
|
||||
const fragment = createFragment((documentFragment) => {
|
||||
documentFragment.createSpan({
|
||||
text: "Self-hosted LiveSync has paused remote synchronisation for compatibility review. ",
|
||||
});
|
||||
documentFragment.createEl("a", { text: "Review why" }, (anchor) => {
|
||||
reminderAnchor = anchor;
|
||||
anchor.addEventListener("click", (event) => {
|
||||
event.preventDefault();
|
||||
openReview();
|
||||
});
|
||||
});
|
||||
});
|
||||
this.reminder = new Notice(fragment, 0);
|
||||
reminderAnchor?.closest<HTMLElement>(".notice")?.classList.add("livesync-compatibility-review-notice");
|
||||
}
|
||||
|
||||
clearReminder(): void {
|
||||
this.reminder?.hide();
|
||||
this.reminder = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
export function createObsidianCompatibilityReviewUi(confirm: Confirm): CompatibilityReviewUi {
|
||||
return new ObsidianCompatibilityReviewUi(confirm);
|
||||
}
|
||||
Reference in New Issue
Block a user