mirror of
https://github.com/vrtmrz/obsidian-livesync.git
synced 2026-10-31 13:28:18 +00:00
Merge pull request #1275 from Akilaydin/feat/syncguard-bulk-sync-edit-guard
feat: add optional SyncGuard workspace protection during large catch-up syncs
This commit is contained in:
23 files changed
+1753
-36
No files matched your search
@@ -202,6 +202,26 @@ Setting key: networkWarningStyle
|
||||
|
||||
How to display network errors when the sync server is unreachable.
|
||||
|
||||
#### Protect the workspace during a large catch-up (opt-in)
|
||||
|
||||
Setting key: syncGuardEnabled
|
||||
|
||||
When enabled on this device, the workspace temporarily blocks interaction after a large batch of incoming note changes is detected during CouchDB LiveSync startup or resume. Incoming changes continue to be received and applied while the workspace is protected. The overlay can be bypassed at any time, including when synchronisation fails or the remote is unavailable. The preference is device-local; it does not affect other synchronising devices.
|
||||
|
||||
Both SyncGuard preferences are stored locally for this device, Vault, and [Database suffix](#database-suffix), separately from the main plug-in settings. They are not transferred through Setup URIs. Existing values saved by earlier test builds are migrated to device-local storage when the plug-in starts.
|
||||
|
||||
After changing the Database suffix, restart Obsidian, then check both SyncGuard preferences before relying on the overlay. Until Obsidian restarts, the settings screen can still show the previous suffix's values.
|
||||
|
||||
The threshold avoids interruption for ordinary small transfers. However, the number of changes cannot be known before they arrive: some notes may be updated before the guard activates. The guard is a user-interface aid, not an atomic Vault snapshot or a restriction on other plug-ins and external tools. It does not apply to other remote types or to ordinary live changes after the initial catch-up.
|
||||
|
||||
**Continue without waiting** releases the workspace while synchronisation continues, so some notes may still be out of date. SyncGuard is primarily a start-up or resume safeguard; after a manual bypass, an internal reconnection does not necessarily show the overlay again. Check the synchronisation status when you need to know whether later changes have settled.
|
||||
|
||||
#### Minimum received files to protect the workspace
|
||||
|
||||
Setting key: syncGuardMinimumFiles
|
||||
|
||||
The number of distinct incoming note Metadata documents required to activate the guard during a catch-up (default: 20). Chunk documents and repeated revisions of the same note do not increase the count. After activation, the guard remains until the network catch-up completes successfully and all observed local application work settles. If the catch-up fails, use **Continue without waiting** to work offline.
|
||||
|
||||
### 2. Logging
|
||||
|
||||
#### Show only notifications
|
||||
@@ -1172,6 +1192,8 @@ Do not use internal API
|
||||
Setting key: additionalSuffixOfDatabaseName
|
||||
LiveSync could not handle multiple vaults which have same name without different prefix, This should be automatically configured.
|
||||
|
||||
After changing this value, restart Obsidian and check the [SyncGuard preferences](#protect-the-workspace-during-a-large-catch-up-opt-in) for the selected suffix.
|
||||
|
||||
#### The Hash algorithm for chunk IDs (compatibility)
|
||||
|
||||
Setting key: hashAlg
|
||||
|
||||
@@ -4,6 +4,10 @@ import { createLiveSyncEventHub } from "@vrtmrz/livesync-commonlib/context";
|
||||
export const EVENT_PLUGIN_LOADED = "plugin-loaded";
|
||||
export const EVENT_PLUGIN_UNLOADED = "plugin-unloaded";
|
||||
export const EVENT_FILE_SAVED = "file-saved";
|
||||
/** A received note could not be reflected from the local database into the Vault. */
|
||||
export const EVENT_VAULT_REFLECTION_FAILED = "vault-reflection-failed";
|
||||
/** Device-local SyncGuard preferences changed without changing replication settings. */
|
||||
export const EVENT_SYNC_GUARD_PREFERENCES_CHANGED = "sync-guard-preferences-changed";
|
||||
export const EVENT_LEAF_ACTIVE_CHANGED = "leaf-active-changed";
|
||||
|
||||
export const EVENT_REQUEST_OPEN_SETTINGS = "request-open-settings";
|
||||
@@ -28,6 +32,8 @@ declare global {
|
||||
interface LSEvents {
|
||||
[EVENT_PLUGIN_LOADED]: undefined;
|
||||
[EVENT_PLUGIN_UNLOADED]: undefined;
|
||||
[EVENT_VAULT_REFLECTION_FAILED]: undefined;
|
||||
[EVENT_SYNC_GUARD_PREFERENCES_CHANGED]: undefined;
|
||||
[EVENT_REQUEST_OPEN_PLUGIN_SYNC_DIALOG]: undefined;
|
||||
[EVENT_REQUEST_OPEN_SETTINGS]: undefined;
|
||||
[EVENT_REQUEST_RELOAD_SETTING_TAB]: undefined;
|
||||
|
||||
@@ -7,6 +7,14 @@
|
||||
* remove it from this map in the same change.
|
||||
*/
|
||||
export const liveSyncProvisionalEnglishMessages = {
|
||||
"Synchronisation protection": "Synchronisation protection",
|
||||
"Synchronising your Vault": "Synchronising your Vault",
|
||||
"Receiving changes from the server…": "Receiving changes from the server…",
|
||||
"Applying received changes to local notes…": "Applying received changes to local notes…",
|
||||
"Synchronisation was interrupted. You can continue locally, but some notes may be out of date.":
|
||||
"Synchronisation was interrupted. You can continue locally, but some notes may be out of date.",
|
||||
"At least ${COUNT} changed files received": "At least ${COUNT} changed files received",
|
||||
"Continue without waiting": "Continue without waiting",
|
||||
"Hidden File Sync is disabled on this device. Enable it in Sync Settings before using Automatic or initialising hidden files.":
|
||||
"Hidden File Sync is disabled on this device. Enable it in Sync Settings before using Automatic or initialising hidden files.",
|
||||
"Hidden File Sync initialisation was not started.": "Hidden File Sync initialisation was not started.",
|
||||
|
||||
@@ -51,6 +51,8 @@ import { createFileReflectionProvenance } from "./serviceModules/FileReflectionP
|
||||
import { useInteractiveConflictResolutionFeature } from "./serviceFeatures/interactiveConflictResolution";
|
||||
import { ConflictResolveModal } from "./modules/features/InteractiveConflictResolving/ConflictResolveModal.ts";
|
||||
import { useObsidianReplicationRibbonFeature } from "./serviceFeatures/obsidianReplicationRibbon.ts";
|
||||
import { useSyncGuardFeature } from "./serviceFeatures/syncGuard";
|
||||
import { SyncGuardOverlay } from "./serviceFeatures/syncGuard/SyncGuardOverlay";
|
||||
import { useStartupLifecycleFeature } from "./serviceFeatures/startupLifecycle";
|
||||
import { useJoinedDevicesFeature } from "./serviceFeatures/useJoinedDevices";
|
||||
import { useDatabaseDiagnosticsFeature } from "./serviceFeatures/useDatabaseDiagnostics";
|
||||
@@ -198,6 +200,7 @@ export default class ObsidianLiveSyncPlugin extends Plugin {
|
||||
useP2PReplicatorCommands(core, replicator);
|
||||
useP2PReplicatorUI(core, core, replicator, createInteractiveP2PReplication(replicator));
|
||||
useObsidianReplicationRibbonFeature(core);
|
||||
useSyncGuardFeature(core, (onBypass) => new SyncGuardOverlay(this.app.workspace.containerEl, onBypass));
|
||||
useDatabaseDiagnosticsFeature(core);
|
||||
useJoinedDevicesFeature(core, serviceHub.context.noticeGroups);
|
||||
useRemoteConfiguration(core);
|
||||
|
||||
@@ -35,6 +35,8 @@ export function createGeneralSettingSpecGroups({
|
||||
},
|
||||
{ key: "showStatusOnStatusbar", control: { type: "toggle" } },
|
||||
{ key: "hideFileWarningNotice", control: { type: "toggle" } },
|
||||
{ key: "syncGuardEnabled", control: { type: "toggle" } },
|
||||
{ key: "syncGuardMinimumFiles", control: { type: "number", min: 1 } },
|
||||
{
|
||||
key: "networkWarningStyle",
|
||||
control: {
|
||||
|
||||
+43
-2
@@ -1,6 +1,7 @@
|
||||
import { MemoryValueStore } from "@vrtmrz/value-store";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { DEFAULT_SETTINGS } from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
import { DEFAULT_SYNC_GUARD_SETTINGS, type SyncGuardSettings } from "@/modules/services/ObsidianSettingService.ts";
|
||||
import type { SettingDefinitionGroup, SettingDefinitionItem, SettingDefinitionPage } from "obsidian";
|
||||
import type { PageFunctions } from "./SettingPane.ts";
|
||||
|
||||
@@ -152,6 +153,7 @@ type SettingsTabOptions = {
|
||||
};
|
||||
|
||||
function createSettingsTab(options: SettingsTabOptions = {}): ObsidianLiveSyncSettingTab {
|
||||
const syncGuardSettings: SyncGuardSettings = { ...DEFAULT_SYNC_GUARD_SETTINGS };
|
||||
const core = {
|
||||
settings: { ...DEFAULT_SETTINGS, useAdvancedMode: true },
|
||||
confirm: {
|
||||
@@ -160,6 +162,10 @@ function createSettingsTab(options: SettingsTabOptions = {}): ObsidianLiveSyncSe
|
||||
services: {
|
||||
setting: {
|
||||
deviceValues: new MemoryValueStore(),
|
||||
syncGuardSettings,
|
||||
setSyncGuardSetting: vi.fn(async (key: keyof SyncGuardSettings, value: boolean | number) => {
|
||||
Object.assign(syncGuardSettings, { [key]: value });
|
||||
}),
|
||||
getDeviceAndVaultName: vi.fn(() => ""),
|
||||
saveSettingData: vi.fn(async () => undefined),
|
||||
},
|
||||
@@ -179,8 +185,8 @@ function createSettingsTab(options: SettingsTabOptions = {}): ObsidianLiveSyncSe
|
||||
};
|
||||
const tab = new ObsidianLiveSyncSettingTab({} as never, plugin as never);
|
||||
Object.assign(tab, {
|
||||
_editingSettings: { ...DEFAULT_SETTINGS, useAdvancedMode: true },
|
||||
initialSettings: { ...DEFAULT_SETTINGS, useAdvancedMode: true },
|
||||
_editingSettings: { ...DEFAULT_SETTINGS, ...syncGuardSettings, useAdvancedMode: true },
|
||||
initialSettings: { ...DEFAULT_SETTINGS, ...syncGuardSettings, useAdvancedMode: true },
|
||||
});
|
||||
return tab;
|
||||
}
|
||||
@@ -335,6 +341,8 @@ describe("ObsidianLiveSyncSettingTab native page lifecycle", () => {
|
||||
"showOnlyIconsOnEditor",
|
||||
"showStatusOnStatusbar",
|
||||
"hideFileWarningNotice",
|
||||
"syncGuardEnabled",
|
||||
"syncGuardMinimumFiles",
|
||||
"networkWarningStyle",
|
||||
]);
|
||||
expect(
|
||||
@@ -345,6 +353,39 @@ describe("ObsidianLiveSyncSettingTab native page lifecycle", () => {
|
||||
).toEqual(["useAdvancedMode", "usePowerUserMode", "useEdgeCaseMode"]);
|
||||
});
|
||||
|
||||
it("persists declarative SyncGuard controls through Obsidian's local settings service", async () => {
|
||||
const tab = createSettingsTab();
|
||||
const setting = tab.services.setting as typeof tab.services.setting & {
|
||||
setSyncGuardSetting: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
const initialCoreSettings = { ...tab.core.settings };
|
||||
|
||||
await tab.setControlValue("syncGuardEnabled", true);
|
||||
await tab.setControlValue("syncGuardMinimumFiles", 6);
|
||||
|
||||
expect(setting.setSyncGuardSetting).toHaveBeenNthCalledWith(1, "syncGuardEnabled", true);
|
||||
expect(setting.setSyncGuardSetting).toHaveBeenNthCalledWith(2, "syncGuardMinimumFiles", 6);
|
||||
expect(tab.getControlValue("syncGuardEnabled")).toBe(true);
|
||||
expect(tab.getControlValue("syncGuardMinimumFiles")).toBe(6);
|
||||
expect(tab.core.settings).toEqual(initialCoreSettings);
|
||||
expect(setting.saveSettingData).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("refreshes active settings when local SyncGuard preferences change elsewhere", async () => {
|
||||
const tab = createSettingsTab();
|
||||
tab.isShown = true;
|
||||
const refresh = vi.spyOn(tab, "requestPageRefresh").mockImplementation(() => undefined);
|
||||
const setting = tab.services.setting as typeof tab.services.setting & { syncGuardSettings: SyncGuardSettings };
|
||||
setting.syncGuardSettings.syncGuardEnabled = true;
|
||||
setting.syncGuardSettings.syncGuardMinimumFiles = 13;
|
||||
|
||||
await tab.requestReload();
|
||||
|
||||
expect(tab.editingSettings.syncGuardEnabled).toBe(true);
|
||||
expect(tab.editingSettings.syncGuardMinimumFiles).toBe(13);
|
||||
expect(refresh).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("omits the old Setup child page and keeps standard General and Advanced pages native", () => {
|
||||
const tab = createSettingsTab();
|
||||
const pages = collectPages(tab.getSettingDefinitions());
|
||||
|
||||
@@ -77,6 +77,11 @@ import { createExtraMenuSettingSpecGroup, createGeneralSettingSpecGroups } from
|
||||
import { SetupManager } from "@/modules/features/SetupManager.ts";
|
||||
import { isP2PMainRemote } from "@/common/remoteConfiguration.ts";
|
||||
import { withOwnedRemoteResource } from "@/common/ownedRemoteResource.ts";
|
||||
import {
|
||||
getSyncGuardSettings,
|
||||
isSyncGuardSettingKey,
|
||||
setSyncGuardSetting,
|
||||
} from "@/modules/services/ObsidianSettingService.ts";
|
||||
|
||||
// For creating a document
|
||||
// const toc = new Set<string>();
|
||||
@@ -138,6 +143,7 @@ export class ObsidianLiveSyncSettingTab extends PluginSettingTab {
|
||||
if (
|
||||
this.isReconstructionRequiredSetting(k) ||
|
||||
this.isConfigurationEncryptionSetting(k) ||
|
||||
isSyncGuardSettingKey(k) ||
|
||||
k in OnDialogSettingsDefault
|
||||
) {
|
||||
// //@ts-ignore
|
||||
@@ -157,6 +163,10 @@ export class ObsidianLiveSyncSettingTab extends PluginSettingTab {
|
||||
(e) => this.isDirty(e) && !this.isReconstructionRequiredSetting(e)
|
||||
);
|
||||
await this.saveConfigurationEncryptionIfDirty(changedKeys);
|
||||
for (const key of changedKeys.filter(isSyncGuardSettingKey)) {
|
||||
await setSyncGuardSetting(this.services.setting, key, this.editingSettings[key]);
|
||||
this.copySettingValue(this.initialSettings, this.editingSettings, key);
|
||||
}
|
||||
this.applySetting(changedKeys);
|
||||
this.reloadAllSettings();
|
||||
}
|
||||
@@ -177,6 +187,11 @@ export class ObsidianLiveSyncSettingTab extends PluginSettingTab {
|
||||
await this.saveConfigurationEncryptionIfDirty(appliedKeys);
|
||||
for (const k of appliedKeys) {
|
||||
if (this.isConfigurationEncryptionSetting(k)) continue;
|
||||
if (isSyncGuardSettingKey(k)) {
|
||||
await setSyncGuardSetting(this.services.setting, k, this.editingSettings[k]);
|
||||
this.copySettingValue(this.initialSettings, this.editingSettings, k);
|
||||
continue;
|
||||
}
|
||||
if (k in OnDialogSettingsDefault) {
|
||||
await this.saveLocalSetting(k as keyof OnDialogSettings);
|
||||
this.copySettingValue(this.initialSettings, this.editingSettings, k);
|
||||
@@ -307,7 +322,7 @@ export class ObsidianLiveSyncSettingTab extends PluginSettingTab {
|
||||
ret.configPassphrase = this.localConfigurationPassphrase;
|
||||
ret.preset = "";
|
||||
ret.deviceAndVaultName = this.services.setting.getDeviceAndVaultName();
|
||||
return ret;
|
||||
return { ...ret, ...getSyncGuardSettings(this.services.setting) };
|
||||
}
|
||||
computeAllLocalSettings(): Partial<OnDialogSettings> {
|
||||
const syncMode = this.editingSettings?.liveSync
|
||||
@@ -375,7 +390,7 @@ export class ObsidianLiveSyncSettingTab extends PluginSettingTab {
|
||||
return;
|
||||
}
|
||||
const localSetting = this.reloadAllLocalSettings();
|
||||
if (key in this.core.settings) {
|
||||
if (isSyncGuardSettingKey(key) || key in this.core.settings) {
|
||||
if (key in localSetting) {
|
||||
this.copySettingValue(this.initialSettings, localSetting, key);
|
||||
this.copySettingValue(this.editingSettings, localSetting, key);
|
||||
@@ -567,25 +582,25 @@ export class ObsidianLiveSyncSettingTab extends PluginSettingTab {
|
||||
const nativeTabIsShown =
|
||||
this.supportsDeclarativeSettings() && this.containerEl !== undefined && this.containerEl.isShown();
|
||||
if (this.isShown || nativeTabIsShown) {
|
||||
const newConf = this.core.settings;
|
||||
const keys = Object.keys(newConf) as (keyof ObsidianLiveSyncSettings)[];
|
||||
const newConf = { ...this.core.settings, ...getSyncGuardSettings(this.services.setting) };
|
||||
const keys = Object.keys(newConf) as AllSettingItemKey[];
|
||||
let hasLoaded = false;
|
||||
let catalogueVisibilityChanged = false;
|
||||
for (const k of keys) {
|
||||
if (isObjectDifferent(newConf[k], this.initialSettings?.[k])) {
|
||||
if (isObjectDifferent(Reflect.get(newConf, k), this.initialSettings?.[k])) {
|
||||
// Something has changed
|
||||
if (this.isDirty(k as AllSettingItemKey)) {
|
||||
if (this.isDirty(k)) {
|
||||
// And modified.
|
||||
this.core.confirm.askInPopup(
|
||||
`config-reloaded-${k}`,
|
||||
$msg("obsidianLiveSyncSettingTab.msgSettingModified", {
|
||||
setting: getConfName(k as AllSettingItemKey),
|
||||
setting: getConfName(k),
|
||||
}),
|
||||
(anchor) => {
|
||||
anchor.text = $msg("obsidianLiveSyncSettingTab.optionHere");
|
||||
anchor.addEventListener("click", () => {
|
||||
this.refreshSetting(k as AllSettingItemKey);
|
||||
if (this.changesPageCatalogue(k as AllSettingItemKey)) {
|
||||
this.refreshSetting(k);
|
||||
if (this.changesPageCatalogue(k)) {
|
||||
this.requestCatalogueRefresh();
|
||||
} else {
|
||||
this.requestPageRefresh();
|
||||
@@ -595,12 +610,12 @@ export class ObsidianLiveSyncSettingTab extends PluginSettingTab {
|
||||
);
|
||||
} else {
|
||||
// not modified
|
||||
this.refreshSetting(k as AllSettingItemKey);
|
||||
this.refreshSetting(k);
|
||||
if (k in OnDialogSettingsDefault) {
|
||||
continue;
|
||||
}
|
||||
hasLoaded = true;
|
||||
if (this.changesPageCatalogue(k as AllSettingItemKey)) {
|
||||
if (this.changesPageCatalogue(k)) {
|
||||
catalogueVisibilityChanged = true;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -142,6 +142,8 @@ describe("General setting specifications", () => {
|
||||
"showOnlyIconsOnEditor",
|
||||
"showStatusOnStatusbar",
|
||||
"hideFileWarningNotice",
|
||||
"syncGuardEnabled",
|
||||
"syncGuardMinimumFiles",
|
||||
"networkWarningStyle",
|
||||
"lessInformationInLog",
|
||||
"showVerboseLog",
|
||||
|
||||
@@ -1,25 +1,50 @@
|
||||
export {
|
||||
AllSettingDefault,
|
||||
OnDialogSettingsDefault,
|
||||
SettingInformation,
|
||||
} from "@vrtmrz/livesync-commonlib/compat/common/settingConstants";
|
||||
export type {
|
||||
AllSettings,
|
||||
AllSettingItemKey,
|
||||
AllStringItemKey,
|
||||
AllNumericItemKey,
|
||||
AllBooleanItemKey,
|
||||
OnDialogSettings,
|
||||
ValueOf,
|
||||
} from "@vrtmrz/livesync-commonlib/compat/common/settingConstants";
|
||||
export { OnDialogSettingsDefault } from "@vrtmrz/livesync-commonlib/compat/common/settingConstants";
|
||||
export type { OnDialogSettings } from "@vrtmrz/livesync-commonlib/compat/common/settingConstants";
|
||||
|
||||
import {
|
||||
AllSettingDefault as CommonlibAllSettingDefault,
|
||||
SettingInformation as CommonlibSettingInformation,
|
||||
getConfig as getCommonlibConfig,
|
||||
getConfName as getCommonlibConfName,
|
||||
type AllSettingItemKey,
|
||||
type AllSettings as CommonlibAllSettings,
|
||||
type AllSettingItemKey as CommonlibAllSettingItemKey,
|
||||
type AllStringItemKey as CommonlibAllStringItemKey,
|
||||
type AllNumericItemKey as CommonlibAllNumericItemKey,
|
||||
type AllBooleanItemKey as CommonlibAllBooleanItemKey,
|
||||
} from "@vrtmrz/livesync-commonlib/compat/common/settingConstants";
|
||||
import type { ConfigurationItem } from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
import type { MessageTranslator } from "@vrtmrz/livesync-commonlib/context";
|
||||
import { translateLiveSyncMessage } from "@/common/translation";
|
||||
import {
|
||||
DEFAULT_SYNC_GUARD_SETTINGS,
|
||||
isSyncGuardSettingKey,
|
||||
type SyncGuardSettingKey,
|
||||
type SyncGuardSettings,
|
||||
} from "@/modules/services/ObsidianSettingService";
|
||||
|
||||
export type AllSettings = Omit<CommonlibAllSettings, SyncGuardSettingKey> & SyncGuardSettings;
|
||||
export type AllStringItemKey = CommonlibAllStringItemKey;
|
||||
export type AllNumericItemKey = Exclude<CommonlibAllNumericItemKey, SyncGuardSettingKey> | "syncGuardMinimumFiles";
|
||||
export type AllBooleanItemKey = Exclude<CommonlibAllBooleanItemKey, SyncGuardSettingKey> | "syncGuardEnabled";
|
||||
export type AllSettingItemKey = Exclude<CommonlibAllSettingItemKey, SyncGuardSettingKey> | SyncGuardSettingKey;
|
||||
export type ValueOf<T extends AllSettingItemKey> = AllSettings[T];
|
||||
|
||||
const syncGuardSettingInformation: Record<SyncGuardSettingKey, ConfigurationItem> = {
|
||||
syncGuardEnabled: {
|
||||
name: "Enable SyncGuard",
|
||||
desc: "Enable device-local SyncGuard protection during synchronisation.",
|
||||
},
|
||||
syncGuardMinimumFiles: {
|
||||
name: "SyncGuard minimum files",
|
||||
desc: "Minimum number of Vault files required for SyncGuard protection to apply.",
|
||||
},
|
||||
};
|
||||
|
||||
export const AllSettingDefault = { ...CommonlibAllSettingDefault, ...DEFAULT_SYNC_GUARD_SETTINGS };
|
||||
export const SettingInformation: Partial<Record<AllSettingItemKey, ConfigurationItem>> = {
|
||||
...CommonlibSettingInformation,
|
||||
...syncGuardSettingInformation,
|
||||
};
|
||||
|
||||
// Commonlib defaults `translate` to its English-only translator, so every caller which omits
|
||||
// it silently renders English regardless of `displayLanguage`. Default it to the LiveSync
|
||||
@@ -28,10 +53,12 @@ import { translateLiveSyncMessage } from "@/common/translation";
|
||||
|
||||
/** `getConfig` with the LiveSync catalogue applied by default. */
|
||||
export function getConfig(key: AllSettingItemKey, translate: MessageTranslator = translateLiveSyncMessage) {
|
||||
if (isSyncGuardSettingKey(key)) return syncGuardSettingInformation[key];
|
||||
return getCommonlibConfig(key, translate);
|
||||
}
|
||||
|
||||
/** `getConfName` with the LiveSync catalogue applied by default. See `getConfig`. */
|
||||
export function getConfName(key: AllSettingItemKey, translate: MessageTranslator = translateLiveSyncMessage) {
|
||||
if (isSyncGuardSettingKey(key)) return syncGuardSettingInformation[key].name;
|
||||
return getCommonlibConfName(key, translate);
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -58,6 +58,7 @@ type ReplicateResultProcessorServices = Pick<
|
||||
* database would be invalid.
|
||||
*/
|
||||
interface ReplicateResultProcessorContext {
|
||||
readonly onVaultReflectionFailure?: () => void;
|
||||
readonly currentSettings: () => ReplicateResultProcessorSettings;
|
||||
readonly getKeyValueDB: () => LiveSyncBaseCore["kvDB"];
|
||||
readonly getLocalDatabase: () => LiveSyncBaseCore["localDatabase"];
|
||||
@@ -87,6 +88,7 @@ export class ReplicateResultProcessor {
|
||||
Logger(e, LOG_LEVEL_VERBOSE);
|
||||
}
|
||||
private reportVaultReflectionFailure(entry: MetaEntry, cause?: unknown) {
|
||||
this.context.onVaultReflectionFailure?.();
|
||||
this.log(
|
||||
`Live replication could not reflect ${this.getPath(entry)} from the local database to the Vault; this path remains eligible for a later Vault scan.`,
|
||||
LOG_LEVEL_VERBOSE
|
||||
@@ -383,6 +385,7 @@ export class ReplicateResultProcessor {
|
||||
}
|
||||
// Enqueue the change
|
||||
this._queuedChanges.push(doc);
|
||||
this.reportStatus();
|
||||
this.updateProcessingActivity();
|
||||
this.triggerTakeSnapshot();
|
||||
this.triggerProcessQueue();
|
||||
@@ -491,6 +494,9 @@ export class ReplicateResultProcessor {
|
||||
} finally {
|
||||
// Remove from processing queue
|
||||
this._processingChanges = this._processingChanges.filter((e) => e !== change);
|
||||
// Queue visibility must not wait for persisting a recovery snapshot. Consumers
|
||||
// use this count to decide whether the reflected Vault is ready for interaction.
|
||||
this.reportStatus();
|
||||
try {
|
||||
if (this._queuedChanges.length === 0 && this._processingChanges.length === 0) {
|
||||
try {
|
||||
@@ -515,6 +521,8 @@ export class ReplicateResultProcessor {
|
||||
releaser = await this._semaphore.acquire();
|
||||
await this._applyToDatabase(doc);
|
||||
} catch (e) {
|
||||
// An exception during application must not be mistaken for a fully reflected Vault.
|
||||
this.context.onVaultReflectionFailure?.();
|
||||
this.log(`Error while processing replication result`, LOG_LEVEL_NOTICE);
|
||||
this.logError(e);
|
||||
} finally {
|
||||
@@ -548,6 +556,7 @@ export class ReplicateResultProcessor {
|
||||
: await this.localDatabase.getDBEntryFromMeta({ ...dbDoc }, false, true);
|
||||
if (!doc) {
|
||||
// Failed to gather content
|
||||
this.context.onVaultReflectionFailure?.();
|
||||
this.log(`Failed to gather content of ${docNote}`, LOG_LEVEL_NOTICE);
|
||||
return;
|
||||
}
|
||||
@@ -623,6 +632,7 @@ export class ReplicateResultProcessor {
|
||||
// Note that this is not a common case.
|
||||
return true;
|
||||
} else {
|
||||
this.context.onVaultReflectionFailure?.();
|
||||
this.log(
|
||||
`Failed to get existing document for ${path} (${shortenId(dbDoc._id)}, ${shortenRev(dbDoc._rev)}) `,
|
||||
LOG_LEVEL_NOTICE
|
||||
|
||||
@@ -37,6 +37,9 @@ function note(id: string): PouchDB.Core.ExistingDocument<EntryDoc> {
|
||||
}
|
||||
|
||||
type SetupOptions = {
|
||||
onVaultReflectionFailure?: () => void;
|
||||
getRaw?: (id: string) => Promise<unknown>;
|
||||
getDBEntryFromMeta?: (entry: object) => Promise<unknown>;
|
||||
applicationReady?: boolean;
|
||||
databaseReady?: boolean;
|
||||
maxMTimeForReflectEvents?: number;
|
||||
@@ -57,7 +60,7 @@ function setup(options: SetupOptions = {}) {
|
||||
const onCloseActiveReplication = vi.fn(async () => true);
|
||||
const isReady = vi.fn(() => options.applicationReady ?? true);
|
||||
const isValidPath = vi.fn(options.isValidPath ?? (() => true));
|
||||
const getDBEntryFromMeta = vi.fn(async (entry: object) => ({ ...entry, data: "x" }));
|
||||
const getDBEntryFromMeta = vi.fn(options.getDBEntryFromMeta ?? (async (entry: object) => ({ ...entry, data: "x" })));
|
||||
const localPhysicalDatabase = {
|
||||
...(options.databaseId ? { id: vi.fn(async () => options.databaseId) } : {}),
|
||||
} as PouchDB.Database<EntryDoc>;
|
||||
@@ -89,12 +92,14 @@ function setup(options: SetupOptions = {}) {
|
||||
if (options.localVersionInfo === undefined) throw { status: 404 };
|
||||
return options.localVersionInfo;
|
||||
}
|
||||
if (options.getRaw) return await options.getRaw(id);
|
||||
return { _id: id, _rev: "1-test" };
|
||||
}),
|
||||
getDBEntryFromMeta,
|
||||
},
|
||||
};
|
||||
const processor = new ReplicateResultProcessor({
|
||||
onVaultReflectionFailure: options.onVaultReflectionFailure,
|
||||
currentSettings: () => ({
|
||||
maxMTimeForReflectEvents: options.maxMTimeForReflectEvents ?? 0,
|
||||
suspendParseReplicationResult: false,
|
||||
@@ -117,12 +122,64 @@ function setup(options: SetupOptions = {}) {
|
||||
isValidPath,
|
||||
onCloseActiveReplication,
|
||||
processor,
|
||||
replicationResultCount: core.services.replication.replicationResultCount,
|
||||
processSynchroniseResult,
|
||||
runBoundedLocalApplicationActivity,
|
||||
};
|
||||
}
|
||||
|
||||
describe("ReplicateResultProcessor", () => {
|
||||
it("reports an IndexedDB read error as failed reflection", async () => {
|
||||
const onVaultReflectionFailure = vi.fn();
|
||||
const { processor, processSynchroniseResult } = setup({
|
||||
onVaultReflectionFailure,
|
||||
getRaw: async () => { throw new Error("IndexedDB read failed"); },
|
||||
});
|
||||
processor.enqueueAll([note("unreadable")]);
|
||||
await vi.waitFor(() => expect(onVaultReflectionFailure).toHaveBeenCalledOnce());
|
||||
expect(processSynchroniseResult).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("reports a thrown document reconstruction as failed reflection", async () => {
|
||||
const onVaultReflectionFailure = vi.fn();
|
||||
const { processor } = setup({
|
||||
onVaultReflectionFailure,
|
||||
getDBEntryFromMeta: async () => { throw new Error("Cannot read chunks"); },
|
||||
});
|
||||
processor.enqueueAll([note("missing-chunks")]);
|
||||
await vi.waitFor(() => expect(onVaultReflectionFailure).toHaveBeenCalledOnce());
|
||||
});
|
||||
|
||||
it("publishes pending received documents synchronously even if snapshot persistence stalls", () => {
|
||||
const { processor, replicationResultCount, processSynchroniseResult } = setup({
|
||||
applicationReady: false,
|
||||
setSnapshot: async () => new Promise<never>(() => undefined),
|
||||
});
|
||||
processor.enqueueAll([note("pending")]);
|
||||
expect(replicationResultCount.value).toBe(1);
|
||||
expect(processSynchroniseResult).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("clears the received-document counter before awaiting an unresponsive snapshot write", async () => {
|
||||
const { processor, replicationResultCount, processSynchroniseResult } = setup({
|
||||
setSnapshot: async () => new Promise<never>(() => undefined),
|
||||
});
|
||||
processor.enqueueAll([note("pending")]);
|
||||
expect(replicationResultCount.value).toBe(1);
|
||||
await vi.waitFor(() => expect(processSynchroniseResult).toHaveBeenCalledOnce());
|
||||
await vi.waitFor(() => expect(replicationResultCount.value).toBe(0));
|
||||
});
|
||||
|
||||
it("reports a failed Vault reflection instead of treating an empty queue as success", async () => {
|
||||
const onVaultReflectionFailure = vi.fn();
|
||||
const { processor, processSynchroniseResult } = setup({
|
||||
onVaultReflectionFailure,
|
||||
processSynchroniseResult: async () => false,
|
||||
});
|
||||
processor.enqueueAll([note("unreflected")]);
|
||||
await vi.waitFor(() => expect(processSynchroniseResult).toHaveBeenCalledTimes(1));
|
||||
await vi.waitFor(() => expect(onVaultReflectionFailure).toHaveBeenCalledTimes(1));
|
||||
});
|
||||
it("does not add a permanent application block when snapshot recovery fails", async () => {
|
||||
const { processor } = setup({
|
||||
getSnapshot: async () => {
|
||||
|
||||
@@ -4,7 +4,7 @@ import { UnresolvedErrorManager } from "@vrtmrz/livesync-commonlib/compat/servic
|
||||
import type { ServiceContext } from "@vrtmrz/livesync-commonlib/context";
|
||||
import { fireAndForget } from "octagonal-wheels/promises";
|
||||
import type { IMinimumLiveSyncCommands, LiveSyncBaseCore } from "@/LiveSyncBaseCore";
|
||||
import { EVENT_APPLICATION_READY } from "@/common/events";
|
||||
import { EVENT_APPLICATION_READY, EVENT_VAULT_REFLECTION_FAILED } from "@/common/events";
|
||||
import { createAutomaticReplicationTriggers } from "./automaticTriggers";
|
||||
import { createCentralCompatibilityRecovery } from "./centralCompatibilityRecovery";
|
||||
import { createOnlineReplicationPreflight, createSecuritySeedPreflight } from "./preflight";
|
||||
@@ -38,6 +38,7 @@ export function useReplicationFeature<TContext extends ServiceContext, TCommands
|
||||
? services.replicator
|
||||
: undefined;
|
||||
const resultProcessor = new ReplicateResultProcessor({
|
||||
onVaultReflectionFailure: () => services.context.events.emitEvent(EVENT_VAULT_REFLECTION_FAILED),
|
||||
currentSettings: () => services.setting.currentSettings(),
|
||||
getKeyValueDB: () => services.keyValueDB.kvDB,
|
||||
getLocalDatabase: () => core.localDatabase,
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
import type { SyncGuardPresentation } from "./SyncGuardState";
|
||||
import { $msg } from "@/common/translation";
|
||||
|
||||
/** Obsidian-owned presentation: blocks workspace input but does not pause replication or other dialogues. */
|
||||
export class SyncGuardOverlay {
|
||||
private root?: HTMLDivElement;
|
||||
private stage?: HTMLParagraphElement;
|
||||
private count?: HTMLParagraphElement;
|
||||
private bypassButton?: HTMLButtonElement;
|
||||
private previousFocus?: HTMLElement;
|
||||
|
||||
constructor(
|
||||
private readonly workspace: HTMLElement,
|
||||
private readonly onBypass: () => void
|
||||
) {}
|
||||
|
||||
update({ phase, receivedFiles }: SyncGuardPresentation): void {
|
||||
if (phase === "idle" || phase === "observing") {
|
||||
this.close();
|
||||
return;
|
||||
}
|
||||
this.open();
|
||||
if (this.stage) {
|
||||
this.stage.textContent =
|
||||
phase === "interrupted"
|
||||
? $msg("Synchronisation was interrupted. You can continue locally, but some notes may be out of date.")
|
||||
: phase === "transferring"
|
||||
? $msg("Receiving changes from the server…")
|
||||
: $msg("Applying received changes to local notes…");
|
||||
}
|
||||
if (this.count) {
|
||||
this.count.textContent = $msg("At least ${COUNT} changed files received", { COUNT: `${receivedFiles}` });
|
||||
}
|
||||
}
|
||||
|
||||
private open(): void {
|
||||
if (this.root) return;
|
||||
const doc = this.workspace.ownerDocument;
|
||||
const root = this.workspace.createDiv({ cls: "sls-sync-guard" });
|
||||
root.setAttribute("role", "region");
|
||||
root.setAttribute("aria-label", $msg("Synchronisation protection"));
|
||||
const panel = root.createEl("section", { cls: "sls-sync-guard-panel" });
|
||||
const title = panel.createEl("h2");
|
||||
title.textContent = $msg("Synchronising your Vault");
|
||||
const stage = panel.createEl("p", { cls: "sls-sync-guard-stage" });
|
||||
stage.setAttribute("aria-live", "polite");
|
||||
const count = panel.createEl("p", { cls: "sls-sync-guard-count" });
|
||||
const bypass = panel.createEl("button", { cls: "mod-cta sls-sync-guard-bypass" });
|
||||
bypass.type = "button";
|
||||
bypass.textContent = $msg("Continue without waiting");
|
||||
bypass.addEventListener("click", this.onBypass);
|
||||
this.root = root;
|
||||
this.stage = stage;
|
||||
this.count = count;
|
||||
this.bypassButton = bypass;
|
||||
const focused = doc.activeElement;
|
||||
const modalFocused = focused instanceof Element && focused.closest(".modal-container") !== null;
|
||||
this.previousFocus = !modalFocused && focused instanceof HTMLElement ? focused : undefined;
|
||||
doc.addEventListener("keydown", this.blockKeyboard, true);
|
||||
// Mobile input methods, dictation, and paste may mutate an editor without a keydown.
|
||||
doc.addEventListener("beforeinput", this.blockEditorInput, true);
|
||||
doc.addEventListener("paste", this.blockEditorInput, true);
|
||||
doc.addEventListener("drop", this.blockEditorInput, true);
|
||||
// Do not steal focus from an Obsidian conflict or setup dialogue which was already open.
|
||||
if (!modalFocused) bypass.focus();
|
||||
}
|
||||
|
||||
/** Prevent editor shortcuts while allowing our own controls and Obsidian's conflict/setup dialogues. */
|
||||
private readonly blockKeyboard = (event: KeyboardEvent): void => {
|
||||
if (!this.root) return;
|
||||
const target = event.target;
|
||||
if (target instanceof Element && target.closest(".modal-container")) return;
|
||||
if (
|
||||
target instanceof Node &&
|
||||
this.root.contains(target) &&
|
||||
!event.ctrlKey &&
|
||||
!event.altKey &&
|
||||
!event.metaKey &&
|
||||
(event.key === "Enter" || event.key === " ")
|
||||
) {
|
||||
return;
|
||||
}
|
||||
// The guard has a single focusable control. Keep Tab/Shift+Tab inside it;
|
||||
// otherwise focus could move behind the overlay with no keyboard route back.
|
||||
event.preventDefault();
|
||||
event.stopImmediatePropagation();
|
||||
if (event.key === "Tab") this.bypassButton?.focus();
|
||||
};
|
||||
|
||||
private readonly blockEditorInput = (event: Event): void => {
|
||||
if (!this.root) return;
|
||||
const target = event.target;
|
||||
if (target instanceof Element && target.closest(".modal-container")) return;
|
||||
if (target instanceof Node && this.root.contains(target)) return;
|
||||
event.preventDefault();
|
||||
event.stopImmediatePropagation();
|
||||
};
|
||||
|
||||
close(): void {
|
||||
if (!this.root) return;
|
||||
const doc = this.workspace.ownerDocument;
|
||||
const focused = doc.activeElement;
|
||||
const ownsFocus = focused instanceof Node && this.root.contains(focused);
|
||||
doc.removeEventListener("keydown", this.blockKeyboard, true);
|
||||
doc.removeEventListener("beforeinput", this.blockEditorInput, true);
|
||||
doc.removeEventListener("paste", this.blockEditorInput, true);
|
||||
doc.removeEventListener("drop", this.blockEditorInput, true);
|
||||
this.root.remove();
|
||||
this.root = undefined;
|
||||
this.stage = undefined;
|
||||
this.count = undefined;
|
||||
this.bypassButton = undefined;
|
||||
// A newer dialogue may own focus now. Never move it back to an old editor.
|
||||
if (ownsFocus && this.previousFocus?.isConnected) this.previousFocus.focus();
|
||||
this.previousFocus = undefined;
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
this.close();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,215 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { SyncGuardOverlay } from "./SyncGuardOverlay";
|
||||
|
||||
class FakeDocument {
|
||||
activeElement: FakeElement | undefined;
|
||||
listeners = new Map<string, (event: KeyboardEvent) => void>();
|
||||
|
||||
addEventListener(type: string, listener: (event: KeyboardEvent) => void): void {
|
||||
this.listeners.set(type, listener);
|
||||
}
|
||||
|
||||
removeEventListener(type: string): void {
|
||||
this.listeners.delete(type);
|
||||
}
|
||||
}
|
||||
|
||||
class FakeElement {
|
||||
readonly children: FakeElement[] = [];
|
||||
readonly handlers = new Map<string, () => void>();
|
||||
readonly attributes = new Map<string, string>();
|
||||
className = "";
|
||||
textContent = "";
|
||||
type = "";
|
||||
isConnected = true;
|
||||
parent: FakeElement | undefined;
|
||||
|
||||
constructor(
|
||||
readonly ownerDocument: FakeDocument,
|
||||
readonly tagName: string
|
||||
) {}
|
||||
|
||||
createEl(tagName: string, options?: { cls?: string }): FakeElement {
|
||||
return this.append(new FakeElement(this.ownerDocument, tagName), options);
|
||||
}
|
||||
|
||||
createDiv(options?: { cls?: string }): FakeElement {
|
||||
return this.createEl("div", options);
|
||||
}
|
||||
|
||||
private append(child: FakeElement, options?: { cls?: string }): FakeElement {
|
||||
child.className = options?.cls ?? "";
|
||||
child.parent = this;
|
||||
this.children.push(child);
|
||||
return child;
|
||||
}
|
||||
|
||||
setAttribute(name: string, value: string): void {
|
||||
this.attributes.set(name, value);
|
||||
}
|
||||
|
||||
addEventListener(name: string, callback: () => void): void {
|
||||
this.handlers.set(name, callback);
|
||||
}
|
||||
|
||||
contains(target: FakeElement): boolean {
|
||||
return target === this || this.children.some((child) => child.contains(target));
|
||||
}
|
||||
|
||||
closest(selector: string): FakeElement | null {
|
||||
if (selector !== ".modal-container") return null;
|
||||
return this.className === "modal-container" ? this : (this.parent?.closest(selector) ?? null);
|
||||
}
|
||||
|
||||
focus(): void {
|
||||
this.ownerDocument.activeElement = this;
|
||||
}
|
||||
|
||||
remove(): void {
|
||||
if (!this.parent) return;
|
||||
const index = this.parent.children.indexOf(this);
|
||||
if (index >= 0) this.parent.children.splice(index, 1);
|
||||
this.isConnected = false;
|
||||
}
|
||||
}
|
||||
|
||||
function setup() {
|
||||
vi.stubGlobal("HTMLElement", FakeElement);
|
||||
vi.stubGlobal("Element", FakeElement);
|
||||
vi.stubGlobal("Node", FakeElement);
|
||||
const doc = new FakeDocument();
|
||||
const workspace = new FakeElement(doc, "workspace");
|
||||
const editor = workspace.createEl("textarea");
|
||||
editor.focus();
|
||||
const bypass = vi.fn();
|
||||
const overlay = new SyncGuardOverlay(workspace as unknown as HTMLElement, bypass);
|
||||
return { overlay, doc, workspace, editor, bypass };
|
||||
}
|
||||
|
||||
describe("SyncGuardOverlay", () => {
|
||||
afterEach(() => vi.unstubAllGlobals());
|
||||
|
||||
it("covers only the workspace, exposes a keyboard-operable bypass, and restores focus", () => {
|
||||
const { overlay, doc, workspace, editor, bypass } = setup();
|
||||
overlay.update({ phase: "observing", receivedFiles: 0 });
|
||||
expect(workspace.children).toHaveLength(1);
|
||||
overlay.update({ phase: "transferring", receivedFiles: 20 });
|
||||
const root = workspace.children[1];
|
||||
const panel = root.children[0];
|
||||
const button = panel.children[3];
|
||||
expect(root.attributes.get("role")).toBe("region");
|
||||
expect(button.tagName).toBe("button");
|
||||
expect(button.type).toBe("button");
|
||||
expect(doc.activeElement).toBe(button);
|
||||
expect(panel.children[2].textContent).toContain("20 changed files");
|
||||
button.handlers.get("click")?.();
|
||||
expect(bypass).toHaveBeenCalledOnce();
|
||||
overlay.update({ phase: "idle", receivedFiles: 20 });
|
||||
expect(workspace.children).toHaveLength(1);
|
||||
expect(doc.activeElement).toBe(editor);
|
||||
expect(doc.listeners.size).toBe(0);
|
||||
});
|
||||
|
||||
it("prevents editor keyboard shortcuts while allowing navigation inside the guard", () => {
|
||||
const { overlay, doc, editor, workspace } = setup();
|
||||
overlay.update({ phase: "applying", receivedFiles: 25 });
|
||||
const button = workspace.children[1].children[0].children[3];
|
||||
const block = doc.listeners.get("keydown");
|
||||
const preventDefault = vi.fn();
|
||||
const stopImmediatePropagation = vi.fn();
|
||||
block?.({ target: editor, key: "x", preventDefault, stopImmediatePropagation } as unknown as KeyboardEvent);
|
||||
expect(preventDefault).toHaveBeenCalledOnce();
|
||||
expect(stopImmediatePropagation).toHaveBeenCalledOnce();
|
||||
block?.({ target: button, key: "Enter", preventDefault, stopImmediatePropagation } as unknown as KeyboardEvent);
|
||||
expect(preventDefault).toHaveBeenCalledOnce();
|
||||
overlay.dispose();
|
||||
expect(doc.listeners.size).toBe(0);
|
||||
});
|
||||
|
||||
it("presents a safe offline bypass when the catch-up is interrupted", () => {
|
||||
const { overlay, workspace } = setup();
|
||||
overlay.update({ phase: "interrupted", receivedFiles: 30 });
|
||||
const panel = workspace.children[1].children[0];
|
||||
expect(panel.children[1].textContent).toContain("some notes may be out of date");
|
||||
overlay.dispose();
|
||||
});
|
||||
|
||||
it("does not steal focus from an existing Obsidian dialogue", () => {
|
||||
const { overlay, doc, workspace } = setup();
|
||||
const modal = new FakeElement(doc, "div");
|
||||
modal.className = "modal-container";
|
||||
const modalInput = modal.createEl("input");
|
||||
modalInput.focus();
|
||||
overlay.update({ phase: "transferring", receivedFiles: 20 });
|
||||
expect(doc.activeElement).toBe(modalInput);
|
||||
const preventDefault = vi.fn();
|
||||
const stopImmediatePropagation = vi.fn();
|
||||
doc.listeners.get("keydown")?.({
|
||||
target: modalInput, key: "Escape", preventDefault, stopImmediatePropagation,
|
||||
} as unknown as KeyboardEvent);
|
||||
expect(preventDefault).not.toHaveBeenCalled();
|
||||
expect(stopImmediatePropagation).not.toHaveBeenCalled();
|
||||
overlay.dispose();
|
||||
expect(workspace.children).toHaveLength(1);
|
||||
expect(doc.activeElement).toBe(modalInput);
|
||||
});
|
||||
|
||||
it("does not restore editor focus if a dialogue opened while the guard was active", () => {
|
||||
const { overlay, doc } = setup();
|
||||
overlay.update({ phase: "transferring", receivedFiles: 20 });
|
||||
const modal = new FakeElement(doc, "div");
|
||||
modal.className = "modal-container";
|
||||
const input = modal.createEl("input");
|
||||
input.focus();
|
||||
overlay.update({ phase: "idle", receivedFiles: 20 });
|
||||
expect(doc.activeElement).toBe(input);
|
||||
});
|
||||
|
||||
it("keeps Tab and Shift+Tab on the bypass button rather than an editor behind the guard", () => {
|
||||
const { overlay, doc, workspace, editor } = setup();
|
||||
overlay.update({ phase: "transferring", receivedFiles: 20 });
|
||||
const button = workspace.children[1].children[0].children[3];
|
||||
for (const shiftKey of [false, true]) {
|
||||
const preventDefault = vi.fn();
|
||||
const stopImmediatePropagation = vi.fn();
|
||||
doc.listeners.get("keydown")?.({
|
||||
target: button, key: "Tab", shiftKey, preventDefault, stopImmediatePropagation,
|
||||
} as unknown as KeyboardEvent);
|
||||
expect(preventDefault).toHaveBeenCalledOnce();
|
||||
expect(stopImmediatePropagation).toHaveBeenCalledOnce();
|
||||
expect(doc.activeElement).toBe(button);
|
||||
}
|
||||
const preventDefault = vi.fn();
|
||||
const stopImmediatePropagation = vi.fn();
|
||||
doc.listeners.get("keydown")?.({
|
||||
target: editor, key: "Tab", preventDefault, stopImmediatePropagation,
|
||||
} as unknown as KeyboardEvent);
|
||||
expect(preventDefault).toHaveBeenCalledOnce();
|
||||
expect(doc.activeElement).toBe(button);
|
||||
overlay.dispose();
|
||||
});
|
||||
|
||||
it("blocks mobile beforeinput and paste into hidden editors but permits input in Obsidian dialogues", () => {
|
||||
const { overlay, doc, editor } = setup();
|
||||
overlay.update({ phase: "transferring", receivedFiles: 30 });
|
||||
const modal = new FakeElement(doc, "div");
|
||||
modal.className = "modal-container";
|
||||
const input = modal.createEl("input");
|
||||
for (const eventType of ["beforeinput", "paste", "drop"]) {
|
||||
const preventDefault = vi.fn();
|
||||
const stopImmediatePropagation = vi.fn();
|
||||
doc.listeners.get(eventType)?.({
|
||||
target: editor, preventDefault, stopImmediatePropagation,
|
||||
} as unknown as KeyboardEvent);
|
||||
expect(preventDefault).toHaveBeenCalledOnce();
|
||||
expect(stopImmediatePropagation).toHaveBeenCalledOnce();
|
||||
|
||||
doc.listeners.get(eventType)?.({
|
||||
target: input, preventDefault, stopImmediatePropagation,
|
||||
} as unknown as KeyboardEvent);
|
||||
expect(preventDefault).toHaveBeenCalledOnce();
|
||||
}
|
||||
overlay.dispose();
|
||||
expect(doc.listeners.size).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,153 @@
|
||||
/** The guard observes catch-up work without changing replication or Vault writes. */
|
||||
export type SyncGuardPhase = "idle" | "observing" | "transferring" | "applying" | "interrupted";
|
||||
|
||||
export interface SyncGuardPresentation {
|
||||
phase: SyncGuardPhase;
|
||||
receivedFiles: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* One resume-triggered CouchDB catch-up attempt. Small transfers never lock the workspace.
|
||||
* Network completion alone does not unlock it: received documents still need reflecting to the Vault.
|
||||
*/
|
||||
export class SyncGuardState {
|
||||
private phase: SyncGuardPhase = "idle";
|
||||
private receivedIds = new Set<string>();
|
||||
private minimumFiles = 20;
|
||||
private catchUpActive = false;
|
||||
private observedCompletion = false;
|
||||
private hadReflectionFailure = false;
|
||||
private bypassed = false;
|
||||
private quiescentSince: number | undefined;
|
||||
|
||||
constructor(
|
||||
private readonly onChange: (presentation: SyncGuardPresentation) => void,
|
||||
private readonly quietPeriodMs = 2_000
|
||||
) {}
|
||||
|
||||
get presentation(): SyncGuardPresentation {
|
||||
return { phase: this.phase, receivedFiles: this.receivedIds.size };
|
||||
}
|
||||
|
||||
get wasBypassed(): boolean {
|
||||
return this.bypassed;
|
||||
}
|
||||
|
||||
get isCatchUpActive(): boolean {
|
||||
return this.catchUpActive;
|
||||
}
|
||||
|
||||
/** Begins a fresh, initially non-blocking observation window on app resume. */
|
||||
resume(enabled: boolean, minimumFiles: number): void {
|
||||
this.minimumFiles = Number.isFinite(minimumFiles) ? Math.max(1, Math.floor(minimumFiles)) : 20;
|
||||
this.receivedIds.clear();
|
||||
this.catchUpActive = false;
|
||||
this.observedCompletion = false;
|
||||
this.hadReflectionFailure = false;
|
||||
this.bypassed = false;
|
||||
this.quiescentSince = undefined;
|
||||
this.setPhase(enabled ? "observing" : "idle");
|
||||
}
|
||||
|
||||
/** A changed setting takes effect during observation, without cancelling an active protection. */
|
||||
updateThreshold(minimumFiles: number): void {
|
||||
this.minimumFiles = Number.isFinite(minimumFiles) ? Math.max(1, Math.floor(minimumFiles)) : 20;
|
||||
if (this.phase === "observing" && this.catchUpActive && this.receivedIds.size >= this.minimumFiles) {
|
||||
this.setPhase("transferring");
|
||||
}
|
||||
}
|
||||
|
||||
/** The CouchDB catch-up phase ends before continuous replication and Vault reflection finish. */
|
||||
catchUpActivityChanged(active: boolean, pendingCount: number, now: number): void {
|
||||
if (this.catchUpActive === active) return;
|
||||
// A manual bypass lasts only for this catch-up generation. Even while the
|
||||
// overlay is closed, observe its terminal transition so that a later
|
||||
// reconnect can protect the workspace again.
|
||||
if (this.bypassed) {
|
||||
this.catchUpActive = active;
|
||||
if (!active) this.bypassed = false;
|
||||
return;
|
||||
}
|
||||
if (this.phase === "idle") return;
|
||||
if (active) {
|
||||
// A retry or a second pull can begin while the Vault is still applying an earlier batch.
|
||||
// Do not let the previous successful completion unlock an active newer transfer.
|
||||
this.catchUpActive = true;
|
||||
this.observedCompletion = false;
|
||||
this.quiescentSince = undefined;
|
||||
if (this.isBlocking()) this.setPhase("transferring");
|
||||
return;
|
||||
}
|
||||
this.catchUpActive = false;
|
||||
if (!this.isBlocking()) {
|
||||
this.setPhase("idle");
|
||||
} else if (!this.observedCompletion || this.hadReflectionFailure) {
|
||||
this.setPhase("interrupted");
|
||||
} else {
|
||||
this.setPhase("applying");
|
||||
this.tick(pendingCount, now);
|
||||
}
|
||||
}
|
||||
|
||||
/** Only the catch-up → continuous transition proves that the initial pull succeeded. */
|
||||
catchUpSucceeded(): void {
|
||||
if (this.phase === "idle" || this.bypassed || !this.catchUpActive) return;
|
||||
this.observedCompletion = true;
|
||||
}
|
||||
|
||||
/** A network-complete batch is not a safe unlock when reflecting a received note failed. */
|
||||
reflectionFailed(): void {
|
||||
if (this.phase === "idle" || this.bypassed) return;
|
||||
this.hadReflectionFailure = true;
|
||||
if (this.phase === "applying") this.setPhase("interrupted");
|
||||
}
|
||||
|
||||
/** Count distinct incoming note Metadata, not Chunks or repeated revisions of a note. */
|
||||
receivedDocuments(ids: readonly string[]): void {
|
||||
if (!this.catchUpActive || this.bypassed || this.phase === "idle") return;
|
||||
const wasBlocking = this.isBlocking();
|
||||
const previousCount = this.receivedIds.size;
|
||||
for (const id of ids) {
|
||||
if (id) this.receivedIds.add(id);
|
||||
}
|
||||
if (!wasBlocking && this.receivedIds.size >= this.minimumFiles) this.setPhase("transferring");
|
||||
else if (wasBlocking && this.receivedIds.size !== previousCount) this.onChange(this.presentation);
|
||||
}
|
||||
|
||||
/** Unlock only after successful transfer and a stable empty local application queue. */
|
||||
tick(pendingCount: number, now: number): void {
|
||||
if (this.phase !== "applying") return;
|
||||
if (pendingCount > 0) {
|
||||
this.quiescentSince = undefined;
|
||||
return;
|
||||
}
|
||||
this.quiescentSince ??= now;
|
||||
if (now - this.quiescentSince >= this.quietPeriodMs) this.setPhase("idle");
|
||||
}
|
||||
|
||||
/** An offline or suspended attempt can be bypassed rather than trapping the user. */
|
||||
interrupted(): void {
|
||||
if (this.isBlocking()) this.setPhase("interrupted");
|
||||
else this.setPhase("idle");
|
||||
}
|
||||
|
||||
bypass(): void {
|
||||
this.bypassed = true;
|
||||
this.setPhase("idle");
|
||||
}
|
||||
|
||||
disable(): void {
|
||||
this.bypassed = true;
|
||||
this.setPhase("idle");
|
||||
}
|
||||
|
||||
private isBlocking(): boolean {
|
||||
return this.phase === "transferring" || this.phase === "applying" || this.phase === "interrupted";
|
||||
}
|
||||
|
||||
private setPhase(next: SyncGuardPhase): void {
|
||||
if (this.phase === next) return;
|
||||
this.phase = next;
|
||||
this.onChange(this.presentation);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { SyncGuardState, type SyncGuardPresentation } from "./SyncGuardState";
|
||||
|
||||
describe("SyncGuardState", () => {
|
||||
function setup() {
|
||||
const presentations: SyncGuardPresentation[] = [];
|
||||
const onChange = vi.fn((value: SyncGuardPresentation) => presentations.push(value));
|
||||
const guard = new SyncGuardState(onChange, 2000);
|
||||
return { guard, onChange, presentations };
|
||||
}
|
||||
|
||||
it("never locks for a small incoming change batch", () => {
|
||||
const { guard } = setup();
|
||||
guard.resume(true, 20);
|
||||
guard.catchUpActivityChanged(true, 0, 0);
|
||||
guard.receivedDocuments(["f:one", "f:two"]);
|
||||
guard.catchUpSucceeded();
|
||||
guard.catchUpActivityChanged(false, 0, 10);
|
||||
expect(guard.presentation.phase).toBe("idle");
|
||||
});
|
||||
|
||||
it("counts distinct Metadata rather than repeated changes and waits for local application", () => {
|
||||
const { guard } = setup();
|
||||
guard.resume(true, 3);
|
||||
guard.catchUpActivityChanged(true, 0, 0);
|
||||
guard.receivedDocuments(["f:one", "f:one", "f:two"]);
|
||||
expect(guard.presentation.phase).toBe("observing");
|
||||
guard.receivedDocuments(["f:three"]);
|
||||
expect(guard.presentation).toEqual({ phase: "transferring", receivedFiles: 3 });
|
||||
guard.receivedDocuments(["f:four", "f:four"]);
|
||||
expect(guard.presentation.receivedFiles).toBe(4);
|
||||
guard.catchUpSucceeded();
|
||||
guard.catchUpActivityChanged(false, 2, 10);
|
||||
expect(guard.presentation.phase).toBe("applying");
|
||||
guard.tick(0, 20);
|
||||
guard.tick(1, 2000);
|
||||
guard.tick(0, 2100);
|
||||
guard.tick(0, 4099);
|
||||
expect(guard.presentation.phase).toBe("applying");
|
||||
guard.tick(0, 4100);
|
||||
expect(guard.presentation.phase).toBe("idle");
|
||||
});
|
||||
|
||||
it("keeps the lock with an interrupted transfer until the user bypasses it", () => {
|
||||
const { guard } = setup();
|
||||
guard.resume(true, 2);
|
||||
guard.catchUpActivityChanged(true, 0, 0);
|
||||
guard.receivedDocuments(["f:one", "f:two"]);
|
||||
guard.catchUpActivityChanged(false, 0, 10);
|
||||
expect(guard.presentation.phase).toBe("interrupted");
|
||||
guard.bypass();
|
||||
expect(guard.presentation.phase).toBe("idle");
|
||||
guard.receivedDocuments(["f:other"]);
|
||||
expect(guard.presentation.phase).toBe("idle");
|
||||
});
|
||||
|
||||
it("expires the manual bypass when its catch-up completes and protects the next catch-up", () => {
|
||||
const { guard } = setup();
|
||||
guard.resume(true, 2);
|
||||
guard.catchUpActivityChanged(true, 0, 0);
|
||||
guard.receivedDocuments(["first", "second"]);
|
||||
expect(guard.presentation.phase).toBe("transferring");
|
||||
guard.bypass();
|
||||
expect(guard.wasBypassed).toBe(true);
|
||||
guard.catchUpActivityChanged(true, 0, 5);
|
||||
expect(guard.isCatchUpActive).toBe(true);
|
||||
guard.catchUpSucceeded();
|
||||
guard.catchUpActivityChanged(false, 0, 10);
|
||||
expect(guard.isCatchUpActive).toBe(false);
|
||||
expect(guard.wasBypassed).toBe(false);
|
||||
expect(guard.presentation.phase).toBe("idle");
|
||||
|
||||
guard.resume(true, 2);
|
||||
guard.catchUpActivityChanged(true, 0, 20);
|
||||
guard.receivedDocuments(["another-first", "another-second"]);
|
||||
expect(guard.presentation).toEqual({ phase: "transferring", receivedFiles: 2 });
|
||||
});
|
||||
|
||||
it("does not unlock when a new pull starts while applying an earlier completed transfer", () => {
|
||||
const { guard } = setup();
|
||||
guard.resume(true, 1);
|
||||
guard.catchUpActivityChanged(true, 0, 0);
|
||||
guard.receivedDocuments(["f:first"]);
|
||||
guard.catchUpSucceeded();
|
||||
guard.catchUpActivityChanged(false, 0, 1);
|
||||
expect(guard.presentation.phase).toBe("applying");
|
||||
guard.catchUpActivityChanged(true, 0, 100);
|
||||
expect(guard.presentation.phase).toBe("transferring");
|
||||
guard.tick(0, 10_000);
|
||||
expect(guard.presentation.phase).toBe("transferring");
|
||||
guard.catchUpActivityChanged(false, 0, 10_001);
|
||||
expect(guard.presentation.phase).toBe("interrupted");
|
||||
});
|
||||
|
||||
it("can recover an interrupted catch-up when a new pull succeeds", () => {
|
||||
const { guard } = setup();
|
||||
guard.resume(true, 1);
|
||||
guard.catchUpActivityChanged(true, 0, 0);
|
||||
guard.receivedDocuments(["f:first"]);
|
||||
guard.catchUpActivityChanged(false, 0, 10);
|
||||
expect(guard.presentation.phase).toBe("interrupted");
|
||||
guard.catchUpActivityChanged(true, 0, 20);
|
||||
expect(guard.presentation.phase).toBe("transferring");
|
||||
guard.catchUpSucceeded();
|
||||
guard.catchUpActivityChanged(false, 0, 30);
|
||||
guard.tick(0, 2030);
|
||||
expect(guard.presentation.phase).toBe("idle");
|
||||
});
|
||||
|
||||
it("does not declare the Vault ready after a failed received-file reflection", () => {
|
||||
const { guard } = setup();
|
||||
guard.resume(true, 1);
|
||||
guard.catchUpActivityChanged(true, 0, 0);
|
||||
guard.receivedDocuments(["f:first"]);
|
||||
guard.reflectionFailed();
|
||||
guard.catchUpSucceeded();
|
||||
guard.catchUpActivityChanged(false, 0, 10);
|
||||
guard.tick(0, 10_000);
|
||||
expect(guard.presentation.phase).toBe("interrupted");
|
||||
guard.bypass();
|
||||
expect(guard.presentation.phase).toBe("idle");
|
||||
});
|
||||
|
||||
it("does not treat a stale completion as success for a new catch-up", () => {
|
||||
const { guard } = setup();
|
||||
guard.resume(true, 1);
|
||||
guard.catchUpSucceeded();
|
||||
guard.catchUpActivityChanged(true, 0, 0);
|
||||
guard.receivedDocuments(["f:one"]);
|
||||
guard.catchUpActivityChanged(false, 0, 1);
|
||||
expect(guard.presentation.phase).toBe("interrupted");
|
||||
});
|
||||
|
||||
it("resets after resume and is opt-in", () => {
|
||||
const { guard } = setup();
|
||||
guard.resume(false, 1);
|
||||
guard.catchUpActivityChanged(true, 0, 0);
|
||||
guard.receivedDocuments(["f:one"]);
|
||||
expect(guard.presentation.phase).toBe("idle");
|
||||
guard.resume(true, 1);
|
||||
guard.catchUpActivityChanged(true, 0, 10);
|
||||
guard.receivedDocuments(["f:one"]);
|
||||
expect(guard.presentation.phase).toBe("transferring");
|
||||
guard.disable();
|
||||
expect(guard.presentation.phase).toBe("idle");
|
||||
});
|
||||
|
||||
it("handles NaN and negative thresholds conservatively", () => {
|
||||
const { guard } = setup();
|
||||
guard.resume(true, NaN);
|
||||
guard.catchUpActivityChanged(true, 0, 0);
|
||||
guard.receivedDocuments(["f:one"]);
|
||||
expect(guard.presentation.phase).toBe("observing");
|
||||
guard.resume(true, -2);
|
||||
guard.catchUpActivityChanged(true, 0, 10);
|
||||
guard.receivedDocuments(["f:one"]);
|
||||
expect(guard.presentation.phase).toBe("transferring");
|
||||
});
|
||||
|
||||
it("applies a lowered threshold while observing without unlocking an already protected Vault", () => {
|
||||
const { guard } = setup();
|
||||
guard.resume(true, 20);
|
||||
guard.catchUpActivityChanged(true, 0, 0);
|
||||
guard.receivedDocuments(["f:one", "f:two", "f:three"]);
|
||||
guard.updateThreshold(3);
|
||||
expect(guard.presentation.phase).toBe("transferring");
|
||||
guard.updateThreshold(200);
|
||||
expect(guard.presentation.phase).toBe("transferring");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,196 @@
|
||||
import { collectingChunks, chunkFetchCounts } from "@vrtmrz/livesync-commonlib/compat/mock_and_interop/stores";
|
||||
import { REMOTE_COUCHDB } from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
import { isAnyNote } from "@vrtmrz/livesync-commonlib/compat/common/utils";
|
||||
import { compatGlobal } from "@vrtmrz/livesync-commonlib/compat/common/coreEnvFunctions";
|
||||
import type { LiveSyncCore } from "@/main.ts";
|
||||
import { getSyncGuardSettings } from "@/modules/services/ObsidianSettingService.ts";
|
||||
import {
|
||||
EVENT_SETTING_SAVED,
|
||||
EVENT_SYNC_GUARD_PREFERENCES_CHANGED,
|
||||
EVENT_VAULT_REFLECTION_FAILED,
|
||||
} from "@/common/events";
|
||||
import type { SyncGuardOverlay } from "./SyncGuardOverlay";
|
||||
import { SyncGuardState } from "./SyncGuardState";
|
||||
|
||||
export type SyncGuardOverlayFactory = (onBypass: () => void) => Pick<SyncGuardOverlay, "update" | "dispose">;
|
||||
|
||||
/**
|
||||
* Obsidian-only UI policy. The synchronisation service remains fully writable so remote
|
||||
* result reflection, conflict resolution, and file-system access can make progress.
|
||||
*/
|
||||
export function useSyncGuardFeature(host: LiveSyncCore, createOverlay: SyncGuardOverlayFactory): void {
|
||||
const { services } = host;
|
||||
let disposed = false;
|
||||
let quietTimer: number | undefined;
|
||||
let terminalPhaseTimer: number | undefined;
|
||||
const readPendingCount = () => {
|
||||
const counts = [
|
||||
services.replication.replicationResultCount.value,
|
||||
services.replication.databaseQueueCount.value,
|
||||
services.replication.storageApplyingCount.value,
|
||||
services.fileProcessing.processing.value,
|
||||
services.fileProcessing.totalQueued.value,
|
||||
services.fileProcessing.batched.value,
|
||||
services.conflict.conflictProcessQueueCount.value,
|
||||
collectingChunks.value,
|
||||
chunkFetchCounts.value.initial,
|
||||
chunkFetchCounts.value.retrying,
|
||||
];
|
||||
return counts.reduce((total, count) => total + Math.max(0, count), 0);
|
||||
};
|
||||
const stopTimer = () => {
|
||||
if (quietTimer !== undefined) compatGlobal.clearTimeout(quietTimer);
|
||||
quietTimer = undefined;
|
||||
};
|
||||
const stopTerminalPhaseTimer = () => {
|
||||
if (terminalPhaseTimer !== undefined) compatGlobal.clearTimeout(terminalPhaseTimer);
|
||||
terminalPhaseTimer = undefined;
|
||||
};
|
||||
|
||||
let guard: SyncGuardState;
|
||||
const overlay = createOverlay(() => guard.bypass());
|
||||
const checkForUnlock = () => {
|
||||
if (disposed) return;
|
||||
stopTimer();
|
||||
if (guard.presentation.phase !== "applying") return;
|
||||
const pending = readPendingCount();
|
||||
guard.tick(pending, Date.now());
|
||||
if (guard.presentation.phase === "applying" && pending === 0) {
|
||||
quietTimer = compatGlobal.setTimeout(checkForUnlock, 2_100);
|
||||
}
|
||||
};
|
||||
guard = new SyncGuardState((presentation) => {
|
||||
if (disposed) return;
|
||||
overlay.update(presentation);
|
||||
checkForUnlock();
|
||||
});
|
||||
|
||||
const isEnabled = () => {
|
||||
const settings = services.setting.currentSettings();
|
||||
return settings.isConfigured && settings.liveSync && settings.remoteType === REMOTE_COUCHDB &&
|
||||
getSyncGuardSettings(services.setting).syncGuardEnabled;
|
||||
};
|
||||
const isCouchDbCatchUp = () => services.replicator.replicationStatics.value.couchDB?.phase === "catch-up";
|
||||
const beginCatchUp = () => {
|
||||
if (!isEnabled()) {
|
||||
guard.disable();
|
||||
return;
|
||||
}
|
||||
// PouchDB briefly reports 'idle' when disposing its progress observer, while
|
||||
// the enclosing CouchDB catch-up still owns the previous guard generation.
|
||||
// Do not reset that generation on duplicate resume, even before the threshold.
|
||||
const sameCatchUp = guard.isCatchUpActive || isCouchDbCatchUp();
|
||||
// An explicit bypass must survive this same transient 'idle'.
|
||||
if (guard.wasBypassed && sameCatchUp) {
|
||||
return;
|
||||
}
|
||||
// Duplicate resume notifications or a visibility transition must not discard an
|
||||
// already active guard and expose a partly reflected Vault to the user.
|
||||
if (guard.presentation.phase === "transferring" || guard.presentation.phase === "applying" ||
|
||||
guard.presentation.phase === "interrupted") {
|
||||
return;
|
||||
}
|
||||
if (guard.presentation.phase === "observing" && sameCatchUp) {
|
||||
return;
|
||||
}
|
||||
guard.resume(Boolean(isEnabled()), getSyncGuardSettings(services.setting).syncGuardMinimumFiles);
|
||||
guard.catchUpActivityChanged(isCouchDbCatchUp(), readPendingCount(), Date.now());
|
||||
};
|
||||
const onReplicationChanged = () => {
|
||||
const phase = services.replicator.replicationStatics.value.couchDB?.phase;
|
||||
if (phase === "catch-up") {
|
||||
stopTerminalPhaseTimer();
|
||||
// Also arm after an internal CouchDB reconnect without an Obsidian resume event.
|
||||
if (guard.presentation.phase === "idle" && !guard.wasBypassed && isEnabled()) beginCatchUp();
|
||||
guard.catchUpActivityChanged(true, readPendingCount(), Date.now());
|
||||
} else if (phase === "continuous") {
|
||||
stopTerminalPhaseTimer();
|
||||
// Commonlib enters continuous only after a successful pull-only catch-up.
|
||||
guard.catchUpSucceeded();
|
||||
guard.catchUpActivityChanged(false, readPendingCount(), Date.now());
|
||||
} else if ((guard.presentation.phase !== "idle" || guard.isCatchUpActive) && terminalPhaseTimer === undefined) {
|
||||
// The one-shot PouchDB progress observer can report 'idle' before the
|
||||
// enclosing finite CouchDB pull has finished releasing its resources.
|
||||
// This counter is not used to identify the provider; CouchDB phase owns that.
|
||||
if (phase === "idle" && services.replicator.finiteReplicationActivityCount.value > 0) return;
|
||||
// PouchDB disposes its per-attempt progress observer with phase 'idle' just
|
||||
// before Commonlib publishes 'continuous'. When the enclosing finite
|
||||
// transfer settles, defer the terminal observation by one event-loop
|
||||
// turn so that this internal hand-off never looks like a failure.
|
||||
terminalPhaseTimer = compatGlobal.setTimeout(() => {
|
||||
terminalPhaseTimer = undefined;
|
||||
const current = services.replicator.replicationStatics.value.couchDB?.phase;
|
||||
if (!disposed && current !== "catch-up" && current !== "continuous") {
|
||||
guard.catchUpActivityChanged(false, readPendingCount(), Date.now());
|
||||
}
|
||||
}, 0);
|
||||
}
|
||||
};
|
||||
const onSettingsChanged = () => {
|
||||
if (!isEnabled()) guard.disable();
|
||||
else guard.updateThreshold(getSyncGuardSettings(services.setting).syncGuardMinimumFiles);
|
||||
};
|
||||
const onPendingChanged = () => checkForUnlock();
|
||||
const onFiniteChanged = () => {
|
||||
if (services.replicator.finiteReplicationActivityCount.value === 0) onReplicationChanged();
|
||||
};
|
||||
const removeReflectionFailureHandler = services.context.events.onEvent(EVENT_VAULT_REFLECTION_FAILED, () => {
|
||||
guard.reflectionFailed();
|
||||
});
|
||||
const removeSettingSavedHandler = services.context.events.onEvent(EVENT_SETTING_SAVED, () => {
|
||||
onSettingsChanged();
|
||||
});
|
||||
const removeSyncGuardPreferencesHandler = services.context.events.onEvent(
|
||||
EVENT_SYNC_GUARD_PREFERENCES_CHANGED,
|
||||
onSettingsChanged
|
||||
);
|
||||
|
||||
const replication = services.replicator.replicationStatics;
|
||||
const finite = services.replicator.finiteReplicationActivityCount;
|
||||
const pendingSources = [
|
||||
services.replication.replicationResultCount,
|
||||
services.replication.databaseQueueCount,
|
||||
services.replication.storageApplyingCount,
|
||||
services.fileProcessing.processing,
|
||||
services.fileProcessing.totalQueued,
|
||||
services.fileProcessing.batched,
|
||||
services.conflict.conflictProcessQueueCount,
|
||||
collectingChunks,
|
||||
chunkFetchCounts,
|
||||
];
|
||||
replication.onChanged(onReplicationChanged);
|
||||
finite.onChanged(onFiniteChanged);
|
||||
for (const source of pendingSources) source.onChanged(onPendingChanged);
|
||||
|
||||
// Register before the ordinary replication scheduling handler so the first finite pull
|
||||
// belongs to the new resume generation even if it starts synchronously.
|
||||
services.appLifecycle.onResumed.addHandler(() => {
|
||||
beginCatchUp();
|
||||
return Promise.resolve(true);
|
||||
}, -100);
|
||||
services.appLifecycle.onSuspending.addHandler(() => {
|
||||
guard.interrupted();
|
||||
return Promise.resolve(true);
|
||||
});
|
||||
services.setting.onSettingRealised.addHandler(() => {
|
||||
onSettingsChanged();
|
||||
return Promise.resolve(true);
|
||||
});
|
||||
services.replication.parseSynchroniseResult.addHandler((documents) => {
|
||||
if (!disposed) guard.receivedDocuments(documents.filter(isAnyNote).map((doc) => doc._id));
|
||||
return Promise.resolve(true);
|
||||
});
|
||||
services.appLifecycle.onUnload.addHandler(() => {
|
||||
disposed = true;
|
||||
stopTimer();
|
||||
stopTerminalPhaseTimer();
|
||||
replication.offChanged(onReplicationChanged);
|
||||
finite.offChanged(onFiniteChanged);
|
||||
for (const source of pendingSources) source.offChanged(onPendingChanged);
|
||||
overlay.dispose();
|
||||
removeSettingSavedHandler();
|
||||
removeSyncGuardPreferencesHandler();
|
||||
removeReflectionFailureHandler();
|
||||
return Promise.resolve(true);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,416 @@
|
||||
import { reactiveSource } from "octagonal-wheels/dataobject/reactive";
|
||||
import { REMOTE_COUCHDB, type EntryDoc } from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
import type { CouchDBReplicationPhase, ReplicationStatics } from "@vrtmrz/livesync-commonlib/replication";
|
||||
import { createServiceContext } from "@vrtmrz/livesync-commonlib/context";
|
||||
import {
|
||||
EVENT_SETTING_SAVED,
|
||||
EVENT_SYNC_GUARD_PREFERENCES_CHANGED,
|
||||
EVENT_VAULT_REFLECTION_FAILED,
|
||||
} from "@/common/events";
|
||||
import { describe, afterEach, expect, it, vi } from "vitest";
|
||||
import { useSyncGuardFeature } from "./index";
|
||||
import type { SyncGuardPresentation } from "./SyncGuardState";
|
||||
|
||||
function receivedNote(id: string): PouchDB.Core.ExistingDocument<EntryDoc> {
|
||||
return {
|
||||
_id: id,
|
||||
_rev: "1-received",
|
||||
path: `${id}.md`,
|
||||
ctime: 1,
|
||||
mtime: 2,
|
||||
size: 1,
|
||||
children: [],
|
||||
datatype: "plain",
|
||||
type: "plain",
|
||||
eden: {},
|
||||
} as unknown as PouchDB.Core.ExistingDocument<EntryDoc>;
|
||||
}
|
||||
|
||||
function setup(overrides: { enabled?: boolean; minimumFiles?: number } = {}) {
|
||||
const onResumed: Array<() => Promise<boolean>> = [];
|
||||
const onSuspending: Array<() => Promise<boolean>> = [];
|
||||
const onUnload: Array<() => Promise<boolean>> = [];
|
||||
const onSettingRealised: Array<() => Promise<boolean>> = [];
|
||||
const parseHandlers: Array<(docs: PouchDB.Core.ExistingDocument<EntryDoc>[]) => Promise<boolean>> = [];
|
||||
const priority = vi.fn();
|
||||
const finiteReplicationActivityCount = reactiveSource(0);
|
||||
const replicationStatics = reactiveSource<ReplicationStatics>({
|
||||
sent: 0,
|
||||
arrived: 0,
|
||||
maxPullSeq: 0,
|
||||
maxPushSeq: 0,
|
||||
lastSyncPullSeq: 0,
|
||||
lastSyncPushSeq: 0,
|
||||
syncStatus: "NOT_CONNECTED",
|
||||
});
|
||||
const setCouchDbPhase = (phase: CouchDBReplicationPhase) => {
|
||||
replicationStatics.value = { ...replicationStatics.value, couchDB: { phase } };
|
||||
};
|
||||
const replicationResultCount = reactiveSource(0);
|
||||
const databaseQueueCount = reactiveSource(0);
|
||||
const storageApplyingCount = reactiveSource(0);
|
||||
const processing = reactiveSource(0);
|
||||
const totalQueued = reactiveSource(0);
|
||||
const batched = reactiveSource(0);
|
||||
const conflictProcessQueueCount = reactiveSource(0);
|
||||
const settings = {
|
||||
syncGuardEnabled: overrides.enabled ?? true,
|
||||
syncGuardMinimumFiles: overrides.minimumFiles ?? 2,
|
||||
};
|
||||
const sharedSettings = {
|
||||
isConfigured: true,
|
||||
liveSync: true,
|
||||
remoteType: REMOTE_COUCHDB,
|
||||
};
|
||||
const host = {
|
||||
services: {
|
||||
context: createServiceContext(),
|
||||
setting: {
|
||||
currentSettings: () => sharedSettings,
|
||||
syncGuardSettings: settings,
|
||||
onSettingRealised: { addHandler: (fn: () => Promise<boolean>) => onSettingRealised.push(fn) },
|
||||
},
|
||||
replication: {
|
||||
replicationResultCount,
|
||||
databaseQueueCount,
|
||||
storageApplyingCount,
|
||||
parseSynchroniseResult: {
|
||||
addHandler: (fn: (docs: PouchDB.Core.ExistingDocument<EntryDoc>[]) => Promise<boolean>) => {
|
||||
parseHandlers.push(fn);
|
||||
},
|
||||
},
|
||||
},
|
||||
replicator: { finiteReplicationActivityCount, replicationStatics },
|
||||
fileProcessing: { processing, totalQueued, batched },
|
||||
conflict: { conflictProcessQueueCount },
|
||||
appLifecycle: {
|
||||
onResumed: {
|
||||
addHandler: (fn: () => Promise<boolean>, prio?: number) => {
|
||||
priority(prio);
|
||||
onResumed.push(fn);
|
||||
},
|
||||
},
|
||||
onSuspending: { addHandler: (fn: () => Promise<boolean>) => onSuspending.push(fn) },
|
||||
onUnload: { addHandler: (fn: () => Promise<boolean>) => onUnload.push(fn) },
|
||||
},
|
||||
},
|
||||
};
|
||||
const updates: SyncGuardPresentation[] = [];
|
||||
const dispose = vi.fn();
|
||||
let bypass: () => void = () => { throw new Error("Guard not composed"); };
|
||||
useSyncGuardFeature(host as never, (onBypass) => {
|
||||
bypass = onBypass;
|
||||
return { update: (p) => updates.push(p), dispose };
|
||||
});
|
||||
return {
|
||||
settings,
|
||||
events: host.services.context.events,
|
||||
onResumed,
|
||||
onSuspending,
|
||||
onUnload,
|
||||
onSettingRealised,
|
||||
parseHandlers,
|
||||
priority,
|
||||
setCouchDbPhase,
|
||||
finiteReplicationActivityCount,
|
||||
replicationStatics,
|
||||
replicationResultCount,
|
||||
databaseQueueCount,
|
||||
storageApplyingCount,
|
||||
updates,
|
||||
bypass: () => bypass(),
|
||||
dispose,
|
||||
};
|
||||
}
|
||||
|
||||
describe("useSyncGuardFeature", () => {
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("observes the initial CouchDB pull ahead of scheduling and unlocks after local queues settle", async () => {
|
||||
vi.useFakeTimers();
|
||||
const h = setup();
|
||||
expect(h.priority).toHaveBeenCalledWith(-100);
|
||||
await h.onResumed[0]();
|
||||
h.setCouchDbPhase("catch-up");
|
||||
await h.parseHandlers[0]([receivedNote("first"), receivedNote("second")]);
|
||||
expect(h.updates[h.updates.length - 1]?.phase).toBe("transferring");
|
||||
h.databaseQueueCount.value = 1;
|
||||
h.replicationStatics.value = { ...h.replicationStatics.value, syncStatus: "COMPLETED" };
|
||||
h.setCouchDbPhase("continuous");
|
||||
expect(h.updates[h.updates.length - 1]?.phase).toBe("applying");
|
||||
vi.advanceTimersByTime(5_000);
|
||||
expect(h.updates[h.updates.length - 1]?.phase).toBe("applying");
|
||||
h.databaseQueueCount.value = 0;
|
||||
await vi.advanceTimersByTimeAsync(2_150);
|
||||
expect(h.updates[h.updates.length - 1]?.phase).toBe("idle");
|
||||
await h.onUnload[0]();
|
||||
expect(h.dispose).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("does not mistake a transient progress-observer idle for a failed catch-up", async () => {
|
||||
vi.useFakeTimers();
|
||||
const h = setup({ minimumFiles: 1 });
|
||||
await h.onResumed[0]();
|
||||
h.setCouchDbPhase("catch-up");
|
||||
await h.parseHandlers[0]([receivedNote("first")]);
|
||||
h.databaseQueueCount.value = 1;
|
||||
h.setCouchDbPhase("idle"); // PouchDB's per-attempt observer is disposed before the continuous hand-off.
|
||||
h.setCouchDbPhase("continuous");
|
||||
await vi.advanceTimersByTimeAsync(1);
|
||||
expect(h.updates[h.updates.length - 1]?.phase).toBe("applying");
|
||||
h.databaseQueueCount.value = 0;
|
||||
await vi.advanceTimersByTimeAsync(2_150);
|
||||
expect(h.updates[h.updates.length - 1]?.phase).toBe("idle");
|
||||
await h.onUnload[0]();
|
||||
});
|
||||
|
||||
it("waits for the finite pull to settle when progress temporarily reports idle", async () => {
|
||||
vi.useFakeTimers();
|
||||
const h = setup({ minimumFiles: 1 });
|
||||
await h.onResumed[0]();
|
||||
h.setCouchDbPhase("catch-up");
|
||||
h.finiteReplicationActivityCount.value = 1;
|
||||
await h.parseHandlers[0]([receivedNote("first")]);
|
||||
h.setCouchDbPhase("idle");
|
||||
await vi.advanceTimersByTimeAsync(5_000); // Resource disposal may be asynchronous.
|
||||
expect(h.updates[h.updates.length - 1]?.phase).toBe("transferring");
|
||||
h.finiteReplicationActivityCount.value = 0;
|
||||
h.setCouchDbPhase("continuous");
|
||||
await vi.advanceTimersByTimeAsync(1);
|
||||
expect(h.updates[h.updates.length - 1]?.phase).toBe("applying");
|
||||
await h.onUnload[0]();
|
||||
});
|
||||
|
||||
it("keeps received-note counting across duplicate resume during transient idle with finite pull active", async () => {
|
||||
vi.useFakeTimers();
|
||||
const h = setup({ minimumFiles: 3 });
|
||||
await h.onResumed[0]();
|
||||
h.setCouchDbPhase("catch-up");
|
||||
h.finiteReplicationActivityCount.value = 1;
|
||||
await h.parseHandlers[0]([receivedNote("first"), receivedNote("second")]);
|
||||
h.setCouchDbPhase("idle");
|
||||
await h.onResumed[0](); // Must not reset the already observed two notes.
|
||||
await h.parseHandlers[0]([receivedNote("third")]);
|
||||
expect(h.updates[h.updates.length - 1]).toMatchObject({
|
||||
phase: "transferring",
|
||||
receivedFiles: 3,
|
||||
});
|
||||
h.finiteReplicationActivityCount.value = 0;
|
||||
h.setCouchDbPhase("continuous");
|
||||
expect(h.updates[h.updates.length - 1]?.phase).toBe("applying");
|
||||
await h.onUnload[0]();
|
||||
});
|
||||
|
||||
it("honours manual bypass across duplicate resume while catch-up reports transient idle", async () => {
|
||||
vi.useFakeTimers();
|
||||
const h = setup({ minimumFiles: 1 });
|
||||
await h.onResumed[0]();
|
||||
h.setCouchDbPhase("catch-up");
|
||||
h.finiteReplicationActivityCount.value = 1;
|
||||
await h.parseHandlers[0]([receivedNote("first")]);
|
||||
h.bypass();
|
||||
h.setCouchDbPhase("idle");
|
||||
await h.onResumed[0]();
|
||||
await h.parseHandlers[0]([receivedNote("second")]);
|
||||
expect(h.updates[h.updates.length - 1]?.phase).toBe("idle");
|
||||
await h.onUnload[0]();
|
||||
});
|
||||
|
||||
it("re-arms protection for a new substantial catch-up after bypass, completion, suspension and resume", async () => {
|
||||
const h = setup({ minimumFiles: 2 });
|
||||
await h.onResumed[0]();
|
||||
h.setCouchDbPhase("catch-up");
|
||||
await h.parseHandlers[0]([receivedNote("first"), receivedNote("second")]);
|
||||
expect(h.updates[h.updates.length - 1]?.phase).toBe("transferring");
|
||||
h.bypass();
|
||||
|
||||
// A duplicate resume before the first transfer finishes must honour the bypass.
|
||||
await h.onResumed[0]();
|
||||
await h.parseHandlers[0]([receivedNote("third"), receivedNote("fourth")]);
|
||||
expect(h.updates[h.updates.length - 1]?.phase).toBe("idle");
|
||||
|
||||
// Successful hand-off to Continuous retires the bypassed generation.
|
||||
h.setCouchDbPhase("continuous");
|
||||
await h.onSuspending[0]();
|
||||
await h.onResumed[0]();
|
||||
h.setCouchDbPhase("catch-up");
|
||||
await h.parseHandlers[0]([receivedNote("new-first"), receivedNote("new-second")]);
|
||||
expect(h.updates[h.updates.length - 1]).toMatchObject({
|
||||
phase: "transferring",
|
||||
receivedFiles: 2,
|
||||
});
|
||||
await h.onUnload[0]();
|
||||
});
|
||||
|
||||
it("re-arms protection after bypassed catch-up fails and later restarts", async () => {
|
||||
vi.useFakeTimers();
|
||||
const h = setup({ minimumFiles: 1 });
|
||||
await h.onResumed[0]();
|
||||
h.setCouchDbPhase("catch-up");
|
||||
h.finiteReplicationActivityCount.value = 1;
|
||||
await h.parseHandlers[0]([receivedNote("failed")]);
|
||||
h.bypass();
|
||||
h.setCouchDbPhase("idle");
|
||||
h.finiteReplicationActivityCount.value = 0;
|
||||
await vi.advanceTimersByTimeAsync(1);
|
||||
await h.onSuspending[0]();
|
||||
await h.onResumed[0]();
|
||||
h.setCouchDbPhase("catch-up");
|
||||
await h.parseHandlers[0]([receivedNote("after-restart")]);
|
||||
expect(h.updates[h.updates.length - 1]?.phase).toBe("transferring");
|
||||
await h.onUnload[0]();
|
||||
});
|
||||
|
||||
it("ignores large P2P finite transfers when CouchDB catch-up is not running", async () => {
|
||||
const h = setup({ minimumFiles: 2 });
|
||||
await h.onResumed[0]();
|
||||
h.finiteReplicationActivityCount.value = 1; // P2P uses only the shared finite counter.
|
||||
await h.parseHandlers[0]([receivedNote("peer-first"), receivedNote("peer-second")]);
|
||||
h.finiteReplicationActivityCount.value = 0;
|
||||
expect(h.updates.some((p) => p.phase === "transferring" || p.phase === "interrupted")).toBe(false);
|
||||
// A real CouchDB catch-up must still arm normally in the same session.
|
||||
h.setCouchDbPhase("catch-up");
|
||||
await h.parseHandlers[0]([receivedNote("db-first"), receivedNote("db-second")]);
|
||||
expect(h.updates[h.updates.length - 1]?.phase).toBe("transferring");
|
||||
await h.onUnload[0]();
|
||||
});
|
||||
|
||||
it("never blocks normal transfers and ignores chunks and duplicate note revisions", async () => {
|
||||
const h = setup({ minimumFiles: 3 });
|
||||
await h.onResumed[0]();
|
||||
h.setCouchDbPhase("catch-up");
|
||||
await h.parseHandlers[0]([receivedNote("first"), receivedNote("first"), { _id: "h:chunk", _rev: "1-a", type: "leaf" } as never]);
|
||||
expect(h.updates.some((p) => p.phase === "transferring")).toBe(false);
|
||||
await h.onUnload[0]();
|
||||
});
|
||||
|
||||
it("preserves offline access through manual bypass and never re-locks that catch-up", async () => {
|
||||
vi.useFakeTimers();
|
||||
const h = setup({ minimumFiles: 1 });
|
||||
await h.onResumed[0]();
|
||||
h.setCouchDbPhase("catch-up");
|
||||
await h.parseHandlers[0]([receivedNote("first")]);
|
||||
h.setCouchDbPhase("idle");
|
||||
await vi.advanceTimersByTimeAsync(1);
|
||||
expect(h.updates[h.updates.length - 1]?.phase).toBe("interrupted");
|
||||
h.bypass();
|
||||
expect(h.updates[h.updates.length - 1]?.phase).toBe("idle");
|
||||
await h.parseHandlers[0]([receivedNote("second")]);
|
||||
expect(h.updates[h.updates.length - 1]?.phase).toBe("idle");
|
||||
await h.onUnload[0]();
|
||||
});
|
||||
|
||||
it("never shows a guard when disabled and releases an active guard when the setting is disabled", async () => {
|
||||
const h = setup({ enabled: false, minimumFiles: 1 });
|
||||
await h.onResumed[0]();
|
||||
h.setCouchDbPhase("catch-up");
|
||||
await h.parseHandlers[0]([receivedNote("first")]);
|
||||
expect(h.updates.some((p) => p.phase === "transferring")).toBe(false);
|
||||
await h.onUnload[0]();
|
||||
|
||||
const enabled = setup({ minimumFiles: 1 });
|
||||
await enabled.onResumed[0]();
|
||||
enabled.setCouchDbPhase("catch-up");
|
||||
await enabled.parseHandlers[0]([receivedNote("first")]);
|
||||
expect(enabled.updates[enabled.updates.length - 1]?.phase).toBe("transferring");
|
||||
enabled.settings.syncGuardEnabled = false;
|
||||
await enabled.onSettingRealised[0]();
|
||||
expect(enabled.updates[enabled.updates.length - 1]?.phase).toBe("idle");
|
||||
await enabled.onUnload[0]();
|
||||
});
|
||||
|
||||
it("observes an internal LiveSync reconnect even without an application resume notification", async () => {
|
||||
const h = setup({ minimumFiles: 1 });
|
||||
h.setCouchDbPhase("catch-up");
|
||||
await h.parseHandlers[0]([receivedNote("after-reconnect")]);
|
||||
expect(h.updates[h.updates.length - 1]?.phase).toBe("transferring");
|
||||
h.replicationStatics.value = { ...h.replicationStatics.value, syncStatus: "COMPLETED" };
|
||||
h.setCouchDbPhase("continuous");
|
||||
expect(h.updates[h.updates.length - 1]?.phase).toBe("applying");
|
||||
await h.onUnload[0]();
|
||||
});
|
||||
|
||||
it("preserves an active guard across duplicate resume notifications", async () => {
|
||||
const h = setup({ minimumFiles: 2 });
|
||||
await h.onResumed[0]();
|
||||
h.setCouchDbPhase("catch-up");
|
||||
await h.parseHandlers[0]([receivedNote("first"), receivedNote("second")]);
|
||||
expect(h.updates[h.updates.length - 1]?.phase).toBe("transferring");
|
||||
await h.onResumed[0]();
|
||||
expect(h.updates[h.updates.length - 1]?.phase).toBe("transferring");
|
||||
h.replicationStatics.value = { ...h.replicationStatics.value, syncStatus: "COMPLETED" };
|
||||
h.setCouchDbPhase("continuous");
|
||||
expect(h.updates[h.updates.length - 1]?.phase).toBe("applying");
|
||||
await h.onUnload[0]();
|
||||
});
|
||||
|
||||
it("preserves accumulated files before the threshold when resume fires twice during one pull", async () => {
|
||||
const h = setup({ minimumFiles: 3 });
|
||||
await h.onResumed[0]();
|
||||
h.setCouchDbPhase("catch-up");
|
||||
await h.parseHandlers[0]([receivedNote("one"), receivedNote("two")]);
|
||||
await h.onResumed[0]();
|
||||
await h.parseHandlers[0]([receivedNote("three")]);
|
||||
expect(h.updates[h.updates.length - 1]).toMatchObject({
|
||||
phase: "transferring",
|
||||
receivedFiles: 3,
|
||||
});
|
||||
await h.onUnload[0]();
|
||||
});
|
||||
|
||||
it("keeps the guard after a received note could not be written to the Vault", async () => {
|
||||
const h = setup({ minimumFiles: 1 });
|
||||
await h.onResumed[0]();
|
||||
h.setCouchDbPhase("catch-up");
|
||||
await h.parseHandlers[0]([receivedNote("failed")]);
|
||||
h.events.emitEvent(EVENT_VAULT_REFLECTION_FAILED);
|
||||
h.replicationStatics.value = { ...h.replicationStatics.value, syncStatus: "COMPLETED" };
|
||||
h.setCouchDbPhase("continuous");
|
||||
expect(h.updates[h.updates.length - 1]?.phase).toBe("interrupted");
|
||||
await h.onUnload[0]();
|
||||
});
|
||||
|
||||
it("responds immediately to settings saved by the UI without setting-realised", async () => {
|
||||
const h = setup({ minimumFiles: 20 });
|
||||
await h.onResumed[0]();
|
||||
h.setCouchDbPhase("catch-up");
|
||||
await h.parseHandlers[0]([receivedNote("one"), receivedNote("two")]);
|
||||
expect(h.updates[h.updates.length - 1]?.phase).toBe("observing");
|
||||
h.settings.syncGuardMinimumFiles = 2;
|
||||
h.events.emitEvent(EVENT_SYNC_GUARD_PREFERENCES_CHANGED);
|
||||
expect(h.updates[h.updates.length - 1]?.phase).toBe("transferring");
|
||||
h.settings.syncGuardEnabled = false;
|
||||
h.events.emitEvent(EVENT_SYNC_GUARD_PREFERENCES_CHANGED);
|
||||
expect(h.updates[h.updates.length - 1]?.phase).toBe("idle");
|
||||
await h.onUnload[0]();
|
||||
});
|
||||
|
||||
it("re-locks on a new internal catch-up after a bypassed transfer has completed", async () => {
|
||||
const h = setup({ minimumFiles: 1 });
|
||||
await h.onResumed[0]();
|
||||
h.setCouchDbPhase("catch-up");
|
||||
await h.parseHandlers[0]([receivedNote("first")]);
|
||||
expect(h.updates[h.updates.length - 1]?.phase).toBe("transferring");
|
||||
h.bypass();
|
||||
h.setCouchDbPhase("continuous");
|
||||
h.setCouchDbPhase("catch-up");
|
||||
await h.parseHandlers[0]([receivedNote("second")]);
|
||||
expect(h.updates[h.updates.length - 1]?.phase).toBe("transferring");
|
||||
await h.onUnload[0]();
|
||||
});
|
||||
|
||||
it("preserves manual bypass across duplicate resume while the same CouchDB pull remains active", async () => {
|
||||
const h = setup({ minimumFiles: 2 });
|
||||
await h.onResumed[0]();
|
||||
h.setCouchDbPhase("catch-up");
|
||||
await h.parseHandlers[0]([receivedNote("first"), receivedNote("second")]);
|
||||
expect(h.updates[h.updates.length - 1]?.phase).toBe("transferring");
|
||||
h.bypass();
|
||||
await h.onResumed[0]();
|
||||
await h.parseHandlers[0]([receivedNote("third"), receivedNote("fourth")]);
|
||||
expect(h.updates[h.updates.length - 1]?.phase).toBe("idle");
|
||||
await h.onUnload[0]();
|
||||
});
|
||||
});
|
||||
+35
@@ -840,6 +840,41 @@ body.is-mobile .livesync-compatibility-review-notice {
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
/* An optional, workspace-only barrier during a substantial catch-up. Other Obsidian dialogues remain usable. */
|
||||
.sls-sync-guard {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: var(--layer-cover, 30);
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: var(--size-4-4);
|
||||
background: var(--background-primary);
|
||||
color: var(--text-normal);
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.sls-sync-guard-panel {
|
||||
width: min(100%, 30rem);
|
||||
padding: var(--size-4-6);
|
||||
border: 1px solid var(--background-modifier-border);
|
||||
border-radius: var(--radius-l);
|
||||
background: var(--background-secondary);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.sls-sync-guard-panel h2 {
|
||||
margin-block-start: 0;
|
||||
}
|
||||
|
||||
.sls-sync-guard-count {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.sls-sync-guard-bypass {
|
||||
min-height: 44px;
|
||||
min-width: 44px;
|
||||
}
|
||||
|
||||
.livesync-stat-item {
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
const stylesheet = readFileSync(fileURLToPath(new URL("../styles.css", import.meta.url)), "utf8");
|
||||
|
||||
describe("SyncGuard CSS", () => {
|
||||
it("closes its bypass rule before the existing status display rules", () => {
|
||||
const bypassStart = stylesheet.indexOf(".sls-sync-guard-bypass {");
|
||||
const statusStart = stylesheet.indexOf(".livesync-stat-item {", bypassStart);
|
||||
expect(bypassStart).toBeGreaterThanOrEqual(0);
|
||||
expect(statusStart).toBeGreaterThan(bypassStart);
|
||||
const bypassRule = stylesheet.slice(bypassStart, statusStart);
|
||||
expect(bypassRule).toMatch(/min-height:\s*44px;/);
|
||||
expect(bypassRule).toMatch(/min-width:\s*44px;/);
|
||||
expect(bypassRule).toMatch(/}\s*$/);
|
||||
});
|
||||
});
|
||||
@@ -21,6 +21,8 @@ Earlier releases remain available in the 1.0 release history, the 1.0 preview hi
|
||||
|
||||
### Synchronisation
|
||||
|
||||
- Added an optional, device-local editing guard for substantial CouchDB LiveSync catch-up transfers. When enough incoming notes are detected, it covers the workspace until the transfer and local application queues have settled. The overlay can always be bypassed, and ordinary small transfers remain uninterrupted.
|
||||
|
||||
- We can see CouchDB change-feed comparison progress separately for push and pull.
|
||||
- The `⏩↑` and `⏩↓` counts show entries compared in each direction; the upload and download totals remain separate.
|
||||
- Existing CSS snippets which target `livesync-stat-ff` to hide comparison progress need to target both `livesync-stat-ff-push` and `livesync-stat-ff-pull`. See the [status display guide](docs/tips/status-display.md).
|
||||
|
||||
Reference in new issue
Block a user