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
+1 -1
View File
@@ -337,7 +337,7 @@ Options:
Commands:
daemon (default) Run mirror scan then continuously sync CouchDB <-> local filesystem
init-settings [path] Create settings JSON from DEFAULT_SETTINGS
init-settings [path] Create unconfigured settings JSON with the new-Vault recommendations
sync Run one replication cycle and exit
p2p-peers <timeout> Show discovered peers as [peer]<TAB><peer-id><TAB><peer-name>
p2p-sync <peer> <timeout> Synchronise with specified peer-id or peer-name
+10
View File
@@ -0,0 +1,10 @@
import type { ObsidianLiveSyncSettings } from "@vrtmrz/livesync-commonlib/compat/common/types";
import { createNewVaultSettings } from "@vrtmrz/livesync-commonlib/settings";
export function createDefaultCliSettings(): ObsidianLiveSyncSettings {
return {
...createNewVaultSettings(),
useIndexedDBAdapter: false,
isConfigured: false,
};
}
@@ -0,0 +1,16 @@
import { describe, expect, it } from "vitest";
import { createNewVaultSettings } from "@vrtmrz/livesync-commonlib/settings";
import { createDefaultCliSettings } from "./cliSettingsDefaults.ts";
describe("createDefaultCliSettings", () => {
it("uses the recommended new-Vault settings with the Node database adapter", () => {
const settings = createDefaultCliSettings();
const recommended = createNewVaultSettings();
expect(settings).toEqual({
...recommended,
useIndexedDBAdapter: false,
isConfigured: false,
});
});
});
+2 -5
View File
@@ -3,7 +3,6 @@ import { configureNodeLocalStorage, ensureGlobalNodeLocalStorage } from "./servi
import { LiveSyncBaseCore } from "@/LiveSyncBaseCore";
import { initialiseServiceModulesCLI } from "./serviceModules/CLIServiceModules";
import {
DEFAULT_SETTINGS,
LOG_LEVEL_VERBOSE,
type LOG_LEVEL,
type ObsidianLiveSyncSettings,
@@ -28,6 +27,7 @@ import { useP2PReplicatorFeature } from "@vrtmrz/livesync-commonlib/compat/repli
import { createNodeStandardIo, fsPromises as fs, path, fs as fsSync } from "@vrtmrz/livesync-commonlib/node";
import type { StandardIo } from "@vrtmrz/livesync-commonlib/context";
import { writeStderrLine, writeStdoutLine } from "./cliOutput";
import { createDefaultCliSettings } from "./cliSettingsDefaults";
const SETTINGS_FILE = ".livesync/settings.json";
ensureGlobalNodeLocalStorage();
@@ -257,10 +257,7 @@ async function createDefaultSettingsFile(options: CLIOptions, standardIo: Standa
}
}
const settings = {
...DEFAULT_SETTINGS,
useIndexedDBAdapter: false,
} as ObsidianLiveSyncSettings;
const settings = createDefaultCliSettings();
await fs.mkdir(path.dirname(targetPath), { recursive: true });
await fs.writeFile(targetPath, JSON.stringify(settings, null, 2), "utf-8");
@@ -93,9 +93,8 @@ data.encrypt = true;
data.passphrase = process.env.PASSPHRASE_VAL;
data.usePathObfuscation = true;
data.handleFilenameCaseSensitive = false;
data.customChunkSize = 50;
data.customChunkSize = 60;
data.usePluginSyncV2 = true;
data.doNotUseFixedRevisionForChunks = false;
data.P2P_DevicePeerName = process.env.DEVICE_NAME;
data.isConfigured = true;
+1 -2
View File
@@ -195,9 +195,8 @@ export async function applyP2pTestTweaks(settingsFile: string, deviceName: strin
data.passphrase = passphrase;
data.usePathObfuscation = true;
data.handleFilenameCaseSensitive = false;
data.customChunkSize = 50;
data.customChunkSize = 60;
data.usePluginSyncV2 = true;
data.doNotUseFixedRevisionForChunks = false;
data.P2P_DevicePeerName = deviceName;
data.isConfigured = true;
await Deno.writeTextFile(settingsFile, JSON.stringify(data, null, 2));
+16 -1
View File
@@ -1,4 +1,7 @@
import type { SettingsMigrationState } from "@vrtmrz/livesync-commonlib/compat/common/types";
import type {
SettingsMigrationReviewReason,
SettingsMigrationState,
} from "@vrtmrz/livesync-commonlib/compat/common/types";
export const DATABASE_COMPATIBILITY_VERSION_KEY = "database-compatibility-version";
export const DATABASE_COMPATIBILITY_LEGACY_VERSION_KEY_PREFIX = "obsidian-live-sync-ver";
@@ -21,6 +24,7 @@ export interface SettingsCompatibilityReason {
currentVersion: number;
isFromFutureSchema: boolean;
resumable: boolean;
reviewReasons: readonly SettingsMigrationReviewReason[];
}
export interface LegacyCompatibilityReason {
@@ -51,6 +55,16 @@ 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,
@@ -112,6 +126,7 @@ export function evaluateCompatibilityPause(input: CompatibilityEvaluationInput):
currentVersion: input.migrationState.targetVersion,
isFromFutureSchema: input.migrationState.isFromFutureSchema,
resumable: !input.migrationState.isFromFutureSchema,
reviewReasons: input.migrationState.reviewReasons,
});
}
@@ -103,11 +103,42 @@ describe("database compatibility evaluation", () => {
currentVersion: 2,
isFromFutureSchema: true,
resumable: false,
reviewReasons: [],
},
],
});
});
it("retains an unresolved filename-case decision in the host compatibility reason", () => {
const reviewReasons = [
{
code: "filename-case-sensitivity-unresolved",
fromVersion: 10,
toVersion: 10,
},
];
const result = evaluateCompatibilityPause({
acknowledgedVersion: "12",
currentVersion: 12,
migrationState: migrationState({
sourceVersion: 10,
targetVersion: 10,
requiresSyncReview: true,
reviewReasons,
}),
legacyReviewMessage: "",
});
expect(result.pause?.reasons).toContainEqual({
source: "settings-schema",
sourceVersion: 10,
currentVersion: 10,
isFromFutureSchema: false,
resumable: true,
reviewReasons,
});
});
it("retains an existing legacy review when no structured reason can be reconstructed", () => {
const result = evaluateCompatibilityPause({
acknowledgedVersion: "12",
@@ -3,8 +3,12 @@ import { DEFAULT_SETTINGS } from "@vrtmrz/livesync-commonlib/compat/common/types
import { ensureLocalDatabaseMaintenancePrerequisites } from "./maintenancePrerequisites";
function createPrerequisites(settingsOverride: Partial<typeof DEFAULT_SETTINGS> = {}) {
const askSelectStringDialogue = vi.fn<() => Promise<"Apply and continue" | "Cancel" | false | undefined>>(
async () => "Apply and continue"
const askSelectStringDialogue = vi.fn(
async (
_message: string,
_buttons: readonly ["Apply and continue", "Cancel"],
_options: { title: string; defaultAction: "Cancel" }
): Promise<"Apply and continue" | "Cancel" | false | undefined> => "Apply and continue"
);
const applyPartial = vi.fn(async () => undefined);
const settings = {
@@ -18,13 +22,12 @@ function createPrerequisites(settingsOverride: Partial<typeof DEFAULT_SETTINGS>
}
describe("LocalDatabaseMaintenance prerequisites", () => {
it("asks to apply missing prerequisite settings before maintenance actions", async () => {
it("asks to disable on-demand chunk fetching before maintenance actions", async () => {
const { settings, askSelectStringDialogue, applyPartial } = createPrerequisites();
const result = await ensureLocalDatabaseMaintenancePrerequisites({
operationName: "Garbage Collection",
settings: {
doNotUseFixedRevisionForChunks: settings.doNotUseFixedRevisionForChunks,
readChunksOnline: settings.readChunksOnline,
},
askSelectStringDialogue,
@@ -42,11 +45,11 @@ describe("LocalDatabaseMaintenance prerequisites", () => {
);
expect(applyPartial).toHaveBeenCalledWith(
{
doNotUseFixedRevisionForChunks: true,
readChunksOnline: false,
},
true
);
expect(vi.mocked(askSelectStringDialogue).mock.calls[0]?.[0]).not.toContain("Compute revisions for chunks");
});
it("cancels maintenance actions when prerequisite changes are rejected", async () => {
@@ -56,7 +59,6 @@ describe("LocalDatabaseMaintenance prerequisites", () => {
const result = await ensureLocalDatabaseMaintenancePrerequisites({
operationName: "Garbage Collection",
settings: {
doNotUseFixedRevisionForChunks: settings.doNotUseFixedRevisionForChunks,
readChunksOnline: settings.readChunksOnline,
},
askSelectStringDialogue,
@@ -76,7 +78,6 @@ describe("LocalDatabaseMaintenance prerequisites", () => {
const result = await ensureLocalDatabaseMaintenancePrerequisites({
operationName: "Garbage Collection",
settings: {
doNotUseFixedRevisionForChunks: settings.doNotUseFixedRevisionForChunks,
readChunksOnline: settings.readChunksOnline,
},
askSelectStringDialogue,
@@ -86,4 +87,24 @@ describe("LocalDatabaseMaintenance prerequisites", () => {
expect(askSelectStringDialogue).not.toHaveBeenCalled();
expect(applyPartial).not.toHaveBeenCalled();
});
it("does not treat the obsolete fixed-revision key as a maintenance prerequisite", async () => {
const { settings, askSelectStringDialogue, applyPartial } = createPrerequisites({
doNotUseFixedRevisionForChunks: false,
readChunksOnline: false,
});
const result = await ensureLocalDatabaseMaintenancePrerequisites({
operationName: "Garbage Collection",
settings: {
readChunksOnline: settings.readChunksOnline,
},
askSelectStringDialogue,
applyPartial,
});
expect(result).toBe(true);
expect(askSelectStringDialogue).not.toHaveBeenCalled();
expect(applyPartial).not.toHaveBeenCalled();
});
});
@@ -2,7 +2,7 @@ import type { ObsidianLiveSyncSettings } from "@vrtmrz/livesync-commonlib/compat
type MaintenancePrerequisiteSettings = Pick<
ObsidianLiveSyncSettings,
"doNotUseFixedRevisionForChunks" | "readChunksOnline"
"readChunksOnline"
>;
type MaintenancePrerequisiteOptions = {
@@ -23,14 +23,10 @@ export async function ensureLocalDatabaseMaintenancePrerequisites({
applyPartial,
}: MaintenancePrerequisiteOptions): Promise<boolean> {
const requiredSettings = {
doNotUseFixedRevisionForChunks: true,
readChunksOnline: false,
} satisfies MaintenancePrerequisiteSettings;
const missing = [
...(settings.doNotUseFixedRevisionForChunks ? [] : ["- Compute revisions for chunks: On (currently Off)"]),
...(settings.readChunksOnline ? ["- Fetch chunks on demand: Off (currently On)"] : []),
];
const missing = settings.readChunksOnline ? ["- Fetch chunks on demand: Off (currently On)"] : [];
if (missing.length == 0) return true;
@@ -117,7 +117,6 @@ const PRESERVED_SYNC_SETTING_KEYS = [
const NEW_VAULT_RECOMMENDATION_KEYS = [
"syncMaxSizeInMB",
"chunkSplitterVersion",
"doNotUseFixedRevisionForChunks",
"usePluginSyncV2",
"handleFilenameCaseSensitive",
"E2EEAlgorithm",
@@ -48,6 +48,7 @@ function compatibilityPause(): CompatibilityPause {
currentVersion: 10,
isFromFutureSchema: false,
resumable: true,
reviewReasons: migration().reviewReasons,
},
],
};
@@ -105,7 +106,6 @@ describe("Review Harness contract", () => {
...preservedSyncSettings,
syncMaxSizeInMB: NEW_VAULT_SETTINGS.syncMaxSizeInMB,
chunkSplitterVersion: NEW_VAULT_SETTINGS.chunkSplitterVersion,
doNotUseFixedRevisionForChunks: NEW_VAULT_SETTINGS.doNotUseFixedRevisionForChunks,
usePluginSyncV2: NEW_VAULT_SETTINGS.usePluginSyncV2,
handleFilenameCaseSensitive: NEW_VAULT_SETTINGS.handleFilenameCaseSensitive,
E2EEAlgorithm: NEW_VAULT_SETTINGS.E2EEAlgorithm,
@@ -31,6 +31,7 @@ function compatibilityPause(): CompatibilityPause {
currentVersion: 10,
isFromFutureSchema: false,
resumable: true,
reviewReasons: migration().reviewReasons,
},
],
};
@@ -473,7 +473,6 @@ export class ObsidianLiveSyncSettingTab extends PluginSettingTab {
isNeedRebuildLocal() {
return this.isSomeDirty([
"useIndexedDBAdapter",
"doNotUseFixedRevisionForChunks",
"handleFilenameCaseSensitive",
"passphrase",
"useDynamicIterationCount",
@@ -484,7 +483,6 @@ export class ObsidianLiveSyncSettingTab extends PluginSettingTab {
}
isNeedRebuildRemote() {
return this.isSomeDirty([
"doNotUseFixedRevisionForChunks",
"handleFilenameCaseSensitive",
"passphrase",
"useDynamicIterationCount",
@@ -11,10 +11,13 @@ import {
import type { ObsidianLiveSyncSettingTab } from "./ObsidianLiveSyncSettingTab.ts";
import type { PageFunctions } from "./SettingPane.ts";
import { visibleOnly } from "./SettingPane.ts";
import { DEFAULT_SETTINGS } from "@vrtmrz/livesync-commonlib/compat/common/types";
import { request } from "@/deps.ts";
import { SetupManager, UserMode } from "@/modules/features/SetupManager.ts";
import { LiveSyncError } from "@vrtmrz/livesync-commonlib/compat/common/LSError";
import {
createCoreSettingsAfterFullReset,
createEditingSettingsAfterFullReset,
} from "@/serviceFeatures/setupObsidian/settingsReset.ts";
export function paneSetup(
this: ObsidianLiveSyncSettingTab,
paneEl: HTMLElement,
@@ -92,9 +95,9 @@ export function paneSetup(
{ defaultOption: "No" }
)) == "yes"
) {
this.editingSettings = { ...this.editingSettings, ...DEFAULT_SETTINGS };
this.editingSettings = createEditingSettingsAfterFullReset(this.editingSettings);
await this.saveAllDirtySettings();
this.core.settings = { ...DEFAULT_SETTINGS };
this.core.settings = createCoreSettingsAfterFullReset();
await this.services.setting.saveSettingData();
await this.services.database.resetDatabase();
// await this.plugin.initializeDatabase();
+2 -2
View File
@@ -4,13 +4,13 @@ import {
type EncryptionSettings,
type ObsidianLiveSyncSettings,
type P2PSyncSetting,
DEFAULT_SETTINGS,
LOG_LEVEL_NOTICE,
LOG_LEVEL_VERBOSE,
REMOTE_COUCHDB,
REMOTE_MINIO,
REMOTE_P2P,
} from "@vrtmrz/livesync-commonlib/compat/common/types";
import { createNewVaultSettings } from "@vrtmrz/livesync-commonlib/settings";
import { isObjectDifferent } from "@vrtmrz/livesync-commonlib/compat/common/utils";
import Intro from "./SetupWizard/dialogs/Intro.svelte";
import SelectMethodNewUser from "./SetupWizard/dialogs/SelectMethodNewUser.svelte";
@@ -99,7 +99,7 @@ export class SetupManager extends AbstractModule {
* @returns Promise that resolves to true if onboarding completed successfully, false otherwise
*/
async onOnboard(userMode: UserMode): Promise<boolean> {
const originalSetting = userMode === UserMode.NewUser ? DEFAULT_SETTINGS : this.core.settings;
const originalSetting = userMode === UserMode.NewUser ? createNewVaultSettings() : this.core.settings;
if (userMode === UserMode.NewUser) {
//Ask how to apply initial setup
const method = await this.dialogManager.openWithExplicitCancel(SelectMethodNewUser);
+16 -1
View File
@@ -1,7 +1,12 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { DEFAULT_SETTINGS, REMOTE_COUCHDB, type ObsidianLiveSyncSettings } from "@vrtmrz/livesync-commonlib/compat/common/types";
import {
DEFAULT_SETTINGS,
REMOTE_COUCHDB,
type ObsidianLiveSyncSettings,
} from "@vrtmrz/livesync-commonlib/compat/common/types";
import { SettingService } from "@vrtmrz/livesync-commonlib/compat/services/base/SettingService";
import { ServiceContext } from "@vrtmrz/livesync-commonlib/context";
import { createNewVaultSettings } from "@vrtmrz/livesync-commonlib/settings";
vi.mock("./SetupWizard/dialogs/Intro.svelte", () => ({ default: {} }));
vi.mock("./SetupWizard/dialogs/SelectMethodNewUser.svelte", () => ({ default: {} }));
@@ -125,6 +130,16 @@ describe("SetupManager", () => {
vi.restoreAllMocks();
});
it("starts manual new-user setup from the recommended new-Vault settings", async () => {
const { manager, dialogManager } = createSetupManager();
dialogManager.openWithExplicitCancel.mockResolvedValueOnce("configure-manually");
const configureManually = vi.spyOn(manager, "onConfigureManually").mockResolvedValue(true);
await manager.onOnboard(UserMode.NewUser);
expect(configureManually).toHaveBeenCalledWith(createNewVaultSettings(), UserMode.NewUser);
});
it("onUseSetupURI should normalise imported legacy remote settings before applying", async () => {
const { manager, setting, dialogManager } = createSetupManager();
dialogManager.openWithExplicitCancel
+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: [],
},
],
};