Require explicit review for restored Vaults

This commit is contained in:
vorotamoroz
2026-07-19 07:05:22 +00:00
parent d28282edad
commit f0ec046cf4
14 changed files with 410 additions and 121 deletions
+6 -1
View File
@@ -12,6 +12,11 @@ import {
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
// compatibility review only after those operations have completed; a recovery
// handler which stops start-up also prevents this dialogue from competing with it.
export const COMPATIBILITY_REVIEW_LAYOUT_PRIORITY = 30;
export interface CompatibilityReviewUi {
showSummary(pause: CompatibilityPause): Promise<CompatibilityReviewSummaryAction>;
showDetails(pause: CompatibilityPause): Promise<CompatibilityReviewDetailsAction>;
@@ -139,7 +144,7 @@ export function useCompatibilityReview(core: LiveSyncCore, ui: CompatibilityRevi
core.services.appLifecycle.onLayoutReady.addHandler(() => {
fireAndForget(() => controller.openReview());
return Promise.resolve(true);
});
}, COMPATIBILITY_REVIEW_LAYOUT_PRIORITY);
core.services.appLifecycle.onUnload.addHandler(() => {
controller.dispose();
return Promise.resolve(true);
@@ -4,7 +4,11 @@ import {
DATABASE_COMPATIBILITY_VERSION_KEY,
legacyDatabaseCompatibilityVersionKey,
} from "@/common/databaseCompatibility.ts";
import { CompatibilityReviewController, type CompatibilityReviewUi } from "./compatibilityReview.ts";
import {
CompatibilityReviewController,
type CompatibilityReviewUi,
useCompatibilityReview,
} from "./compatibilityReview.ts";
function migrationState(overrides: Record<string, unknown> = {}) {
return {
@@ -161,4 +165,26 @@ describe("compatibility review controller", () => {
expect(fixture.ui.showSummary).not.toHaveBeenCalled();
expect(fixture.ui.clearReminder).toHaveBeenCalledOnce();
});
it("runs the review after the ordered red flag recovery handlers", () => {
const onSettingLoaded = { addHandler: vi.fn() };
const onLayoutReady = { addHandler: vi.fn() };
const onUnload = { addHandler: vi.fn() };
const core = {
services: {
appLifecycle: { onSettingLoaded, onLayoutReady, onUnload },
API: { addCommand: vi.fn() },
},
} as never;
const ui: CompatibilityReviewUi = {
showSummary: vi.fn(),
showDetails: vi.fn(),
showReminder: vi.fn(),
clearReminder: vi.fn(),
};
useCompatibilityReview(core, ui);
expect(onLayoutReady.addHandler).toHaveBeenCalledWith(expect.any(Function), 30);
});
});
@@ -0,0 +1,54 @@
import type { CompatibilityPause, 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.";
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 can happen when a Vault is copied or restored, or when it is opened with a new Obsidian profile. This installation uses version **${reason.currentVersion}**. An empty local database does not mean that it is safe to resume automatically.`;
}
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}`;
}
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.";
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}`;
}
@@ -1,70 +1,21 @@
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 { CompatibilityPause } from "@/common/databaseCompatibility.ts";
import type {
CompatibilityReviewDetailsAction,
CompatibilityReviewSummaryAction,
CompatibilityReviewUi,
} from "./compatibilityReview.ts";
import {
compatibilityReviewDetailsMarkdown,
compatibilityReviewSummaryMarkdown,
} from "./compatibilityReviewMarkdown.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;
@@ -76,7 +27,7 @@ export class ObsidianCompatibilityReviewUi implements CompatibilityReviewUi {
: ([REVIEW_DETAILS, KEEP_PAUSED] as const);
const result = await this.confirm.confirmWithMessage(
"Synchronisation paused for compatibility review",
summaryMarkdown(pause),
compatibilityReviewSummaryMarkdown(pause),
[...buttons],
KEEP_PAUSED,
undefined,
@@ -91,7 +42,7 @@ export class ObsidianCompatibilityReviewUi implements CompatibilityReviewUi {
async showDetails(pause: CompatibilityPause): Promise<CompatibilityReviewDetailsAction> {
const result = await this.confirm.confirmWithMessage(
"Compatibility review details",
detailsMarkdown(pause),
compatibilityReviewDetailsMarkdown(pause),
[BACK],
BACK,
undefined,
@@ -0,0 +1,24 @@
import { describe, expect, it } from "vitest";
import type { CompatibilityPause } from "@/common/databaseCompatibility.ts";
import { compatibilityReviewDetailsMarkdown } from "./compatibilityReviewMarkdown.ts";
describe("Obsidian compatibility review", () => {
it("explains why a configured Vault can be missing its device-local acknowledgement", async () => {
const pause: CompatibilityPause = {
resumable: true,
reasons: [
{
source: "database-version",
state: "missing",
currentVersion: 12,
resumable: true,
},
],
};
const details = compatibilityReviewDetailsMarkdown(pause);
expect(details).toContain("copied or restored");
expect(details).toContain("new Obsidian profile");
expect(details).toContain("does not mean that it is safe to resume automatically");
});
});