Merge main into shared runtime foundations

Keep SyncGuard startup dependencies isolated in unload logging tests.
This commit is contained in:
vorotamoroz committed 2026-10-30 15:36:57 +00:00
commit bd05dde39c
83 files changed
+6313 -1507

No files matched your search

@@ -67,7 +67,7 @@
gap: 0.5em;
display: flex;
flex-direction: column;
padding-bottom: var(--keyboard-height, 0px);
padding-bottom: 0;
user-select: text;
-webkit-user-select: text;
}
@@ -76,6 +76,10 @@
margin-left: 10px;
}
.dialog-host :global(.button-group > button) {
margin-left: 0;
}
.dialog-host :global(.button-group) {
display: flex;
flex-direction: column;
+91 -2
View File
@@ -8,6 +8,36 @@ import {
type SettingServiceDependencies,
} from "@vrtmrz/livesync-commonlib/compat/services/base/SettingService";
import type { ObsidianServiceContext } from "@/modules/services/ObsidianServiceContext";
import { EVENT_SYNC_GUARD_PREFERENCES_CHANGED } from "@/common/events.ts";
export type SyncGuardSettings = {
syncGuardEnabled: boolean;
syncGuardMinimumFiles: number;
};
export type SyncGuardSettingKey = keyof SyncGuardSettings;
export const DEFAULT_SYNC_GUARD_SETTINGS: Readonly<SyncGuardSettings> = {
syncGuardEnabled: false,
syncGuardMinimumFiles: 20,
};
export function isSyncGuardSettingKey(key: string): key is SyncGuardSettingKey {
return key === "syncGuardEnabled" || key === "syncGuardMinimumFiles";
}
/** Access the Obsidian host preferences from a service exposed through the shared hub type. */
export function getSyncGuardSettings(setting: SettingService<ObsidianServiceContext>): Readonly<SyncGuardSettings> {
return (setting as ObsidianSettingService<ObsidianServiceContext>).syncGuardSettings;
}
export async function setSyncGuardSetting<K extends SyncGuardSettingKey>(
setting: SettingService<ObsidianServiceContext>,
key: K,
value: SyncGuardSettings[K]
): Promise<void> {
await (setting as ObsidianSettingService<ObsidianServiceContext>).setSyncGuardSetting(key, value);
}
export function normaliseObsidianSettingsData(data: unknown): ObsidianLiveSyncSettings | undefined {
if (typeof data !== "object" || data === null || Array.isArray(data)) return undefined;
@@ -15,6 +45,13 @@ export function normaliseObsidianSettingsData(data: unknown): ObsidianLiveSyncSe
}
export class ObsidianSettingService<T extends ObsidianServiceContext> extends SettingService<T> {
private _syncGuardSettings: SyncGuardSettings = { ...DEFAULT_SYNC_GUARD_SETTINGS };
private legacySyncGuardSettings: Partial<SyncGuardSettings> = {};
get syncGuardSettings(): Readonly<SyncGuardSettings> {
return this._syncGuardSettings;
}
constructor(context: T, dependencies: SettingServiceDependencies) {
super(context, dependencies);
this.onSettingSaved.addHandler((settings) => {
@@ -26,10 +63,62 @@ export class ObsidianSettingService<T extends ObsidianServiceContext> extends Se
return Promise.resolve(true);
});
}
override async loadSettings(): Promise<void> {
await super.loadSettings();
const storedEnabled = await this.config.getObject("syncGuardEnabled");
const storedMinimumFiles = await this.config.getObject("syncGuardMinimumFiles");
const enabled = typeof storedEnabled === "boolean" ? storedEnabled : this.legacySyncGuardSettings.syncGuardEnabled;
const minimumFiles =
typeof storedMinimumFiles === "number" && Number.isFinite(storedMinimumFiles) && storedMinimumFiles >= 1
? storedMinimumFiles
: this.legacySyncGuardSettings.syncGuardMinimumFiles;
this._syncGuardSettings = {
syncGuardEnabled: enabled ?? DEFAULT_SYNC_GUARD_SETTINGS.syncGuardEnabled,
syncGuardMinimumFiles: minimumFiles ?? DEFAULT_SYNC_GUARD_SETTINGS.syncGuardMinimumFiles,
};
if (storedEnabled === undefined && enabled !== undefined) {
await this.config.setObject("syncGuardEnabled", enabled);
}
if (storedMinimumFiles === undefined && minimumFiles !== undefined) {
await this.config.setObject("syncGuardMinimumFiles", minimumFiles);
}
}
async setSyncGuardSetting<K extends SyncGuardSettingKey>(key: K, value: SyncGuardSettings[K]): Promise<void> {
if (key === "syncGuardMinimumFiles" && (typeof value !== "number" || !Number.isInteger(value) || value < 1)) {
throw new RangeError("SyncGuard minimum files must be a positive integer");
}
if (this._syncGuardSettings[key] === value) return;
await this.config.setObject(key, value);
this._syncGuardSettings = { ...this._syncGuardSettings, [key]: value };
// These preferences change only the workspace overlay. Re-applying all
// replication settings would unnecessarily interrupt an active sync.
this.context.events.emitEvent(EVENT_SYNC_GUARD_PREFERENCES_CHANGED);
}
protected override async saveData(data: ObsidianLiveSyncSettings): Promise<void> {
return await this.context.liveSyncPlugin.saveData(data);
const persisted: Record<string, unknown> = { ...data };
delete persisted.syncGuardEnabled;
delete persisted.syncGuardMinimumFiles;
return await this.context.liveSyncPlugin.saveData(persisted);
}
protected override async loadData(): Promise<ObsidianLiveSyncSettings | undefined> {
return normaliseObsidianSettingsData(await this.context.liveSyncPlugin.loadData());
const data = normaliseObsidianSettingsData(await this.context.liveSyncPlugin.loadData());
if (!data) return undefined;
const persisted: Record<string, unknown> = { ...data };
const { syncGuardEnabled, syncGuardMinimumFiles } = persisted;
this.legacySyncGuardSettings = {
...(typeof syncGuardEnabled === "boolean" ? { syncGuardEnabled } : {}),
...(typeof syncGuardMinimumFiles === "number" &&
Number.isFinite(syncGuardMinimumFiles) &&
syncGuardMinimumFiles >= 1
? { syncGuardMinimumFiles }
: {}),
};
delete persisted.syncGuardEnabled;
delete persisted.syncGuardMinimumFiles;
return persisted as unknown as ObsidianLiveSyncSettings;
}
}
@@ -1,20 +1,29 @@
import { describe, expect, it } from "vitest";
import { describe, expect, it, vi } from "vitest";
import { createServiceContext } from "@vrtmrz/livesync-commonlib/context";
import type { ObsidianLiveSyncSettings } from "@vrtmrz/livesync-commonlib/compat/common/types";
import { DEFAULT_SETTINGS, type ObsidianLiveSyncSettings } from "@vrtmrz/livesync-commonlib/compat/common/types";
import { EVENT_SETTING_SAVED } from "@vrtmrz/livesync-commonlib/compat/events/coreEvents";
import { EVENT_SYNC_GUARD_PREFERENCES_CHANGED } from "@/common/events.ts";
import type { IAPIService } from "@vrtmrz/livesync-commonlib/compat/services/base/IService";
import { MemoryValueStore } from "@vrtmrz/value-store";
import type { ObsidianServiceContext } from "@/modules/services/ObsidianServiceContext";
import { normaliseObsidianSettingsData } from "./ObsidianSettingService.ts";
import { ObsidianSettingService } from "./ObsidianSettingService.ts";
import { getSyncGuardSettings, ObsidianSettingService, setSyncGuardSetting } from "./ObsidianSettingService.ts";
function createSettingService(
vaultName: string,
deviceValues: MemoryValueStore
deviceValues: MemoryValueStore,
savedData?: unknown,
saveData = vi.fn(async (_settings: unknown) => undefined)
): ObsidianSettingService<ObsidianServiceContext> {
const context = createServiceContext() as unknown as ObsidianServiceContext;
context.liveSyncPlugin = {
loadData: vi.fn(async () => savedData),
saveData,
} as unknown as ObsidianServiceContext["liveSyncPlugin"];
const APIService = {
addLog: () => undefined,
getSystemVaultName: () => vaultName,
getAppID: () => "test-app",
} as unknown as IAPIService;
return new ObsidianSettingService(context, { APIService, deviceValues });
}
@@ -52,3 +61,102 @@ describe("normaliseObsidianSettingsData", () => {
await expect(secondValues.getValue("second-vault-shared-key")).resolves.toBe("second config");
});
});
describe("SyncGuard device-local settings", () => {
const savedSettings = { ...DEFAULT_SETTINGS, isConfigured: true, additionalSuffixOfDatabaseName: "" };
it("loads safe defaults when no local values were saved", async () => {
const setting = createSettingService("first-vault", new MemoryValueStore(), savedSettings);
await setting.loadSettings();
expect(getSyncGuardSettings(setting)).toEqual({ syncGuardEnabled: false, syncGuardMinimumFiles: 20 });
});
it("migrates preferences stored by earlier builds and excludes them from plugin data", async () => {
const localValues = new MemoryValueStore();
const saveData = vi.fn(async (_data: unknown) => undefined);
const setting = createSettingService(
"first-vault",
localValues,
{ ...savedSettings, syncGuardEnabled: true, syncGuardMinimumFiles: 7 },
saveData
);
await setting.loadSettings();
await setting.saveSettingData();
expect(getSyncGuardSettings(setting)).toEqual({ syncGuardEnabled: true, syncGuardMinimumFiles: 7 });
await expect(setting.config.getObject("syncGuardEnabled")).resolves.toBe(true);
await expect(setting.config.getObject("syncGuardMinimumFiles")).resolves.toBe(7);
expect(saveData.mock.lastCall?.[0]).not.toHaveProperty("syncGuardEnabled");
expect(saveData.mock.lastCall?.[0]).not.toHaveProperty("syncGuardMinimumFiles");
});
it("keeps existing device-local values authoritative over legacy plugin data", async () => {
const localValues = new MemoryValueStore();
const setting = createSettingService(
"first-vault",
localValues,
{ ...savedSettings, syncGuardEnabled: true, syncGuardMinimumFiles: 5 }
);
await localValues.setObject("first-vault--syncGuardEnabled", false);
await localValues.setObject("first-vault--syncGuardMinimumFiles", 30);
await setting.loadSettings();
expect(getSyncGuardSettings(setting)).toEqual({ syncGuardEnabled: false, syncGuardMinimumFiles: 30 });
});
it("restores saved preferences after recreating the service", async () => {
const localValues = new MemoryValueStore();
const first = createSettingService("first-vault", localValues, savedSettings);
await first.loadSettings();
await setSyncGuardSetting(first, "syncGuardEnabled", true);
await setSyncGuardSetting(first, "syncGuardMinimumFiles", 12);
const reloaded = createSettingService("first-vault", localValues, savedSettings);
await reloaded.loadSettings();
expect(getSyncGuardSettings(reloaded)).toEqual({ syncGuardEnabled: true, syncGuardMinimumFiles: 12 });
});
it("rejects invalid file thresholds without changing local settings", async () => {
const setting = createSettingService("first-vault", new MemoryValueStore(), savedSettings);
await setting.loadSettings();
const storedBefore = await setting.config.getObject("syncGuardMinimumFiles");
for (const invalid of [0, -1, 1.5, NaN, Infinity]) {
await expect(setSyncGuardSetting(setting, "syncGuardMinimumFiles", invalid)).rejects.toThrow(RangeError);
}
expect(getSyncGuardSettings(setting).syncGuardMinimumFiles).toBe(20);
await expect(setting.config.getObject("syncGuardMinimumFiles")).resolves.toBe(storedBefore);
});
it("persists edits only in this Vault's device store and notifies the guard", async () => {
const localValues = new MemoryValueStore();
const saveData = vi.fn(async (_data: unknown) => undefined);
const first = createSettingService("first-vault", localValues, savedSettings, saveData);
const second = createSettingService("second-vault", localValues, savedSettings);
await first.loadSettings();
await second.loadSettings();
const initialSaveCount = saveData.mock.calls.length;
const changed = vi.fn();
const unrelatedReplicationSettingsSaved = vi.fn();
first.context.events.onEvent(EVENT_SYNC_GUARD_PREFERENCES_CHANGED, changed);
first.context.events.onEvent(EVENT_SETTING_SAVED, unrelatedReplicationSettingsSaved);
await setSyncGuardSetting(first, "syncGuardEnabled", true);
await setSyncGuardSetting(first, "syncGuardMinimumFiles", 8);
await setSyncGuardSetting(first, "syncGuardEnabled", true);
expect(getSyncGuardSettings(first)).toEqual({ syncGuardEnabled: true, syncGuardMinimumFiles: 8 });
expect(getSyncGuardSettings(second)).toEqual({ syncGuardEnabled: false, syncGuardMinimumFiles: 20 });
await expect(localValues.getObject("first-vault--syncGuardEnabled")).resolves.toBe(true);
await expect(localValues.getObject("first-vault--syncGuardMinimumFiles")).resolves.toBe(8);
expect(changed).toHaveBeenCalledTimes(2);
expect(unrelatedReplicationSettingsSaved).not.toHaveBeenCalled();
expect(saveData).toHaveBeenCalledTimes(initialSaveCount);
});
});