Preserve legacy settings across same-profile upgrades

This commit is contained in:
vorotamoroz
2026-07-22 06:21:54 +00:00
parent c669f64933
commit aad0e56f09
22 changed files with 2258 additions and 167 deletions
-10
View File
@@ -55,16 +55,6 @@ export interface CompatibilityEvaluationInput {
legacyReviewMessage: string;
}
const FILENAME_CASE_SENSITIVITY_UNRESOLVED = "filename-case-sensitivity-unresolved";
export function requiresFilenameCaseSensitivityDecision(pause: CompatibilityPause): boolean {
return pause.reasons.some(
(reason) =>
reason.source === "settings-schema" &&
reason.reviewReasons.some(({ code }) => code === FILENAME_CASE_SENSITIVITY_UNRESOLVED)
);
}
function databaseVersionReason(
acknowledgedVersion: string | null,
currentVersion: number,
@@ -109,11 +109,11 @@ describe("database compatibility evaluation", () => {
});
});
it("retains an unresolved filename-case decision in the host compatibility reason", () => {
it("retains a settings migration review in the host compatibility reason", () => {
const reviewReasons = [
{
code: "filename-case-sensitivity-unresolved",
fromVersion: 10,
code: "legacy-update-review-pending",
fromVersion: 9,
toVersion: 10,
},
];
@@ -121,7 +121,7 @@ describe("database compatibility evaluation", () => {
acknowledgedVersion: "12",
currentVersion: 12,
migrationState: migrationState({
sourceVersion: 10,
sourceVersion: 9,
targetVersion: 10,
requiresSyncReview: true,
reviewReasons,
@@ -131,7 +131,7 @@ describe("database compatibility evaluation", () => {
expect(result.pause?.reasons).toContainEqual({
source: "settings-schema",
sourceVersion: 10,
sourceVersion: 9,
currentVersion: 10,
isFromFutureSchema: false,
resumable: true,
+17 -25
View File
@@ -6,16 +6,10 @@ import {
DATABASE_COMPATIBILITY_VERSION_KEY,
evaluateCompatibilityPause,
legacyDatabaseCompatibilityVersionKey,
requiresFilenameCaseSensitivityDecision,
type CompatibilityPause,
} from "@/common/databaseCompatibility.ts";
export type CompatibilityReviewSummaryAction =
| "details"
| "resume"
| "use-case-sensitive"
| "keep-paused"
| false;
export type CompatibilityReviewSummaryAction = "details" | "resume" | "keep-paused" | false;
export type CompatibilityReviewDetailsAction = "back" | false;
// Explicit flag-file recovery runs at priorities 5, 10, and 20. Present the
@@ -68,11 +62,25 @@ export class CompatibilityReviewController {
if (this.disposed) return true;
const setting = this.core.services.setting;
const settings = setting.currentSettings();
const migrationState = setting.getSettingsMigrationState();
// An existing unconfigured Vault cannot replicate, so a database
// compatibility pause would only compete with onboarding and persist
// a misleading sync warning. Do not acknowledge the missing marker:
// activation on a later start must evaluate the same state again.
// Genuinely new Vaults still initialise their marker below.
if (settings.isConfigured !== true && migrationState?.isNewVault !== true) {
this.pause = undefined;
this.ui.clearReminder();
this._initialised = true;
return true;
}
const acknowledgedVersion = this.readAcknowledgedVersion();
const evaluation = evaluateCompatibilityPause({
acknowledgedVersion,
currentVersion: this.currentVersion,
migrationState: setting.getSettingsMigrationState(),
migrationState,
legacyReviewMessage: settings.versionUpFlash,
});
@@ -95,26 +103,16 @@ export class CompatibilityReviewController {
return true;
}
private async acknowledge(options: { useCaseSensitiveFilenames?: boolean } = {}): Promise<void> {
private async acknowledge(): 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;
}
@@ -135,15 +133,9 @@ 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) {
@@ -28,6 +28,7 @@ function createFixture(
marker?: string | null;
legacyMarker?: string | null;
versionUpFlash?: string;
isConfigured?: boolean;
migration?: Record<string, unknown>;
} = {}
) {
@@ -39,10 +40,10 @@ function createFixture(
if (options.legacyMarker !== undefined && options.legacyMarker !== null) {
local.set(legacyKey, options.legacyMarker);
}
const settings: {
versionUpFlash: string;
handleFilenameCaseSensitive?: boolean;
} = { versionUpFlash: options.versionUpFlash ?? "" };
const settings = {
versionUpFlash: options.versionUpFlash ?? "",
isConfigured: options.isConfigured ?? true,
};
const saveSettingData = vi.fn().mockResolvedValue(undefined);
const applySettings = vi.fn().mockResolvedValue(true);
const setting = {
@@ -77,7 +78,7 @@ describe("compatibility review controller", () => {
});
it("initialises the acknowledged version for a new Vault without showing a pause", async () => {
const fixture = createFixture({ marker: null, migration: { isNewVault: true } });
const fixture = createFixture({ marker: null, isConfigured: false, migration: { isNewVault: true } });
expect(fixture.controller.initialised).toBe(false);
@@ -89,6 +90,30 @@ describe("compatibility review controller", () => {
expect(fixture.saveSettingData).not.toHaveBeenCalled();
});
it("defers a missing database marker while the Vault remains unconfigured", async () => {
const fixture = createFixture({ marker: null, isConfigured: false });
await expect(fixture.controller.initialise()).resolves.toBe(true);
expect(fixture.controller.pendingPause).toBeUndefined();
expect(fixture.settings.versionUpFlash).toBe("");
expect(fixture.local.has(DATABASE_COMPATIBILITY_VERSION_KEY)).toBe(false);
expect(fixture.saveSettingData).not.toHaveBeenCalled();
fixture.settings.isConfigured = true;
await expect(fixture.controller.initialise()).resolves.toBe(true);
expect(fixture.controller.pendingPause?.reasons).toContainEqual({
source: "database-version",
state: "missing",
currentVersion: 12,
resumable: true,
});
expect(fixture.settings.versionUpFlash).toBe(COMPATIBILITY_PAUSE_SETTING_MESSAGE);
expect(fixture.local.has(DATABASE_COMPATIBILITY_VERSION_KEY)).toBe(false);
expect(fixture.saveSettingData).toHaveBeenCalledOnce();
});
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");
@@ -109,61 +134,6 @@ 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,15 +1,9 @@
import {
requiresFilenameCaseSensitivityDecision,
type CompatibilityPause,
type CompatibilityPauseReason,
} from "@/common/databaseCompatibility.ts";
import type { CompatibilityPause, CompatibilityPauseReason } from "@/common/databaseCompatibility.ts";
export function compatibilityReviewSummaryMarkdown(pause: CompatibilityPause): string {
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.";
: "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}
@@ -34,9 +28,6 @@ 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, "\\$&");
@@ -46,9 +37,7 @@ function reasonMarkdown(reason: CompatibilityPauseReason): string {
export function compatibilityReviewDetailsMarkdown(pause: CompatibilityPause): string {
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.";
: "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,9 +1,6 @@
import { Notice } from "@/deps.ts";
import type { Confirm } from "@vrtmrz/livesync-commonlib/compat/interfaces/Confirm";
import {
requiresFilenameCaseSensitivityDecision,
type CompatibilityPause,
} from "@/common/databaseCompatibility.ts";
import type { CompatibilityPause } from "@/common/databaseCompatibility.ts";
import type {
CompatibilityReviewDetailsAction,
CompatibilityReviewSummaryAction,
@@ -16,7 +13,6 @@ 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";
@@ -28,9 +24,7 @@ export class ObsidianCompatibilityReviewUi implements CompatibilityReviewUi {
async showSummary(pause: CompatibilityPause): Promise<CompatibilityReviewSummaryAction> {
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);
: ([REVIEW_DETAILS, RESUME, KEEP_PAUSED] as const);
const result = await this.confirm.confirmWithMessage(
"Synchronisation paused for compatibility review",
compatibilityReviewSummaryMarkdown(pause),
@@ -40,7 +34,6 @@ 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;
@@ -9,22 +9,15 @@ vi.mock("@/deps.ts", () => ({
},
}));
const unresolvedFilenameCasePause: CompatibilityPause = {
const resumablePause: CompatibilityPause = {
resumable: true,
reasons: [
{
source: "settings-schema",
sourceVersion: 10,
currentVersion: 10,
isFromFutureSchema: false,
source: "database-version",
state: "upgrade",
acknowledgedVersion: 11,
currentVersion: 12,
resumable: true,
reviewReasons: [
{
code: "filename-case-sensitivity-unresolved",
fromVersion: 10,
toVersion: 10,
},
],
},
],
};
@@ -49,23 +42,15 @@ describe("Obsidian compatibility review", () => {
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);
it("offers the generic resume action in a vertical action dialogue", async () => {
const confirmWithMessage = vi.fn().mockResolvedValue("Resume synchronisation");
const ui = new ObsidianCompatibilityReviewUi({ confirmWithMessage } as never);
await expect(ui.showSummary(unresolvedFilenameCasePause)).resolves.toBe("use-case-sensitive");
await expect(ui.showSummary(resumablePause)).resolves.toBe("resume");
expect(confirmWithMessage).toHaveBeenCalledWith(
"Synchronisation paused for compatibility review",
expect.any(String),
["Review compatibility details", label, "Keep synchronisation paused"],
["Review compatibility details", "Resume synchronisation", "Keep synchronisation paused"],
"Keep synchronisation paused",
undefined,
"vertical"