Align host settings with the 1.0 lifecycle

This commit is contained in:
vorotamoroz
2026-07-20 13:20:48 +00:00
parent 52e7bd25da
commit 80d1416a81
28 changed files with 360 additions and 57 deletions
+24 -2
View File
@@ -6,10 +6,16 @@ import {
DATABASE_COMPATIBILITY_VERSION_KEY,
evaluateCompatibilityPause,
legacyDatabaseCompatibilityVersionKey,
requiresFilenameCaseSensitivityDecision,
type CompatibilityPause,
} from "@/common/databaseCompatibility.ts";
export type CompatibilityReviewSummaryAction = "details" | "resume" | "keep-paused" | false;
export type CompatibilityReviewSummaryAction =
| "details"
| "resume"
| "use-case-sensitive"
| "keep-paused"
| false;
export type CompatibilityReviewDetailsAction = "back" | false;
// Explicit flag-file recovery runs at priorities 5, 10, and 20. Present the
@@ -89,16 +95,26 @@ export class CompatibilityReviewController {
return true;
}
private async acknowledge(): Promise<void> {
private async acknowledge(options: { useCaseSensitiveFilenames?: boolean } = {}): Promise<void> {
if (!this.pause?.resumable) return;
const setting = this.core.services.setting;
const settings = setting.currentSettings();
const previousMessage = settings.versionUpFlash;
const hadFilenameCaseDecision = typeof settings.handleFilenameCaseSensitive === "boolean";
const previousFilenameCaseDecision = settings.handleFilenameCaseSensitive;
if (options.useCaseSensitiveFilenames) {
settings.handleFilenameCaseSensitive = true;
}
settings.versionUpFlash = "";
try {
await setting.saveSettingData();
} catch (error) {
settings.versionUpFlash = previousMessage || COMPATIBILITY_PAUSE_SETTING_MESSAGE;
if (hadFilenameCaseDecision) {
settings.handleFilenameCaseSensitive = previousFilenameCaseDecision;
} else {
delete (settings as Partial<typeof settings>).handleFilenameCaseSensitive;
}
throw error;
}
@@ -119,9 +135,15 @@ export class CompatibilityReviewController {
break;
}
if (action === "resume" && this.pause.resumable) {
if (requiresFilenameCaseSensitivityDecision(this.pause)) break;
await this.acknowledge();
return;
}
if (action === "use-case-sensitive" && this.pause.resumable) {
if (!requiresFilenameCaseSensitivityDecision(this.pause)) break;
await this.acknowledge({ useCaseSensitiveFilenames: true });
return;
}
break;
}
if (this.pause && !this.disposed) {
@@ -39,7 +39,10 @@ function createFixture(
if (options.legacyMarker !== undefined && options.legacyMarker !== null) {
local.set(legacyKey, options.legacyMarker);
}
const settings = { versionUpFlash: options.versionUpFlash ?? "" };
const settings: {
versionUpFlash: string;
handleFilenameCaseSensitive?: boolean;
} = { versionUpFlash: options.versionUpFlash ?? "" };
const saveSettingData = vi.fn().mockResolvedValue(undefined);
const applySettings = vi.fn().mockResolvedValue(true);
const setting = {
@@ -106,6 +109,61 @@ describe("compatibility review controller", () => {
expect(fixture.ui.clearReminder).toHaveBeenCalled();
});
it("requires an explicit legacy-compatible filename-case decision before resuming", async () => {
const fixture = createFixture({
marker: "12",
migration: {
sourceVersion: 10,
targetVersion: 10,
requiresSyncReview: true,
reviewReasons: [
{
code: "filename-case-sensitivity-unresolved",
fromVersion: 10,
toVersion: 10,
},
],
},
});
vi.mocked(fixture.ui.showSummary).mockResolvedValue("use-case-sensitive" as never);
await fixture.controller.initialise();
await fixture.controller.openReview();
expect(fixture.settings.handleFilenameCaseSensitive).toBe(true);
expect(fixture.local.get(DATABASE_COMPATIBILITY_VERSION_KEY)).toBe("12");
expect(fixture.saveSettingData).toHaveBeenCalledTimes(2);
expect(fixture.applySettings).toHaveBeenCalledOnce();
expect(fixture.controller.pendingPause).toBeUndefined();
});
it("does not let a generic resume action bypass an unresolved filename-case decision", async () => {
const fixture = createFixture({
marker: "12",
migration: {
sourceVersion: 10,
targetVersion: 10,
requiresSyncReview: true,
reviewReasons: [
{
code: "filename-case-sensitivity-unresolved",
fromVersion: 10,
toVersion: 10,
},
],
},
});
vi.mocked(fixture.ui.showSummary).mockResolvedValue("resume");
await fixture.controller.initialise();
await fixture.controller.openReview();
expect(fixture.settings.handleFilenameCaseSensitive).toBeUndefined();
expect(fixture.applySettings).not.toHaveBeenCalled();
expect(fixture.controller.pendingPause).toBeDefined();
expect(fixture.ui.showReminder).toHaveBeenCalledOnce();
});
it("does not allow a downgrade pause to be resumed", async () => {
const fixture = createFixture({ marker: "13" });
vi.mocked(fixture.ui.showSummary).mockResolvedValue("resume");
@@ -1,9 +1,15 @@
import type { CompatibilityPause, CompatibilityPauseReason } from "@/common/databaseCompatibility.ts";
import {
requiresFilenameCaseSensitivityDecision,
type CompatibilityPause,
type CompatibilityPauseReason,
} from "@/common/databaseCompatibility.ts";
export function compatibilityReviewSummaryMarkdown(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.";
const action = !pause.resumable
? "This installation cannot safely acknowledge the detected state. Update Self-hosted LiveSync before attempting to synchronise again."
: requiresFilenameCaseSensitivityDecision(pause)
? "Before resuming, review the missing file-name case policy and explicitly keep the legacy-compatible behaviour, or leave synchronisation paused while you plan a database rebuild."
: "Before resuming, review the compatibility details and update Self-hosted LiveSync on every device which uses this remote database.";
return `Remote synchronisation is paused on this device because its compatibility state requires attention.
${action}
@@ -28,6 +34,9 @@ function reasonMarkdown(reason: CompatibilityPauseReason): string {
if (reason.isFromFutureSchema) {
return `- The saved settings use schema **${reason.sourceVersion}**, which is newer than schema **${reason.currentVersion}** supported by this installation.`;
}
if (reason.reviewReasons.some(({ code }) => code === "filename-case-sensitivity-unresolved")) {
return "- This existing Vault has no saved file-name case policy. Self-hosted LiveSync will not infer a cross-platform policy while synchronisation is paused.";
}
return `- The settings were migrated from schema **${reason.sourceVersion}** to **${reason.currentVersion}** and require review before synchronisation resumes.`;
}
const escapedMessage = reason.message.replace(/[\\`*_{}[\]()<>#+.!|-]/gu, "\\$&");
@@ -35,9 +44,11 @@ function reasonMarkdown(reason: CompatibilityPauseReason): string {
}
export function compatibilityReviewDetailsMarkdown(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.";
const resolution = !pause.resumable
? "Install a compatible current version of Self-hosted LiveSync. This pause cannot be dismissed by the current installation."
: requiresFilenameCaseSensitivityDecision(pause)
? "Choosing 'Keep case-sensitive handling and resume' records an explicit decision; case-sensitive handling preserves the earlier behaviour. To adopt cross-platform case-insensitive handling, keep synchronisation paused and use the compatibility setting workflow after checking for paths which differ only by letter case; case-insensitive handling requires a database rebuild."
: "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.";
return `## Why synchronisation is paused
${pause.reasons.map(reasonMarkdown).join("\n")}
@@ -1,6 +1,9 @@
import { Notice } from "@/deps.ts";
import type { Confirm } from "@vrtmrz/livesync-commonlib/compat/interfaces/Confirm";
import type { CompatibilityPause } from "@/common/databaseCompatibility.ts";
import {
requiresFilenameCaseSensitivityDecision,
type CompatibilityPause,
} from "@/common/databaseCompatibility.ts";
import type {
CompatibilityReviewDetailsAction,
CompatibilityReviewSummaryAction,
@@ -13,6 +16,7 @@ import {
const REVIEW_DETAILS = "Review compatibility details";
const KEEP_PAUSED = "Keep synchronisation paused";
const USE_CASE_SENSITIVE = "Keep case-sensitive handling and resume";
const RESUME = "Resume synchronisation";
const BACK = "Back to compatibility review";
@@ -22,9 +26,11 @@ export class ObsidianCompatibilityReviewUi implements CompatibilityReviewUi {
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 buttons = !pause.resumable
? ([REVIEW_DETAILS, KEEP_PAUSED] as const)
: requiresFilenameCaseSensitivityDecision(pause)
? ([REVIEW_DETAILS, USE_CASE_SENSITIVE, KEEP_PAUSED] as const)
: ([REVIEW_DETAILS, RESUME, KEEP_PAUSED] as const);
const result = await this.confirm.confirmWithMessage(
"Synchronisation paused for compatibility review",
compatibilityReviewSummaryMarkdown(pause),
@@ -34,6 +40,7 @@ export class ObsidianCompatibilityReviewUi implements CompatibilityReviewUi {
"vertical"
);
if (result === REVIEW_DETAILS) return "details";
if (result === USE_CASE_SENSITIVE) return "use-case-sensitive";
if (result === RESUME) return "resume";
if (result === KEEP_PAUSED) return "keep-paused";
return false;
@@ -1,6 +1,33 @@
import { describe, expect, it } from "vitest";
import { describe, expect, it, vi } from "vitest";
import type { CompatibilityPause } from "@/common/databaseCompatibility.ts";
import { compatibilityReviewDetailsMarkdown } from "./compatibilityReviewMarkdown.ts";
import { ObsidianCompatibilityReviewUi } from "./compatibilityReviewObsidian.ts";
vi.mock("@/deps.ts", () => ({
Notice: class {
hide() {}
},
}));
const unresolvedFilenameCasePause: CompatibilityPause = {
resumable: true,
reasons: [
{
source: "settings-schema",
sourceVersion: 10,
currentVersion: 10,
isFromFutureSchema: false,
resumable: true,
reviewReasons: [
{
code: "filename-case-sensitivity-unresolved",
fromVersion: 10,
toVersion: 10,
},
],
},
],
};
describe("Obsidian compatibility review", () => {
it("explains why a configured Vault can be missing its device-local acknowledgement", async () => {
@@ -21,4 +48,27 @@ describe("Obsidian compatibility review", () => {
expect(details).toContain("new Obsidian profile");
expect(details).toContain("does not mean that it is safe to resume automatically");
});
it("explains the safe choices for an unresolved filename-case policy", () => {
const details = compatibilityReviewDetailsMarkdown(unresolvedFilenameCasePause);
expect(details).toContain("file-name case policy");
expect(details).toContain("case-sensitive handling preserves the earlier behaviour");
expect(details).toContain("case-insensitive handling requires a database rebuild");
});
it("offers the legacy-compatible case decision instead of a generic resume action", async () => {
const label = "Keep case-sensitive handling and resume";
const confirmWithMessage = vi.fn().mockResolvedValue(label);
const ui = new ObsidianCompatibilityReviewUi({ confirmWithMessage } as never);
await expect(ui.showSummary(unresolvedFilenameCasePause)).resolves.toBe("use-case-sensitive");
expect(confirmWithMessage).toHaveBeenCalledWith(
"Synchronisation paused for compatibility review",
expect.any(String),
["Review compatibility details", label, "Keep synchronisation paused"],
"Keep synchronisation paused",
undefined,
"vertical"
);
});
});
@@ -0,0 +1,10 @@
import type { ObsidianLiveSyncSettings } from "@vrtmrz/livesync-commonlib/compat/common/types";
import { createNewVaultSettings } from "@vrtmrz/livesync-commonlib/settings";
export function createEditingSettingsAfterFullReset<T extends ObsidianLiveSyncSettings>(editingSettings: T): T {
return { ...editingSettings, ...createNewVaultSettings(), isConfigured: false };
}
export function createCoreSettingsAfterFullReset(): ObsidianLiveSyncSettings {
return { ...createNewVaultSettings(), isConfigured: false };
}
@@ -0,0 +1,27 @@
import { describe, expect, it } from "vitest";
import { DEFAULT_SETTINGS, type ObsidianLiveSyncSettings } from "@vrtmrz/livesync-commonlib/compat/common/types";
import { createNewVaultSettings } from "@vrtmrz/livesync-commonlib/settings";
import { createCoreSettingsAfterFullReset, createEditingSettingsAfterFullReset } from "./settingsReset.ts";
describe("full settings reset", () => {
it("resets the persisted settings to the recommended new-Vault values", () => {
const settings = createCoreSettingsAfterFullReset();
expect(settings).toEqual({
...createNewVaultSettings(),
isConfigured: false,
});
});
it("preserves settings-dialog fields while applying the new-Vault values", () => {
const editing = {
...DEFAULT_SETTINGS,
configPassphrase: "dialog-only",
} as ObsidianLiveSyncSettings & { configPassphrase: string };
expect(createEditingSettingsAfterFullReset(editing)).toEqual({
...editing,
...createNewVaultSettings(),
isConfigured: false,
});
});
});
@@ -20,6 +20,7 @@ function compatibilityPause(): CompatibilityPause {
currentVersion: 10,
isFromFutureSchema: false,
resumable: true,
reviewReasons: [],
},
],
};