Prepare the 1.0 compatibility review flow

This commit is contained in:
vorotamoroz
2026-07-19 04:34:08 +00:00
parent 52138bf7a5
commit ed3f81e9f9
42 changed files with 5524 additions and 4119 deletions
+157
View File
@@ -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);
}