Merge origin/main into optional-file sync ownership

This commit is contained in:
vorotamoroz committed 2026-09-04 10:34:41 +00:00
commit c011eeda77
55 files changed
+5477 -2265

No files matched your search

+6 -11
View File
@@ -22,17 +22,16 @@ import { useRemoteConfigurationMigration } from "@vrtmrz/livesync-commonlib/comp
import type { ServiceContext } from "@vrtmrz/livesync-commonlib/context";
import type { InjectableServiceHub } from "@vrtmrz/livesync-commonlib/compat/services/implements/injectable/InjectableServiceHub";
import { AbstractModule } from "./modules/AbstractModule";
import { ModuleConflictChecker } from "./modules/coreFeatures/ModuleConflictChecker";
import { ModuleConflictResolver } from "./modules/coreFeatures/ModuleConflictResolver";
import { ModuleResolvingMismatchedTweaks } from "./modules/coreFeatures/ModuleResolveMismatchedTweaks";
import { ModuleLiveSyncMain } from "./modules/main/ModuleLiveSyncMain";
import type { ServiceModules } from "@vrtmrz/livesync-commonlib/compat/interfaces/ServiceModule";
import { ModuleBasicMenu } from "./modules/essential/ModuleBasicMenu";
import { usePrepareDatabaseForUse } from "@vrtmrz/livesync-commonlib/compat/serviceFeatures/prepareDatabaseForUse";
import type { Constructor } from "@vrtmrz/livesync-commonlib/compat/common/utils.type";
import { useReplicationScheduling, type ReplicationSchedulingControl } from "./serviceFeatures/replicationScheduling";
import { createCentralReplicatorProviderDefinitions } from "./common/replicatorProviders";
import { useReplicationFeature } from "./serviceFeatures/replication";
import { useConflictResolutionFeature } from "./serviceFeatures/conflictResolution";
import { useBasicCommandsFeature } from "./serviceFeatures/basicCommands";
/** Focused views returned by serviceFeatures which the host may consume during composition. */
export interface LiveSyncCoreFeatureViews {
@@ -45,10 +44,7 @@ export class LiveSyncBaseCore<
T extends ServiceContext = ServiceContext,
TCommands extends IMinimumLiveSyncCommands = IMinimumLiveSyncCommands,
>
implements
LiveSyncLocalDBEnv,
LiveSyncCouchDBReplicatorEnv,
HasSettings<ObsidianLiveSyncSettings>
implements LiveSyncLocalDBEnv, LiveSyncCouchDBReplicatorEnv, HasSettings<ObsidianLiveSyncSettings>
{
addOns = [] as TCommands[];
@@ -83,9 +79,10 @@ export class LiveSyncBaseCore<
for (const addOn of addOns) {
this._registerAddOn(addOn);
}
// Register host features and add-ons before replication, then bind
// Compose late core features after host features and add-ons, then bind
// legacy modules so lifecycle handlers observe the required order.
useReplicationFeature(this);
useBasicCommandsFeature(this);
this.bindModuleFunctions();
}
/**
@@ -145,10 +142,7 @@ export class LiveSyncBaseCore<
public registerModules(extraModules: AbstractModule[] = []) {
this._registerModule(new ModuleLiveSyncMain(this));
this._registerModule(new ModuleConflictChecker(this));
this._registerModule(new ModuleConflictResolver(this));
this._registerModule(new ModuleResolvingMismatchedTweaks(this));
this._registerModule(new ModuleBasicMenu(this));
for (const module of extraModules) {
this._registerModule(module);
@@ -278,6 +272,7 @@ export class LiveSyncBaseCore<
* (Please refer `serviceFeatures` for more details)
*/
initialiseServiceFeatures(): LiveSyncCoreFeatureViews {
useConflictResolutionFeature(this);
useTargetFilters(this);
// enable target filter feature.
usePrepareDatabaseForUse(this);
+16 -9
View File
@@ -4,7 +4,6 @@ setGetLanguage(getLanguage);
import { LiveSyncCommands } from "./features/LiveSyncCommands.ts";
// import { ModuleDev } from "./modules/extras/ModuleDev.ts";
import { ModuleInteractiveConflictResolver } from "./modules/features/ModuleInteractiveConflictResolver.ts";
import { ModuleLog } from "./modules/features/ModuleLog.ts";
import { ModuleObsidianEvents } from "./modules/essentialObsidian/ModuleObsidianEvents.ts";
import { ModuleObsidianSettingDialogue } from "./modules/features/ModuleObsidianSettingTab.ts";
@@ -24,10 +23,8 @@ import type { ServiceModules } from "./types.ts";
import { setNoticeClass } from "@vrtmrz/livesync-commonlib/compat/mock_and_interop/wrapper";
import type { ObsidianServiceContext } from "@/modules/services/ObsidianServiceContext";
import { LiveSyncBaseCore } from "./LiveSyncBaseCore.ts";
import { ModuleObsidianMenu } from "./modules/essentialObsidian/ModuleObsidianMenu.ts";
import { ModuleObsidianSettingsAsMarkdown } from "./modules/features/ModuleObsidianSettingAsMarkdown.ts";
import { SetupManager } from "./modules/features/SetupManager.ts";
import { ModuleMigration } from "./modules/essential/ModuleMigration.ts";
import { enableI18nFeature } from "./serviceFeatures/onLayoutReady/enablei18n.ts";
import { useOfflineScanner } from "@vrtmrz/livesync-commonlib/compat/serviceFeatures/offlineScanner";
import { useRemoteConfiguration } from "@vrtmrz/livesync-commonlib/compat/serviceFeatures/remoteConfig";
@@ -36,7 +33,10 @@ import { useRedFlagFeatures } from "./serviceFeatures/redFlag.ts";
import { useSetupProtocolFeature } from "./serviceFeatures/setupObsidian/setupProtocol.ts";
import { useSetupQRCodeFeature } from "@/serviceFeatures/setupObsidian/qrCode";
import { useSetupURIFeature } from "@/serviceFeatures/setupObsidian/setupUri";
import { useSetupManagerHandlersFeature } from "./serviceFeatures/setupObsidian/setupManagerHandlers.ts";
import {
showOnboardingInvitation,
useSetupManagerHandlersFeature,
} from "./serviceFeatures/setupObsidian/setupManagerHandlers.ts";
import { useP2PReplicatorCommands, useP2PReplicatorFeature } from "@vrtmrz/livesync-commonlib/p2p";
import { useP2PReplicatorUI } from "./serviceFeatures/useP2PReplicatorUI.ts";
import { useReviewHarness } from "./serviceFeatures/useReviewHarness.ts";
@@ -47,6 +47,10 @@ import { createFileReflectionProvenance } from "./serviceModules/FileReflectionP
import { useCustomisationSyncUI } from "./serviceFeatures/useCustomisationSyncUI.ts";
import { useOptionalFileSync, type OptionalFileSyncFeature } from "./serviceFeatures/useOptionalFileSync.ts";
import { useHiddenFileSyncCommands } from "./serviceFeatures/useHiddenFileSyncCommands.ts";
import { useInteractiveConflictResolutionFeature } from "./serviceFeatures/interactiveConflictResolution";
import { ConflictResolveModal } from "./modules/features/InteractiveConflictResolving/ConflictResolveModal.ts";
import { useObsidianReplicationRibbonFeature } from "./serviceFeatures/obsidianReplicationRibbon.ts";
import { useStartupLifecycleFeature } from "./serviceFeatures/startupLifecycle";
export type LiveSyncCore = LiveSyncBaseCore<ObsidianServiceContext, LiveSyncCommands>;
export default class ObsidianLiveSyncPlugin extends Plugin {
core: LiveSyncCore;
@@ -147,7 +151,6 @@ export default class ObsidianLiveSyncPlugin extends Plugin {
setNoticeClass(Notice);
const serviceHub = new ObsidianServiceHub(this);
let waitForCompatibilityReview = (): Promise<void> => Promise.resolve();
this.core = new LiveSyncBaseCore(
serviceHub,
@@ -160,15 +163,12 @@ export default class ObsidianLiveSyncPlugin extends Plugin {
new ModuleObsidianSettingDialogue(this, core, {
getHiddenFileSyncRepair: () => this.optionalFileSync?.hiddenFileSyncRepair,
}),
new ModuleObsidianMenu(core),
new ModuleObsidianSettingsAsMarkdown(core),
new ModuleLog(this, core),
new ModuleObsidianDocumentHistory(this, core),
new ModuleInteractiveConflictResolver(this, core),
new ModuleObsidianGlobalHistory(this, core),
// new ModuleDev(this, core),
new SetupManager(core), // this should be moved to core?
new ModuleMigration(core, () => waitForCompatibilityReview()),
];
return extraModules;
},
@@ -190,6 +190,7 @@ export default class ObsidianLiveSyncPlugin extends Plugin {
setupManager.registerP2PSetupConnectionProbe(replicator.connectionProbe);
useP2PReplicatorCommands(core, replicator);
useP2PReplicatorUI(core, core, replicator, createInteractiveP2PReplication(replicator));
useObsidianReplicationRibbonFeature(core);
useRemoteConfiguration(core);
useSetupProtocolFeature(core, setupManager);
@@ -199,11 +200,17 @@ export default class ObsidianLiveSyncPlugin extends Plugin {
useOfflineScanner(core);
useRedFlagFeatures(core);
useCheckRemoteSize(core);
useInteractiveConflictResolutionFeature(core, (filename, conflictCheckResult) => {
return new ConflictResolveModal(this.app, filename, conflictCheckResult);
});
const compatibilityReview = useCompatibilityReview(
core,
createObsidianCompatibilityReviewUi(core.confirm)
);
waitForCompatibilityReview = () => compatibilityReview.openReview();
useStartupLifecycleFeature(core, {
inviteToOnboarding: () => showOnboardingInvitation(core, setupManager),
waitForCompatibilityReview: () => compatibilityReview.openReview(),
});
useReviewHarness(core, this, compatibilityReview);
let customisationSyncUI: ReturnType<typeof useCustomisationSyncUI> | undefined;
@@ -1,82 +0,0 @@
import { AbstractModule } from "@/modules/AbstractModule.ts";
import { LOG_LEVEL_NOTICE, type FilePathWithPrefix } from "@vrtmrz/livesync-commonlib/compat/common/types";
import { QueueProcessor } from "octagonal-wheels/concurrency/processor";
import { sendValue } from "octagonal-wheels/messagepassing/signal";
import type { InjectableServiceHub } from "@vrtmrz/livesync-commonlib/compat/services/implements/injectable/InjectableServiceHub";
import type { LiveSyncCore } from "@/main.ts";
export class ModuleConflictChecker extends AbstractModule {
async _queueConflictCheckIfOpen(file: FilePathWithPrefix): Promise<void> {
const path = file;
if (this.settings.checkConflictOnlyOnOpen) {
const af = this.services.vault.getActiveFilePath();
if (af && af != path) {
this._log(`${file} is conflicted, merging process has been postponed.`, LOG_LEVEL_NOTICE);
return;
}
}
await this.services.conflict.queueCheckFor(path);
}
async _queueConflictCheck(file: FilePathWithPrefix): Promise<void> {
const optionalConflictResult = await this.services.conflict.getOptionalConflictCheckMethod(file);
if (optionalConflictResult == true) {
// The conflict has been resolved by another process.
return;
} else if (optionalConflictResult === "newer") {
// The conflict should be resolved by the newer entry.
await this.services.conflict.resolveByNewest(file);
} else {
this.conflictCheckQueue.enqueue(file);
}
}
_waitForAllConflictProcessed(): Promise<boolean> {
return this.conflictResolveQueue.waitForAllProcessed();
}
// TODO-> Move to ModuleConflictResolver?
conflictResolveQueue = new QueueProcessor(
async (filenames: FilePathWithPrefix[]) => {
const filename = filenames[0];
return await this.services.conflict.resolve(filename);
},
{
suspended: false,
batchSize: 1,
// No need to limit concurrency to `1` here, subsequent process will handle it,
// And, some cases, we do not need to synchronised. (e.g., auto-merge available).
// Therefore, limiting global concurrency is performed on resolver with the UI.
concurrentLimit: 10,
delay: 0,
keepResultUntilDownstreamConnected: false,
}
).replaceEnqueueProcessor((queue, newEntity) => {
const filename = newEntity;
sendValue("cancel-resolve-conflict:" + filename, true);
const newQueue = [...queue].filter((e) => e != newEntity);
return [...newQueue, newEntity];
});
conflictCheckQueue = // First process - Check is the file actually need resolve -
new QueueProcessor(
(files: FilePathWithPrefix[]) => {
const filename = files[0];
return Promise.resolve([filename]);
},
{
suspended: false,
batchSize: 1,
concurrentLimit: 10,
delay: 0,
keepResultUntilDownstreamConnected: true,
pipeTo: this.conflictResolveQueue,
totalRemainingReactiveSource: this.services.conflict.conflictProcessQueueCount,
}
);
override onBindFunction(core: LiveSyncCore, services: InjectableServiceHub): void {
services.conflict.queueCheckForIfOpen.setHandler(this._queueConflictCheckIfOpen.bind(this));
services.conflict.queueCheckFor.setHandler(this._queueConflictCheck.bind(this));
services.conflict.ensureAllProcessed.setHandler(this._waitForAllConflictProcessed.bind(this));
}
}
@@ -1,240 +0,0 @@
import { serialized } from "octagonal-wheels/concurrency/lock";
import { AbstractModule } from "@/modules/AbstractModule.ts";
import {
AUTO_MERGED,
CANCELLED,
LOG_LEVEL_INFO,
LOG_LEVEL_NOTICE,
LOG_LEVEL_VERBOSE,
MISSING_OR_ERROR,
NOT_CONFLICTED,
type diff_check_result,
type FilePathWithPrefix,
} from "@vrtmrz/livesync-commonlib/compat/common/types";
import { isCustomisationSyncMetadata, isPluginMetadata } from "@vrtmrz/livesync-commonlib/compat/common/typeUtils";
import { TARGET_IS_NEW } from "@vrtmrz/livesync-commonlib/compat/common/models/shared.const.symbols";
import { compareMTime, displayRev } from "@vrtmrz/livesync-commonlib/compat/common/utils";
import diff_match_patch from "diff-match-patch";
import { stripAllPrefixes, isPlainText } from "@vrtmrz/livesync-commonlib/compat/string_and_binary/path";
import { EVENT_CONFLICT_CANCELLED, eventHub } from "@/common/events.ts";
import type { InjectableServiceHub } from "@vrtmrz/livesync-commonlib/compat/services/implements/injectable/InjectableServiceHub";
import type { LiveSyncCore } from "@/main.ts";
import { NO_INTERACTION } from "@vrtmrz/livesync-commonlib/replication";
export class ModuleConflictResolver extends AbstractModule {
private async _resolveConflictByDeletingRev(
path: FilePathWithPrefix,
deleteRevision: string,
subTitle = "",
showNotice = true
): Promise<typeof MISSING_OR_ERROR | typeof AUTO_MERGED> {
const title = `Resolving ${subTitle ? `[${subTitle}]` : ""}:`;
if (!(await this.core.fileHandler.deleteRevisionFromDB(path, deleteRevision))) {
this._log(
`${title} Could not delete conflicted revision ${displayRev(deleteRevision)} of ${path}`,
LOG_LEVEL_NOTICE
);
return MISSING_OR_ERROR;
}
eventHub.emitEvent(EVENT_CONFLICT_CANCELLED, path);
this._log(
`${title} Conflicted revision has been deleted ${displayRev(deleteRevision)} ${path}`,
LOG_LEVEL_INFO
);
if ((await this.core.databaseFileAccess.getConflictedRevs(path)).length != 0) {
this._log(`${title} some conflicts are left in ${path}`, LOG_LEVEL_INFO);
return AUTO_MERGED;
}
if (isPluginMetadata(path) || isCustomisationSyncMetadata(path)) {
this._log(`${title} ${path} is a plugin metadata file, no need to write to storage`, LOG_LEVEL_INFO);
return AUTO_MERGED;
}
// If no conflicts were found, write the resolved content to the storage.
if (!(await this.core.fileHandler.dbToStorage(path, stripAllPrefixes(path), true))) {
this._log(`Could not write the resolved content to the storage: ${path}`, LOG_LEVEL_NOTICE);
return MISSING_OR_ERROR;
}
const level = subTitle.indexOf("same") !== -1 || !showNotice ? LOG_LEVEL_INFO : LOG_LEVEL_NOTICE;
this._log(`${path} has been merged automatically`, level);
return AUTO_MERGED;
}
async checkConflictAndPerformAutoMerge(path: FilePathWithPrefix): Promise<diff_check_result> {
//
const ret = await this.localDatabase.tryAutoMerge(path, !this.settings.disableMarkdownAutoMerge);
if ("ok" in ret) {
return ret.ok;
}
if ("result" in ret) {
const p = ret.result;
// Merged content is coming.
// 1. Store the merged content to the storage
if (!(await this.core.databaseFileAccess.storeContent(path, p))) {
this._log(`Merged content cannot be stored:${path}`, LOG_LEVEL_NOTICE);
return MISSING_OR_ERROR;
}
// 2. As usual, delete the conflicted revision and if there are no conflicts, write the resolved content to the storage.
return await this.services.conflict.resolveByDeletingRevision(path, ret.conflictedRev, "Sensible");
}
const { rightRev, leftLeaf, rightLeaf } = ret;
// should be one or more conflicts;
if (leftLeaf == false) {
// what's going on..
this._log(`could not get current revisions:${path}`, LOG_LEVEL_NOTICE);
return MISSING_OR_ERROR;
}
if (rightLeaf == false) {
// A locally unreadable conflict leaf may still be recoverable from another
// replica or backup. Keep it visible for explicit repair instead of treating
// missing chunks as evidence that the branch is obsolete.
this._log(`could not read conflicted revision ${rightRev}:${path}`, LOG_LEVEL_NOTICE);
return MISSING_OR_ERROR;
}
const isSame = leftLeaf.data == rightLeaf.data && leftLeaf.deleted == rightLeaf.deleted;
const isBinary = !isPlainText(path);
const alwaysNewer = this.settings.resolveConflictsByNewerFile;
if (isSame || isBinary || alwaysNewer) {
const result = compareMTime(leftLeaf.mtime, rightLeaf.mtime);
let loser = leftLeaf;
// if (lMtime > rMtime) {
if (result != TARGET_IS_NEW) {
loser = rightLeaf;
}
const subTitle = [
`${isSame ? "same" : ""}`,
`${isBinary ? "binary" : ""}`,
`${alwaysNewer ? "alwaysNewer" : ""}`,
]
.filter((e) => e.trim())
.join(",");
return await this.services.conflict.resolveByDeletingRevision(path, loser.rev, subTitle);
}
// make diff.
const dmp = new diff_match_patch();
const diff = dmp.diff_main(leftLeaf.data, rightLeaf.data);
dmp.diff_cleanupSemantic(diff);
this._log(`conflict(s) found:${path}`);
return {
left: leftLeaf,
right: rightLeaf,
diff: diff,
};
}
private async _resolveConflict(filename: FilePathWithPrefix): Promise<void> {
// const filename = filenames[0];
return await serialized(`conflict-resolve:${filename}`, async () => {
const conflictCheckResult = await this.checkConflictAndPerformAutoMerge(filename);
if (conflictCheckResult === NOT_CONFLICTED) {
eventHub.emitEvent(EVENT_CONFLICT_CANCELLED, filename);
this._log(`[conflict] Not conflicted or cancelled: ${filename}`, LOG_LEVEL_VERBOSE);
return;
}
if (conflictCheckResult === MISSING_OR_ERROR || conflictCheckResult === CANCELLED) {
// nothing to do.
this._log(`[conflict] Not conflicted or cancelled: ${filename}`, LOG_LEVEL_VERBOSE);
return;
}
if (conflictCheckResult === AUTO_MERGED) {
//auto resolved, but need check again;
if (this.settings.syncAfterMerge && !this.services.appLifecycle.isSuspended()) {
//Wait for the running replication, if not running replication, run it once.
await this.services.replication.replicateUnattendedByEvent({
trigger: "merge",
interaction: NO_INTERACTION,
});
}
this._log("[conflict] Automatically merged, but we have to check it again");
await this.services.conflict.queueCheckFor(filename);
return;
}
if (this.settings.showMergeDialogOnlyOnActive) {
const af = this.services.vault.getActiveFilePath();
if (af && af != filename) {
this._log(
`[conflict] ${filename} is conflicted. Merging process has been postponed to the file have got opened.`,
LOG_LEVEL_NOTICE
);
return;
}
}
this._log("[conflict] Manual merge required!");
eventHub.emitEvent(EVENT_CONFLICT_CANCELLED, filename);
await this.services.conflict.resolveByUserInteraction(filename, conflictCheckResult);
});
}
private async _anyResolveConflictByNewest(filename: FilePathWithPrefix, showNotice = true): Promise<boolean> {
const currentRev = await this.core.databaseFileAccess.fetchEntryMeta(filename, undefined, true);
if (currentRev == false) {
this._log(`Could not get current revision of ${filename}`);
return Promise.resolve(false);
}
const revs = await this.core.databaseFileAccess.getConflictedRevs(filename);
if (revs.length == 0) {
return Promise.resolve(true);
}
const mTimeAndRev = (
[
[currentRev.mtime, currentRev._rev],
...(await Promise.all(
revs.map(async (rev) => {
const leaf = await this.core.databaseFileAccess.fetchEntryMeta(filename, rev);
if (leaf == false) {
return [0, rev];
}
return [leaf.mtime, rev];
})
)),
] as [number, string][]
).sort((a, b) => {
const diff = b[0] - a[0];
if (diff == 0) {
return a[1].localeCompare(b[1], "en", { numeric: true });
}
return diff;
});
// console.warn(mTimeAndRev);
this._log(
`Resolving conflict by newest: ${filename} (Newest: ${new Date(mTimeAndRev[0][0]).toLocaleString()}) (${mTimeAndRev.length} revisions exists)`
);
for (let i = 1; i < mTimeAndRev.length; i++) {
this._log(
`conflict: Deleting the older revision ${mTimeAndRev[i][1]} (${new Date(mTimeAndRev[i][0]).toLocaleString()}) of ${filename}`
);
await this._resolveConflictByDeletingRev(filename, mTimeAndRev[i][1], "NEWEST", showNotice);
}
return true;
}
private async _resolveAllConflictedFilesByNewerOnes() {
this._log(`Resolving conflicts by newer ones`, LOG_LEVEL_NOTICE);
const files = await this.core.storageAccess.getFileNames();
let i = 0;
for (const file of files) {
i++;
if (i % 10 === 0)
this._log(
`Check and Processing ${i} / ${files.length}`,
LOG_LEVEL_NOTICE,
"resolveAllConflictedFilesByNewerOnes"
);
await this._anyResolveConflictByNewest(file, false);
}
this._log(`Done!`, LOG_LEVEL_NOTICE, "resolveAllConflictedFilesByNewerOnes");
}
override onBindFunction(core: LiveSyncCore, services: InjectableServiceHub): void {
services.conflict.resolveByDeletingRevision.setHandler(this._resolveConflictByDeletingRev.bind(this));
services.conflict.resolve.setHandler(this._resolveConflict.bind(this));
services.conflict.resolveByNewest.setHandler(this._anyResolveConflictByNewest.bind(this));
services.conflict.resolveAllConflictedFilesByNewerOnes.setHandler(
this._resolveAllConflictedFilesByNewerOnes.bind(this)
);
}
}
@@ -1,268 +0,0 @@
import { describe, expect, it, vi } from "vitest";
import {
AUTO_MERGED,
DEFAULT_SETTINGS,
LOG_LEVEL_INFO,
LOG_LEVEL_NOTICE,
MISSING_OR_ERROR,
type FilePathWithPrefix,
type MetaEntry,
} from "@vrtmrz/livesync-commonlib/compat/common/types";
import { ModuleConflictResolver } from "./ModuleConflictResolver";
function createModule(files: FilePathWithPrefix[] = []) {
const resolveByDeletingRevision = vi.fn(async () => AUTO_MERGED);
const tryAutoMerge = vi.fn();
const queueCheckFor = vi.fn(async () => undefined);
const resolveByUserInteraction = vi.fn(async () => false);
const core = {
_services: {
API: {
addLog: vi.fn(),
addCommand: vi.fn(),
registerWindow: vi.fn(),
addRibbonIcon: vi.fn(),
registerProtocolHandler: vi.fn(),
},
setting: {
saveSettingData: vi.fn(async () => undefined),
},
conflict: {
resolveByNewest: vi.fn(async () => true),
resolveByDeletingRevision,
resolveByUserInteraction,
queueCheckFor,
},
appLifecycle: {
isSuspended: vi.fn(() => false),
},
replication: {
replicateUnattendedByEvent: vi.fn(async () => ({ status: "completed" as const })),
},
vault: {
getActiveFilePath: vi.fn(() => undefined),
},
},
settings: DEFAULT_SETTINGS,
fileHandler: {
deleteRevisionFromDB: vi.fn(async () => true),
dbToStorage: vi.fn(async () => true),
},
databaseFileAccess: {
getConflictedRevs: vi.fn(async () => []),
storeContent: vi.fn(async () => true),
},
localDatabase: {
tryAutoMerge,
},
storageAccess: {
getFileNames: vi.fn(async () => files),
},
} as any;
Object.defineProperty(core, "services", { get: () => core._services });
const module = new ModuleConflictResolver(core);
module._log = vi.fn();
return { module, queueCheckFor, resolveByDeletingRevision, resolveByUserInteraction, tryAutoMerge };
}
describe("ModuleConflictResolver bulk newest resolution", () => {
it("retains the success notice for a non-bulk newest resolution", async () => {
const { module } = createModule();
const path = "example.md" as FilePathWithPrefix;
module.core.databaseFileAccess.fetchEntryMeta = vi.fn(
async (_path: unknown, rev?: string): Promise<MetaEntry> =>
({
_id: "doc-id",
_rev: rev ?? "2-current",
path,
ctime: 1,
mtime: rev ? 1 : 2,
size: 0,
children: [],
type: "plain",
eden: {},
}) as unknown as MetaEntry
);
module.core.databaseFileAccess.getConflictedRevs = vi
.fn()
.mockResolvedValueOnce(["1-old"])
.mockResolvedValue([]);
await (module as any)._anyResolveConflictByNewest(path);
expect(module._log).toHaveBeenLastCalledWith(`${path} has been merged automatically`, LOG_LEVEL_NOTICE);
});
it("logs a successful bulk newest resolution without displaying a notice", async () => {
const { module } = createModule();
const path = "example.md" as FilePathWithPrefix;
module.core.databaseFileAccess.fetchEntryMeta = vi.fn(
async (_path: unknown, rev?: string): Promise<MetaEntry> =>
({
_id: "doc-id",
_rev: rev ?? "2-current",
path,
ctime: 1,
mtime: rev ? 1 : 2,
size: 0,
children: [],
type: "plain",
eden: {},
}) as unknown as MetaEntry
);
module.core.databaseFileAccess.getConflictedRevs = vi
.fn()
.mockResolvedValueOnce(["1-old"])
.mockResolvedValue([]);
await (module as any)._anyResolveConflictByNewest(path, false);
expect(module._log).toHaveBeenLastCalledWith(`${path} has been merged automatically`, LOG_LEVEL_INFO);
});
it("updates notice-level progress once every ten checked files", async () => {
const files = Array.from({ length: 11 }, (_, index) => `note-${index}.md` as FilePathWithPrefix);
const { module } = createModule(files);
const resolveByNewest = vi.spyOn(module as any, "_anyResolveConflictByNewest").mockResolvedValue(true);
await (module as any)._resolveAllConflictedFilesByNewerOnes();
expect(resolveByNewest).toHaveBeenCalledTimes(11);
expect(resolveByNewest).toHaveBeenCalledWith(files[0], false);
expect(module._log).toHaveBeenCalledWith(
"Check and Processing 10 / 11",
LOG_LEVEL_NOTICE,
"resolveAllConflictedFilesByNewerOnes"
);
expect(module._log).toHaveBeenCalledTimes(3);
});
});
describe("ModuleConflictResolver independent same-path creation", () => {
const path = "independently-created.md" as FilePathWithPrefix;
function leaf(rev: string, data: string, mtime: number) {
return {
rev,
data,
mtime,
ctime: mtime,
deleted: false,
} as any;
}
it("collapses one duplicate revision when independently created files have identical content", async () => {
const { module, resolveByDeletingRevision, tryAutoMerge } = createModule();
const leftLeaf = leaf("1-left", "Same content\n", 1000);
const rightLeaf = leaf("1-right", "Same content\n", 2000);
tryAutoMerge.mockResolvedValue({
leftRev: leftLeaf.rev,
rightRev: rightLeaf.rev,
leftLeaf,
rightLeaf,
});
const result = await module.checkConflictAndPerformAutoMerge(path);
expect(result).toBe(AUTO_MERGED);
expect(resolveByDeletingRevision).toHaveBeenCalledOnce();
expect(resolveByDeletingRevision).toHaveBeenCalledWith(path, "1-left", "same");
});
it("returns a manual diff when independently created files have different content", async () => {
const { module, resolveByDeletingRevision, tryAutoMerge } = createModule();
const leftLeaf = leaf("1-left", "Left content\n", 1000);
const rightLeaf = leaf("1-right", "Right content\n", 2000);
tryAutoMerge.mockResolvedValue({
leftRev: leftLeaf.rev,
rightRev: rightLeaf.rev,
leftLeaf,
rightLeaf,
});
const result = await module.checkConflictAndPerformAutoMerge(path);
expect(result).toMatchObject({ left: leftLeaf, right: rightLeaf });
expect(result).toHaveProperty("diff");
expect(resolveByDeletingRevision).not.toHaveBeenCalled();
});
});
describe("ModuleConflictResolver sensible merge hand-off", () => {
it("keeps an unreadable non-winner revision unresolved", async () => {
const path = "missing-conflict-body.md" as FilePathWithPrefix;
const { module, resolveByDeletingRevision, tryAutoMerge } = createModule();
tryAutoMerge.mockResolvedValue({
leftRev: "3-current",
rightRev: "2-unreadable",
leftLeaf: {
rev: "3-current",
data: "Readable current body\n",
ctime: 1,
mtime: 3,
deleted: false,
},
rightLeaf: false,
});
const result = await module.checkConflictAndPerformAutoMerge(path);
expect(result).toBe(MISSING_OR_ERROR);
expect(resolveByDeletingRevision).not.toHaveBeenCalled();
});
it("stores the merged body and removes the resolved conflict leaf", async () => {
const path = "sensible.md" as FilePathWithPrefix;
const { module, resolveByDeletingRevision, tryAutoMerge } = createModule();
tryAutoMerge.mockResolvedValue({
result: "Title\nLeft changed\nRight changed\n",
conflictedRev: "2-right",
});
const result = await module.checkConflictAndPerformAutoMerge(path);
expect(result).toBe(AUTO_MERGED);
expect(module.core.databaseFileAccess.storeContent).toHaveBeenCalledWith(
path,
"Title\nLeft changed\nRight changed\n"
);
expect(resolveByDeletingRevision).toHaveBeenCalledWith(path, "2-right", "Sensible");
});
it("commits a sensible pair before rechecking the remaining manual pair", async () => {
const path = "three-versions.md" as FilePathWithPrefix;
const { module, queueCheckFor, resolveByDeletingRevision, resolveByUserInteraction, tryAutoMerge } =
createModule();
const remainingManualPair = {
leftRev: "3-merged",
rightRev: "2-third",
leftLeaf: { rev: "3-merged", data: "Merged\n", ctime: 1, mtime: 3 },
rightLeaf: { rev: "2-third", data: "Overlapping\n", ctime: 1, mtime: 2 },
};
tryAutoMerge
.mockResolvedValueOnce({
result: "Merged\n",
conflictedRev: "2-second",
})
.mockResolvedValueOnce(remainingManualPair);
await (module as any)._resolveConflict(path);
expect(module.core.databaseFileAccess.storeContent).toHaveBeenCalledWith(path, "Merged\n");
expect(resolveByDeletingRevision).toHaveBeenCalledWith(path, "2-second", "Sensible");
expect(queueCheckFor).toHaveBeenCalledWith(path);
expect(resolveByUserInteraction).not.toHaveBeenCalled();
await (module as any)._resolveConflict(path);
expect(tryAutoMerge).toHaveBeenCalledTimes(2);
expect(resolveByUserInteraction).toHaveBeenCalledWith(
path,
expect.objectContaining({
left: remainingManualPair.leftLeaf,
right: remainingManualPair.rightLeaf,
})
);
});
});
-107
View File
@@ -1,107 +0,0 @@
import type { LiveSyncCore } from "@/main";
import { LOG_LEVEL_NOTICE } from "octagonal-wheels/common/logger";
import { fireAndForget } from "octagonal-wheels/promises";
import { AbstractModule } from "@/modules/AbstractModule";
import { $msg } from "@/common/translation";
import { copyFileDatabaseInfo } from "@/serviceFeatures/fileDatabaseInfo";
import {
REPLICATION_PROGRESS_PRESENTATIONS,
USER_INITIATED_REPLICATION_AUTHORITY,
} from "@vrtmrz/livesync-commonlib/replication";
// Separated Module for basic menu commands, which are not related to obsidian specific features. It is expected to be used in other platforms with minimal changes.
// However, it is odd that it has here at all; it really ought to be in each respective feature. It will likely be moved eventually. Until now, addCommand pointed to Obsidian's version.
export class ModuleBasicMenu extends AbstractModule {
_everyOnloadStart(): Promise<boolean> {
this.addCommand({
id: "livesync-replicate",
name: $msg("Sync now"),
callback: async () => {
await this.services.replication.replicateUserInitiated({
trigger: "manual",
progressPresentation: REPLICATION_PROGRESS_PRESENTATIONS.QUIET,
interaction: USER_INITIATED_REPLICATION_AUTHORITY,
});
},
});
this.addCommand({
id: "livesync-dump",
name: $msg("Copy database information for the active file"),
checkCallback: (checking) => {
const file = this.services.vault.getActiveFilePath();
if (!file) return false;
if (!checking) {
fireAndForget(() => copyFileDatabaseInfo(this.core, file));
}
return true;
},
});
this.addCommand({
id: "livesync-toggle",
name: "Toggle LiveSync",
callback: async () => {
if (this.settings.liveSync) {
this.settings.liveSync = false;
this._log("LiveSync Disabled.", LOG_LEVEL_NOTICE);
} else {
this.settings.liveSync = true;
this._log("LiveSync Enabled.", LOG_LEVEL_NOTICE);
}
await this.services.control.applySettings();
await this.services.setting.saveSettingData();
},
});
this.addCommand({
id: "livesync-suspendall",
name: "Toggle All Sync.",
callback: async () => {
if (this.services.appLifecycle.isSuspended()) {
this.services.appLifecycle.setSuspended(false);
this._log("Self-hosted LiveSync resumed", LOG_LEVEL_NOTICE);
} else {
this.services.appLifecycle.setSuspended(true);
this._log("Self-hosted LiveSync suspended", LOG_LEVEL_NOTICE);
}
await this.services.control.applySettings();
await this.services.setting.saveSettingData();
},
});
this.addCommand({
id: "livesync-scan-files",
name: "Scan storage and database again",
checkCallback: (checking) => {
if (!this.settings.useAdvancedMode) return false;
if (!checking) {
fireAndForget(() => this.services.vault.scanVault(true));
}
return true;
},
});
this.addCommand({
id: "livesync-runbatch",
name: $msg("Apply pending changes now"),
callback: async () => {
await this.services.fileProcessing.commitPendingFileEvents();
},
});
// TODO, Replicator is possibly one of features. It should be moved to features.
this.addCommand({
id: "livesync-abortsync",
name: "Abort synchronization immediately",
checkCallback: (checking) => {
if (!this.settings.useAdvancedMode) return false;
if (!checking) {
fireAndForget(() => this.services.replication.stopActiveTransfer());
}
return true;
},
});
return Promise.resolve(true);
}
override onBindFunction(core: LiveSyncCore, services: typeof core.services): void {
services.appLifecycle.onInitialise.addHandler(this._everyOnloadStart.bind(this));
}
}
@@ -1,201 +0,0 @@
import { describe, expect, it, vi } from "vitest";
import type { Command } from "@/deps";
import {
REPLICATION_PROGRESS_PRESENTATIONS,
USER_INITIATED_REPLICATION_AUTHORITY,
} from "@vrtmrz/livesync-commonlib/replication";
import { ModuleBasicMenu } from "./ModuleBasicMenu";
type RegisteredCommand = Command & {
checkCallback?: (checking: boolean) => boolean | void;
};
function createFixture() {
const commands: RegisteredCommand[] = [];
const settings = {
liveSync: false,
useAdvancedMode: false,
enableDebugTools: false,
};
const services = {
API: {
addLog: vi.fn(),
addCommand: vi.fn((command: RegisteredCommand) => {
commands.push(command);
return command;
}),
registerWindow: vi.fn(),
addRibbonIcon: vi.fn(),
registerProtocolHandler: vi.fn(),
},
replication: {
replicateUserInitiated: vi.fn(async () => ({ status: "completed" as const })),
stopActiveTransfer: vi.fn(async () => ({ status: "completed" as const })),
},
vault: {
getActiveFilePath: vi.fn((): string | null => "note.md"),
scanVault: vi.fn(async () => undefined),
},
control: {
applySettings: vi.fn(async () => undefined),
},
setting: {
saveSettingData: vi.fn(async () => undefined),
},
appLifecycle: {
isSuspended: vi.fn(() => false),
setSuspended: vi.fn(),
},
fileProcessing: {
commitPendingFileEvents: vi.fn(async () => true),
},
UI: {
promptCopyToClipboard: vi.fn(async (_title: string, _value: string) => true),
},
path: {
path2id: vi.fn(async () => "f:note"),
},
};
const core = {
settings,
_services: services,
services,
localDatabase: {
getDBEntry: vi.fn(async () => false),
localDatabase: {
get: vi.fn(async () => ({
_id: "f:note",
_rev: "2-current",
_conflicts: [],
path: "note.md",
ctime: 100,
mtime: 200,
size: 12,
type: "plain",
children: ["h:private-chunk-id"],
eden: {},
})),
},
getDBEntryMeta: vi.fn(async () => ({
_id: "f:note",
_rev: "2-current",
_conflicts: [],
path: "note.md",
ctime: 100,
mtime: 200,
size: 12,
type: "plain",
datatype: "plain",
data: "",
children: ["h:private-chunk-id"],
eden: {},
})),
allDocsRaw: vi.fn(async () => ({
rows: [{ id: "h:private-chunk-id", key: "h:private-chunk-id", value: { rev: "1-chunk" } }],
})),
},
storageAccess: {
isExistsIncludeHidden: vi.fn(async () => true),
statHidden: vi.fn(async () => ({ ctime: 100, mtime: 200, size: 12, type: "file" })),
},
replicator: {
terminateSync: vi.fn(),
},
};
const module = new ModuleBasicMenu(core as never);
return {
commands,
core,
module,
services,
settings,
getCommand(id: string) {
const command = commands.find((candidate) => candidate.id === id);
expect(command, `command ${id}`).toBeDefined();
return command!;
},
};
}
describe("ModuleBasicMenu command palette", () => {
it("uses clear user-facing names without changing the established command IDs", async () => {
const fixture = createFixture();
await fixture.module._everyOnloadStart();
expect(fixture.getCommand("livesync-replicate").name).toBe("Sync now");
expect(fixture.getCommand("livesync-runbatch").name).toBe("Apply pending changes now");
});
it("keeps Sync now progress quiet while retaining failure-recovery authority", async () => {
const fixture = createFixture();
await fixture.module._everyOnloadStart();
await fixture.getCommand("livesync-replicate").callback?.();
expect(fixture.services.replication.replicateUserInitiated).toHaveBeenCalledWith({
trigger: "manual",
progressPresentation: REPLICATION_PROGRESS_PRESENTATIONS.QUIET,
interaction: USER_INITIATED_REPLICATION_AUTHORITY,
});
});
it("keeps maintenance commands out of the normal palette", async () => {
const fixture = createFixture();
await fixture.module._everyOnloadStart();
expect(fixture.getCommand("livesync-scan-files").checkCallback?.(true)).toBe(false);
expect(fixture.getCommand("livesync-abortsync").checkCallback?.(true)).toBe(false);
fixture.settings.useAdvancedMode = true;
expect(fixture.getCommand("livesync-scan-files").checkCallback?.(true)).toBe(true);
expect(fixture.getCommand("livesync-abortsync").checkCallback?.(true)).toBe(true);
});
it("routes an explicit stop through the active provider capability", async () => {
const fixture = createFixture();
fixture.settings.useAdvancedMode = true;
await fixture.module._everyOnloadStart();
expect(fixture.getCommand("livesync-abortsync").checkCallback?.(false)).toBe(true);
await vi.waitFor(() => {
expect(fixture.services.replication.stopActiveTransfer).toHaveBeenCalledOnce();
});
expect(fixture.core.replicator.terminateSync).not.toHaveBeenCalled();
});
it("keeps active-file database information available and opens it in a copy dialogue", async () => {
const fixture = createFixture();
await fixture.module._everyOnloadStart();
const command = fixture.getCommand("livesync-dump");
expect(command.name).toBe("Copy database information for the active file");
expect(command.checkCallback?.(true)).toBe(true);
command.checkCallback?.(false);
await vi.waitFor(() => {
expect(fixture.services.UI.promptCopyToClipboard).toHaveBeenCalledOnce();
});
const [title, report] = fixture.services.UI.promptCopyToClipboard.mock.calls[0];
expect(title).toBe("Database information for note.md");
expect(report).toContain("note.md");
expect(report).toContain("2-current");
expect(report).toContain("h:private-chunk-id");
expect(report).toContain("1-chunk");
expect(fixture.core.localDatabase.getDBEntry).not.toHaveBeenCalled();
});
it("hides the active-file database report when no file is active", async () => {
const fixture = createFixture();
fixture.services.vault.getActiveFilePath.mockReturnValue(null);
await fixture.module._everyOnloadStart();
expect(fixture.getCommand("livesync-dump").checkCallback?.(true)).toBe(false);
});
});
-346
View File
@@ -1,346 +0,0 @@
import {
LOG_LEVEL_INFO,
LOG_LEVEL_NOTICE,
LOG_LEVEL_VERBOSE,
Logger,
} from "@vrtmrz/livesync-commonlib/compat/common/logger";
import { EVENT_REQUEST_RUN_DOCTOR, EVENT_REQUEST_RUN_FIX_INCOMPLETE, eventHub } from "@/common/events.ts";
import { AbstractModule } from "@/modules/AbstractModule.ts";
import { $msg } from "@/common/translation";
import { performDoctorConsultation, RebuildOptions } from "@vrtmrz/livesync-commonlib/compat/common/configForDoc";
import { isValidPath } from "@/common/utils.ts";
import { isMetaEntry } from "@vrtmrz/livesync-commonlib/compat/common/types";
import {
isDeletedEntry,
isDocContentSame,
isLoadedEntry,
readAsBlob,
} from "@vrtmrz/livesync-commonlib/compat/common/utils";
import { countCompromisedChunks } from "@vrtmrz/livesync-commonlib/compat/pouchdb/negotiation";
import type { LiveSyncCore } from "@/main.ts";
import { SetupManager } from "@/modules/features/SetupManager.ts";
import { showOnboardingInvitation } from "@/serviceFeatures/setupObsidian/setupManagerHandlers.ts";
import {
runConfiguredStartupLifecycle,
runStartupEntryLifecycle,
} from "@/serviceFeatures/configuredStartupLifecycle.ts";
import { disableLegacyBulkChunkPreSend } from "@/common/compatibilitySettings.ts";
type ErrorInfo = {
path: string;
recordedSize: number;
actualSize: number;
storageSize: number;
contentMatched: boolean;
isConflicted?: boolean;
};
const INCOMPLETE_DOCUMENT_NOTICE_GROUP = "startup-integrity-check";
interface CompromisedChunkCounter {
countCompromisedChunks(): Promise<number | boolean>;
}
function hasCompromisedChunkCounter(value: object | undefined): value is CompromisedChunkCounter {
return (
value !== undefined && "countCompromisedChunks" in value && typeof value.countCompromisedChunks === "function"
);
}
export class ModuleMigration extends AbstractModule<LiveSyncCore> {
constructor(
core: LiveSyncCore,
private readonly waitForCompatibilityReview: () => Promise<void> = () => Promise.resolve()
) {
super(core);
}
async migrateUsingDoctor(skipRebuild: boolean = false, activateReason = "updated", forceRescan = false) {
const { shouldRebuild, shouldRebuildLocal, isModified, settings } = await performDoctorConsultation(
{
confirm: this.core.confirm,
translate: this.services.context.translate,
},
this.settings,
{
localRebuild: skipRebuild ? RebuildOptions.SkipEvenIfRequired : RebuildOptions.AutomaticAcceptable,
remoteRebuild: skipRebuild ? RebuildOptions.SkipEvenIfRequired : RebuildOptions.AutomaticAcceptable,
activateReason,
forceRescan,
}
);
if (isModified) {
this.settings = settings;
await this.saveSettings();
}
if (!skipRebuild) {
if (shouldRebuild) {
await this.core.rebuilder.scheduleRebuild();
this.services.appLifecycle.performRestart();
return false;
} else if (shouldRebuildLocal) {
await this.core.rebuilder.scheduleFetch();
this.services.appLifecycle.performRestart();
return false;
}
}
return true;
}
async migrateDisableBulkSend() {
if (disableLegacyBulkChunkPreSend(this.settings)) {
this._log($msg("moduleMigration.logBulkSendCorrupted"), LOG_LEVEL_NOTICE);
await this.saveSettings();
}
}
initialMessage() {
const manager = this.core.getModule(SetupManager);
showOnboardingInvitation(this.core, manager);
}
async hasIncompleteDocs(force: boolean = false): Promise<boolean> {
const incompleteDocsChecked = (await this.core.kvDB.get<boolean>("checkIncompleteDocs")) || false;
if (incompleteDocsChecked && !force) {
this._log("Incomplete docs check already done, skipping.", LOG_LEVEL_VERBOSE);
return Promise.resolve(true);
}
const noticeGroups = this.core.services.context.noticeGroups;
noticeGroups.setItem(INCOMPLETE_DOCUMENT_NOTICE_GROUP, "checking", {
message: "Checking for incomplete documents...",
});
this._log("Checking for incomplete documents...", LOG_LEVEL_VERBOSE);
try {
const errorFiles = [] as ErrorInfo[];
for await (const metaDoc of this.localDatabase.findAllNormalDocs({ conflicts: true })) {
const path = this.getPath(metaDoc);
if (!isValidPath(path)) {
continue;
}
if (!(await this.services.vault.isTargetFile(path))) {
continue;
}
if (!isMetaEntry(metaDoc)) {
continue;
}
const doc = await this.localDatabase.getDBEntryFromMeta(metaDoc);
if (!doc || !isLoadedEntry(doc)) {
continue;
}
if (isDeletedEntry(doc)) {
continue;
}
const isConflicted = metaDoc?._conflicts && metaDoc._conflicts.length > 0;
let storageFileContent;
try {
storageFileContent = await this.core.storageAccess.readHiddenFileBinary(path);
} catch (e) {
Logger(`Failed to read file ${path}: Possibly unprocessed or missing`);
Logger(e, LOG_LEVEL_VERBOSE);
continue;
}
// const storageFileBlob = createBlob(storageFileContent);
const sizeOnStorage = storageFileContent.byteLength;
const recordedSize = doc.size;
const docBlob = readAsBlob(doc);
const actualSize = docBlob.size;
if (
recordedSize !== actualSize ||
sizeOnStorage !== actualSize ||
sizeOnStorage !== recordedSize ||
isConflicted
) {
const contentMatched = await isDocContentSame(doc.data, storageFileContent);
errorFiles.push({
path,
recordedSize,
actualSize,
storageSize: sizeOnStorage,
contentMatched,
isConflicted,
});
Logger(
`Size mismatch for ${path}: ${recordedSize} (DB Recorded) , ${actualSize} (DB Stored) , ${sizeOnStorage} (Storage Stored), ${contentMatched ? "Content Matched" : "Content Mismatched"} ${isConflicted ? "Conflicted" : "Not Conflicted"}`
);
}
}
if (errorFiles.length == 0) {
Logger("No size mismatches found", LOG_LEVEL_INFO);
noticeGroups.setItem(INCOMPLETE_DOCUMENT_NOTICE_GROUP, "result", {
message: "No size mismatches found",
});
await this.core.kvDB.set("checkIncompleteDocs", true);
return Promise.resolve(true);
}
Logger(`Found ${errorFiles.length} size mismatches`, LOG_LEVEL_INFO);
noticeGroups.setItem(INCOMPLETE_DOCUMENT_NOTICE_GROUP, "result", {
message: `Found ${errorFiles.length} size mismatches`,
});
// We have to repair them following rules and situations:
// A. DB Recorded != DB Stored
// A.1. DB Recorded == Storage Stored
// Possibly recoverable from storage. Just overwrite the DB content with storage content.
// A.2. Neither
// Probably it cannot be resolved on this device. Even if the storage content is larger than DB Recorded, it possibly corrupted.
// We do not fix it automatically. Leave it as is. Possibly other device can do this.
// B. DB Recorded == DB Stored , < Storage Stored
// Very fragile, if DB Recorded size is less than Storage Stored size, we possibly repair the content (The issue was `unexpectedly shortened file`).
// We do not fix it automatically, but it will be automatically overwritten in other process.
// C. DB Recorded == DB Stored , > Storage Stored
// Probably restored by the user by resolving A or B on other device, We should overwrite the storage
// Also do not fix it automatically. It should be overwritten by replication.
const recoverable = errorFiles.filter((e) => {
return e.recordedSize === e.storageSize && !e.isConflicted;
});
const unrecoverable = errorFiles.filter((e) => {
return e.recordedSize !== e.storageSize || e.isConflicted;
});
const fileInfo = (e: (typeof errorFiles)[0]) => {
return `${e.path} (M: ${e.recordedSize}, A: ${e.actualSize}, S: ${e.storageSize}) ${e.isConflicted ? "(Conflicted)" : ""}`;
};
const messageUnrecoverable =
unrecoverable.length > 0
? $msg("moduleMigration.fix0256.messageUnrecoverable", {
filesNotRecoverable: unrecoverable.map((e) => `- ${fileInfo(e)}`).join("\n"),
})
: "";
const message = $msg("moduleMigration.fix0256.message", {
files: recoverable.map((e) => `- ${fileInfo(e)}`).join("\n"),
messageUnrecoverable,
});
const CHECK_IT_LATER = $msg("moduleMigration.fix0256.buttons.checkItLater");
const FIX = $msg("moduleMigration.fix0256.buttons.fix");
const DISMISS = $msg("moduleMigration.fix0256.buttons.DismissForever");
const ret = await this.core.confirm.askSelectStringDialogue(message, [CHECK_IT_LATER, FIX, DISMISS], {
title: $msg("moduleMigration.fix0256.title"),
defaultAction: CHECK_IT_LATER,
});
if (ret == FIX) {
for (const file of recoverable) {
// Overwrite the database with the files on the storage
const stubFile = await this.core.storageAccess.getFileStub(file.path);
if (stubFile == null) {
Logger(`Could not find stub file for ${file.path}`, LOG_LEVEL_NOTICE);
continue;
}
stubFile.stat.mtime = Date.now();
const result = await this.core.fileHandler.storeFileToDB(stubFile, true, false);
if (result) {
Logger(`Successfully restored ${file.path} from storage`);
} else {
Logger(`Failed to restore ${file.path} from storage`, LOG_LEVEL_NOTICE);
}
}
} else if (ret === DISMISS) {
// User chose to dismiss the issue
await this.core.kvDB.set("checkIncompleteDocs", true);
}
return Promise.resolve(true);
} catch (error) {
noticeGroups.setItem(INCOMPLETE_DOCUMENT_NOTICE_GROUP, "result", {
message: "The incomplete document check could not be completed.",
});
throw error;
} finally {
noticeGroups.finish(INCOMPLETE_DOCUMENT_NOTICE_GROUP);
}
}
async hasCompromisedChunks(): Promise<boolean> {
Logger(`Checking for compromised chunks...`, LOG_LEVEL_VERBOSE);
if (!this.settings.encrypt) {
// If not encrypted, we do not need to check for compromised chunks.
return true;
}
// Check local database for compromised chunks
const localCompromised = await countCompromisedChunks(this.localDatabase.localDatabase);
const remote = this.services.replicator.getActiveReplicator();
const remoteCompromised =
this.services.API.isOnline && hasCompromisedChunkCounter(remote)
? await remote.countCompromisedChunks()
: 0;
if (localCompromised === false) {
Logger(`Failed to count compromised chunks in local database`, LOG_LEVEL_NOTICE);
return false;
}
if (remoteCompromised === false) {
Logger(`Failed to count compromised chunks in remote database`, LOG_LEVEL_NOTICE);
return false;
}
if (remoteCompromised === 0 && localCompromised === 0) {
return true;
}
Logger(
`Found compromised chunks : ${localCompromised} in local, ${remoteCompromised} in remote`,
LOG_LEVEL_NOTICE
);
const title = $msg("moduleMigration.insecureChunkExist.title");
const msg = $msg("moduleMigration.insecureChunkExist.message");
const REBUILD = $msg("moduleMigration.insecureChunkExist.buttons.rebuild");
const FETCH = $msg("moduleMigration.insecureChunkExist.buttons.fetch");
const DISMISS = $msg("moduleMigration.insecureChunkExist.buttons.later");
const buttons = [REBUILD, FETCH, DISMISS];
if (remoteCompromised != 0) {
buttons.splice(buttons.indexOf(FETCH), 1);
}
const result = await this.core.confirm.askSelectStringDialogue(msg, buttons, {
title,
defaultAction: DISMISS,
timeout: 0,
});
if (result === REBUILD) {
// Rebuild the database
await this.core.rebuilder.scheduleRebuild();
this.services.appLifecycle.performRestart();
return false;
} else if (result === FETCH) {
// Fetch the latest data from remote
await this.core.rebuilder.scheduleFetch();
this.services.appLifecycle.performRestart();
return false;
} else {
// User chose to dismiss the issue
this._log($msg("moduleMigration.insecureChunkExist.laterMessage"), LOG_LEVEL_NOTICE);
}
return true;
}
async _everyOnFirstInitialize(): Promise<boolean> {
return await runConfiguredStartupLifecycle({
databaseReady: this.localDatabase.isReady,
reportDatabaseNotReady: () => this._log($msg("moduleMigration.logLocalDatabaseNotReady"), LOG_LEVEL_NOTICE),
hasCompromisedChunks: () => this.hasCompromisedChunks(),
hasIncompleteDocuments: () => this.hasIncompleteDocs(),
waitForCompatibilityReview: () => this.waitForCompatibilityReview(),
runDoctor: () => this.migrateUsingDoctor(false),
migrateBulkSend: () => this.migrateDisableBulkSend(),
});
}
_everyOnLayoutReady(): Promise<boolean> {
const shouldInitialiseDatabase = runStartupEntryLifecycle({
configured: this.settings.isConfigured === true,
inviteToOnboarding: () => this.initialMessage(),
});
if (!shouldInitialiseDatabase) return Promise.resolve(false);
eventHub.onEvent(EVENT_REQUEST_RUN_DOCTOR, async (reason) => {
await this.migrateUsingDoctor(false, reason, true);
});
eventHub.onEvent(EVENT_REQUEST_RUN_FIX_INCOMPLETE, async () => {
await this.hasIncompleteDocs(true);
});
return Promise.resolve(true);
}
override onBindFunction(core: LiveSyncCore, services: typeof core.services): void {
super.onBindFunction(core, services);
services.appLifecycle.onLayoutReady.addHandler(this._everyOnLayoutReady.bind(this));
services.appLifecycle.onFirstInitialise.addHandler(this._everyOnFirstInitialize.bind(this));
}
}
@@ -1,107 +0,0 @@
import { describe, expect, it, vi } from "vitest";
vi.mock("@/modules/features/SetupManager.ts", () => ({
SetupManager: class SetupManager {},
}));
vi.mock("@/deps.ts", () => ({}));
vi.mock("@/common/utils.ts", () => ({
isValidPath: () => true,
}));
import { ModuleMigration } from "./ModuleMigration.ts";
async function* noDocuments() {
return;
}
async function* failedDocumentScan() {
throw new Error("scan failed");
}
function createMigration(
findAllNormalDocs: typeof noDocuments | typeof failedDocumentScan = noDocuments,
settings = { sendChunksBulk: false, sendChunksBulkMaxSize: 1 }
) {
const noticeGroups = {
setItem: vi.fn(),
finish: vi.fn(() => true),
};
const services = {
API: {
addLog: vi.fn(),
addCommand: vi.fn(),
registerWindow: vi.fn(),
addRibbonIcon: vi.fn(),
registerProtocolHandler: vi.fn(),
},
context: { noticeGroups },
setting: { saveSettingData: vi.fn(async () => undefined) },
vault: { isTargetFile: vi.fn(async () => true) },
path: { getPath: vi.fn() },
};
const core = {
_services: services,
services,
kvDB: {
get: vi.fn(async () => false),
set: vi.fn(async () => undefined),
},
localDatabase: { findAllNormalDocs },
storageAccess: {},
settings,
};
return {
migration: new ModuleMigration(core as never),
noticeGroups,
saveSettingData: services.setting.saveSettingData,
};
}
describe("ModuleMigration obsolete-setting migration", () => {
it("persists the removal of an enabled automatic bulk chunk pre-send setting", async () => {
const settings = { sendChunksBulk: true, sendChunksBulkMaxSize: 16 };
const { migration, saveSettingData } = createMigration(noDocuments, settings);
await migration.migrateDisableBulkSend();
expect(settings).toEqual({ sendChunksBulk: false, sendChunksBulkMaxSize: 1 });
expect(saveSettingData).toHaveBeenCalledOnce();
});
it("does not persist an already disabled automatic bulk chunk pre-send setting", async () => {
const settings = { sendChunksBulk: false, sendChunksBulkMaxSize: 16 };
const { migration, saveSettingData } = createMigration(noDocuments, settings);
await migration.migrateDisableBulkSend();
expect(settings).toEqual({ sendChunksBulk: false, sendChunksBulkMaxSize: 16 });
expect(saveSettingData).not.toHaveBeenCalled();
});
});
describe("ModuleMigration incomplete-document notice", () => {
it("keeps the check and its result in one persistent named group", async () => {
const { migration, noticeGroups } = createMigration();
await expect(migration.hasIncompleteDocs()).resolves.toBe(true);
expect(noticeGroups.setItem).toHaveBeenNthCalledWith(1, "startup-integrity-check", "checking", {
message: "Checking for incomplete documents...",
});
expect(noticeGroups.setItem).toHaveBeenNthCalledWith(2, "startup-integrity-check", "result", {
message: "No size mismatches found",
});
expect(noticeGroups.finish).toHaveBeenCalledWith("startup-integrity-check");
});
it("finishes the group with a failure result when the scan throws", async () => {
const { migration, noticeGroups } = createMigration(failedDocumentScan);
await expect(migration.hasIncompleteDocs()).rejects.toThrow("scan failed");
expect(noticeGroups.setItem).toHaveBeenLastCalledWith("startup-integrity-check", "result", {
message: "The incomplete document check could not be completed.",
});
expect(noticeGroups.finish).toHaveBeenCalledWith("startup-integrity-check");
});
});
@@ -1,41 +0,0 @@
import { describe, expect, it, vi } from "vitest";
vi.mock("@/deps.ts", () => ({ addIcon: vi.fn() }));
import {
REPLICATION_PROGRESS_PRESENTATIONS,
USER_INITIATED_REPLICATION_AUTHORITY,
} from "@vrtmrz/livesync-commonlib/replication";
import { ModuleObsidianMenu } from "./ModuleObsidianMenu";
describe("ModuleObsidianMenu ribbon", () => {
it("retains visible progress and full interaction authority", async () => {
let runRibbonAction: (() => Promise<void>) | undefined;
const addClass = vi.fn();
const replicateUserInitiated = vi.fn(async () => ({ status: "completed" as const }));
const services = {
API: {
addLog: vi.fn(),
addCommand: vi.fn(),
registerWindow: vi.fn(),
registerProtocolHandler: vi.fn(),
addRibbonIcon: vi.fn((_icon: string, _title: string, callback: () => Promise<void>) => {
runRibbonAction = callback;
return { addClass };
}),
},
replication: { replicateUserInitiated },
};
const module = new ModuleObsidianMenu({ _services: services, services } as never);
await module._everyOnloadStart();
await runRibbonAction?.();
expect(replicateUserInitiated).toHaveBeenCalledWith({
trigger: "manual",
progressPresentation: REPLICATION_PROGRESS_PRESENTATIONS.NOTICE,
interaction: USER_INITIATED_REPLICATION_AUTHORITY,
});
expect(addClass).toHaveBeenCalledWith("livesync-ribbon-replicate");
});
});
@@ -6,12 +6,11 @@ import {
type diff_result,
type FilePathWithPrefix,
} from "@vrtmrz/livesync-commonlib/compat/common/types";
import { EVENT_CONFLICT_CANCELLED, eventHub } from "@/common/events.ts";
import { EVENT_CONFLICT_CANCELLED, EVENT_PLUGIN_UNLOADED, eventHub } from "@/common/events.ts";
import { promiseWithResolvers } from "octagonal-wheels/promises";
import { POSTPONED, type MergeDialogResult } from "@/serviceFeatures/interactiveConflictResolution/types";
export const POSTPONED = Symbol("postponed");
export type MergeDialogResult = typeof CANCELLED | typeof POSTPONED | typeof LEAVE_TO_SUBSEQUENT | string;
export { POSTPONED, type MergeDialogResult };
export type ConflictResolveModalOptions = {
readOnly?: boolean;
@@ -35,7 +34,7 @@ export class ConflictResolveModal extends Modal {
readOnly: boolean = false;
localName: string = "Base";
remoteName: string = "Conflicted";
offEvent?: ReturnType<typeof eventHub.onEvent>;
private eventSubscriptions?: AbortController;
currentDiffIndex = -1;
diffView!: HTMLDivElement;
diffNavIndicator!: HTMLSpanElement;
@@ -112,20 +111,31 @@ export class ConflictResolveModal extends Modal {
override onOpen() {
const { contentEl } = this;
if (this.offEvent) {
this.offEvent();
}
this.eventSubscriptions?.abort();
const eventSubscriptions = new AbortController();
this.eventSubscriptions = eventSubscriptions;
eventHub.onceEvent(
EVENT_PLUGIN_UNLOADED,
() => {
this.sendResponse(CANCELLED);
},
{ signal: eventSubscriptions.signal }
);
if (!this.readOnly) {
// Cancel an older dialogue for this path before subscribing this
// instance. Emitting after subscription would close the replacement
// itself; the instance-owned result promise then completes the older
// caller even when it only begins waiting after this event.
eventHub.emitEvent(EVENT_CONFLICT_CANCELLED, this.filename);
this.offEvent = eventHub.onEvent(EVENT_CONFLICT_CANCELLED, (path) => {
if (path === this.filename) {
this.sendResponse(CANCELLED);
}
});
eventHub.onEvent(
EVENT_CONFLICT_CANCELLED,
(path) => {
if (path === this.filename) {
this.sendResponse(CANCELLED);
}
},
{ signal: eventSubscriptions.signal }
);
}
this.titleEl.setText(this.title);
contentEl.empty();
@@ -216,9 +226,8 @@ export class ConflictResolveModal extends Modal {
override onClose() {
const { contentEl } = this;
contentEl.empty();
if (this.offEvent) {
this.offEvent();
}
this.eventSubscriptions?.abort();
this.eventSubscriptions = undefined;
if (this.consumed) {
return;
}
@@ -1,6 +1,7 @@
import { describe, expect, it, vi } from "vitest";
import { POSTPONED, ConflictResolveModal } from "./ConflictResolveModal.ts";
import { CANCELLED, type diff_result, type FilePathWithPrefix } from "@vrtmrz/livesync-commonlib/compat/common/types";
import { EVENT_CONFLICT_CANCELLED, EVENT_PLUGIN_UNLOADED, eventHub } from "@/common/events.ts";
vi.mock("@/deps.ts", () => ({
App: class App {},
@@ -24,12 +25,7 @@ vi.mock("@/deps.ts", () => ({
};
element.createDiv = vi.fn(() => this.createElement());
element.createEl = vi.fn((_tag: string, _options?: unknown, callback?: (child: unknown) => void) => {
if (
_tag === "button" &&
typeof _options === "object" &&
_options !== null &&
"text" in _options
) {
if (_tag === "button" && typeof _options === "object" && _options !== null && "text" in _options) {
this.createdButtons.push(String((_options as { text: unknown }).text));
}
const child = this.createElement();
@@ -93,23 +89,50 @@ describe("ConflictResolveModal result lifecycle", () => {
expect(replacementState).toBe("still-open");
});
it("closes for an external resolution of the same file and ignores other files", async () => {
const filename = "resolved-elsewhere.md" as FilePathWithPrefix;
const modal = new ConflictResolveModal({} as never, filename, conflict);
modal.onOpen();
eventHub.emitEvent(EVENT_CONFLICT_CANCELLED, "other.md" as FilePathWithPrefix);
const stateAfterOtherFile = await Promise.race([
modal.waitForResult(),
new Promise<"still-open">((resolve) => setTimeout(() => resolve("still-open"), 25)),
]);
eventHub.emitEvent(EVENT_CONFLICT_CANCELLED, filename);
await expect(modal.waitForResult()).resolves.toBe(CANCELLED);
expect(stateAfterOtherFile).toBe("still-open");
});
it("closes and completes its result when the plug-in unloads", async () => {
const modal = new ConflictResolveModal(
{} as never,
"open-during-unload.md" as FilePathWithPrefix,
conflict
);
modal.onOpen();
eventHub.emitEvent(EVENT_PLUGIN_UNLOADED);
const result = await Promise.race([
modal.waitForResult(),
new Promise<"timed-out">((resolve) => setTimeout(() => resolve("timed-out"), 25)),
]);
modal.sendResponse(CANCELLED);
expect(result).toBe(CANCELLED);
});
it("renders a read-only comparison with no resolution actions", () => {
const ReadOnlyModal = ConflictResolveModal as unknown as new (
...args: unknown[]
) => ConflictResolveModal & { createdButtons: string[] };
const modal = new ReadOnlyModal(
{},
"repair-preview.md",
conflict,
false,
undefined,
{
readOnly: true,
title: "Vault and database revision",
localName: "Vault file",
remoteName: "Database revision",
}
);
const modal = new ReadOnlyModal({}, "repair-preview.md", conflict, false, undefined, {
readOnly: true,
title: "Vault and database revision",
localName: "Vault file",
remoteName: "Database revision",
});
modal.onOpen();
@@ -124,9 +147,7 @@ describe("ConflictResolveModal result lifecycle", () => {
it("does not cancel an active conflict dialogue when a read-only comparison opens for the same file", async () => {
const filename = "repair-alongside-conflict.md" as FilePathWithPrefix;
const previous = new ConflictResolveModal({} as never, filename, conflict);
const ReadOnlyModal = ConflictResolveModal as unknown as new (
...args: unknown[]
) => ConflictResolveModal;
const ReadOnlyModal = ConflictResolveModal as unknown as new (...args: unknown[]) => ConflictResolveModal;
const comparison = new ReadOnlyModal({}, filename, conflict, false, undefined, {
readOnly: true,
});
@@ -1,276 +0,0 @@
import {
CANCELLED,
LEAVE_TO_SUBSEQUENT,
LOG_LEVEL_INFO,
LOG_LEVEL_NOTICE,
LOG_LEVEL_VERBOSE,
MISSING_OR_ERROR,
type DocumentID,
type FilePathWithPrefix,
type diff_result,
} from "@vrtmrz/livesync-commonlib/compat/common/types";
import { ConflictResolveModal, POSTPONED } from "./InteractiveConflictResolving/ConflictResolveModal.ts";
import { AbstractObsidianModule } from "@/modules/AbstractObsidianModule.ts";
import { displayRev } from "@/common/utils.ts";
import { fireAndForget } from "octagonal-wheels/promises";
import { serialized } from "octagonal-wheels/concurrency/lock";
import type { LiveSyncCore } from "@/main.ts";
import { EVENT_CONFLICT_CANCELLED, EVENT_ON_UNRESOLVED_ERROR, eventHub } from "@/common/events.ts";
import { $msg } from "@/common/translation.ts";
import type { Editor, MarkdownFileInfo, MarkdownView } from "@/deps.ts";
import { NO_INTERACTION } from "@vrtmrz/livesync-commonlib/replication";
export class ModuleInteractiveConflictResolver extends AbstractObsidianModule {
private postponedConflictEpisodes = new Set<FilePathWithPrefix>();
private async getConflictVersionCount(filename: FilePathWithPrefix): Promise<number | undefined> {
try {
const conflictCount = (await this.core.databaseFileAccess.getConflictedRevs(filename)).length;
return conflictCount === 0 ? 0 : conflictCount + 1;
} catch (error) {
this._log(`Could not inspect the conflict state of ${filename}`, LOG_LEVEL_VERBOSE);
this._log(error, LOG_LEVEL_VERBOSE);
return undefined;
}
}
private async getActiveConflictMessages(): Promise<string[]> {
const filename = this.services.vault.getActiveFilePath();
if (!filename) return [];
const versionCount = await this.getConflictVersionCount(filename);
if (versionCount === 0) {
this.postponedConflictEpisodes.delete(filename);
return [];
}
if (versionCount !== undefined && versionCount >= 3) {
return [
$msg("This file has ${COUNT} unresolved versions. They will be reviewed one pair at a time.", {
COUNT: `${versionCount}`,
}),
];
}
if (versionCount === 2 || this.postponedConflictEpisodes.has(filename)) {
return [$msg("This file has unresolved conflicts.")];
}
return [];
}
private async refreshConflictState(filename: FilePathWithPrefix): Promise<void> {
if ((await this.getConflictVersionCount(filename)) === 0) {
this.postponedConflictEpisodes.delete(filename);
}
eventHub.emitEvent(EVENT_ON_UNRESOLVED_ERROR);
}
private async requestConflictResolution(filename: FilePathWithPrefix): Promise<void> {
this.postponedConflictEpisodes.delete(filename);
eventHub.emitEvent(EVENT_ON_UNRESOLVED_ERROR);
await this.services.conflict.queueCheckFor(filename);
await this.services.conflict.ensureAllProcessed();
}
_everyOnloadStart(): Promise<boolean> {
this.addCommand({
id: "livesync-checkdoc-conflicted",
name: "Resolve if conflicted.",
editorCallback: (editor: Editor, view: MarkdownView | MarkdownFileInfo) => {
const file = view.file;
if (!file) return;
void this.requestConflictResolution(file.path as FilePathWithPrefix);
},
});
this.addCommand({
id: "livesync-conflictcheck",
name: "Pick a file to resolve conflict",
callback: async () => {
await this.pickFileForResolve();
},
});
this.addCommand({
id: "livesync-all-conflictcheck",
name: "Resolve all conflicted files",
callback: async () => {
await this.allConflictCheck();
},
});
return Promise.resolve(true);
}
async _anyResolveConflictByUI(filename: FilePathWithPrefix, conflictCheckResult: diff_result): Promise<boolean> {
// UI for resolving conflicts should one-by-one.
return await serialized(`conflict-resolve-ui`, async () => {
if (this.postponedConflictEpisodes.has(filename)) {
this._log(`Merge: Postponed ${filename}`, LOG_LEVEL_VERBOSE);
eventHub.emitEvent(EVENT_ON_UNRESOLVED_ERROR);
return false;
}
this._log("Merge:open conflict dialog", LOG_LEVEL_VERBOSE);
const dialog = new ConflictResolveModal(this.app, filename, conflictCheckResult);
dialog.open();
const selected = await dialog.waitForResult();
if (selected === POSTPONED) {
this.postponedConflictEpisodes.add(filename);
eventHub.emitEvent(EVENT_ON_UNRESOLVED_ERROR);
this._log(`Merge: Postponed ${filename}`, LOG_LEVEL_INFO);
return false;
}
if (selected === CANCELLED) {
// Cancelled by UI, or another conflict.
this._log(`Merge: Cancelled ${filename}`, LOG_LEVEL_INFO);
return false;
}
const testDoc = await this.localDatabase.getDBEntry(filename, { conflicts: true }, false, true, true);
if (testDoc === false) {
this._log(`Merge: Could not read ${filename} from the local database`, LOG_LEVEL_VERBOSE);
return false;
}
if (!testDoc._conflicts || testDoc._conflicts.length === 0) {
this._log(`Merge: Nothing to do ${filename}`, LOG_LEVEL_VERBOSE);
await this.refreshConflictState(filename);
return false;
}
if (
testDoc._rev !== conflictCheckResult.left.rev ||
!testDoc._conflicts.includes(conflictCheckResult.right.rev)
) {
this._log(
`Merge: The compared revisions changed while the dialogue was open: ${filename}`,
LOG_LEVEL_INFO
);
await this.refreshConflictState(filename);
await this.services.conflict.queueCheckFor(filename);
return false;
}
const toDelete = selected;
// const toKeep = conflictCheckResult.left.rev != toDelete ? conflictCheckResult.left.rev : conflictCheckResult.right.rev;
if (toDelete === LEAVE_TO_SUBSEQUENT) {
// Concatenate both conflicted revisions.
// Create a new file by concatenating both conflicted revisions.
const p = conflictCheckResult.diff.map((e) => e[1]).join("");
const delRev = conflictCheckResult.right.rev;
if (!(await this.core.databaseFileAccess.storeContent(filename, p))) {
this._log(`Concatenated content cannot be stored:${filename}`, LOG_LEVEL_NOTICE);
return false;
}
// 2. As usual, delete the conflicted revision and if there are no conflicts, write the resolved content to the storage.
if (
(await this.services.conflict.resolveByDeletingRevision(filename, delRev, "UI Concatenated")) ==
MISSING_OR_ERROR
) {
this._log(
`Concatenated saved, but cannot delete conflicted revisions: ${filename}, (${displayRev(delRev)})`,
LOG_LEVEL_NOTICE
);
return false;
}
} else if (
typeof toDelete === "string" &&
(toDelete === conflictCheckResult.left.rev || toDelete === conflictCheckResult.right.rev)
) {
// Select one of the conflicted revision to delete.
if (
(await this.services.conflict.resolveByDeletingRevision(filename, toDelete, "UI Selected")) ==
MISSING_OR_ERROR
) {
this._log(`Merge: Something went wrong: ${filename}, (${toDelete})`, LOG_LEVEL_NOTICE);
return false;
}
} else {
this._log(`Merge: Something went wrong: ${filename}, (${String(toDelete)})`, LOG_LEVEL_NOTICE);
return false;
}
// In here, some merge has been processed.
// So we have to run replication if configured.
// TODO: Make this is as a event request
if (this.settings.syncAfterMerge && !this.services.appLifecycle.isSuspended()) {
await this.services.replication.replicateUnattendedByEvent({
trigger: "merge",
interaction: NO_INTERACTION,
});
}
// And, check it again.
await this.services.conflict.queueCheckFor(filename);
return false;
});
}
async allConflictCheck() {
let notifyIfEmpty = true;
while (await this.pickFileForResolve(notifyIfEmpty)) {
notifyIfEmpty = false;
}
}
async pickFileForResolve(notifyIfEmpty = true) {
const notes: { id: DocumentID; path: FilePathWithPrefix; dispPath: string; mtime: number }[] = [];
for await (const doc of this.localDatabase.findAllDocs({ conflicts: true })) {
if (!("_conflicts" in doc)) continue;
notes.push({
id: doc._id,
path: this.getPath(doc),
dispPath: this.getPathWithoutPrefix(doc),
mtime: doc.mtime,
});
}
notes.sort((a, b) => b.mtime - a.mtime);
const notesList = notes.map((e) => e.dispPath);
if (notesList.length == 0) {
if (notifyIfEmpty) {
this._log("There are no conflicted documents", LOG_LEVEL_NOTICE);
}
return false;
}
const target = await this.core.confirm.askSelectString("File to resolve conflict", notesList);
if (target) {
const targetItem = notes.find((e) => e.dispPath == target)!;
await this.requestConflictResolution(targetItem.path);
return true;
}
return false;
}
async _allScanStat(): Promise<boolean> {
const notes: { path: string; mtime: number }[] = [];
this._log(`Checking conflicted files`, LOG_LEVEL_VERBOSE);
try {
for await (const doc of this.localDatabase.findAllDocs({ conflicts: true })) {
if (!("_conflicts" in doc)) continue;
notes.push({ path: this.getPath(doc), mtime: doc.mtime });
}
if (notes.length > 0) {
this.core.confirm.askInPopup(
`conflicting-detected-on-safety`,
`Some files have been left conflicted! Press {HERE} to resolve them, or you can do it later by "Pick a file to resolve conflict`,
(anchor) => {
anchor.text = "HERE";
anchor.addEventListener("click", () => {
fireAndForget(() => this.allConflictCheck());
});
}
);
this._log(
`Some files have been left conflicted! Please resolve them by "Pick a file to resolve conflict". The list is written in the log.`,
LOG_LEVEL_VERBOSE
);
for (const note of notes) {
this._log(`Conflicted: ${note.path}`);
}
} else {
this._log(`There are no conflicting files`, LOG_LEVEL_VERBOSE);
}
} catch (e) {
this._log(`Error while scanning conflicted files...`, LOG_LEVEL_NOTICE);
this._log(e, LOG_LEVEL_VERBOSE);
return false;
}
return true;
}
override onBindFunction(core: LiveSyncCore, services: typeof core.services): void {
services.appLifecycle.onScanningStartupIssues.addHandler(this._allScanStat.bind(this));
services.appLifecycle.onInitialise.addHandler(this._everyOnloadStart.bind(this));
services.appLifecycle.getUnresolvedMessages.addHandler(this.getActiveConflictMessages.bind(this));
services.conflict.resolveByUserInteraction.addHandler(this._anyResolveConflictByUI.bind(this));
eventHub.onEvent(EVENT_CONFLICT_CANCELLED, (filename) => {
fireAndForget(() => this.refreshConflictState(filename));
});
}
}
@@ -1,283 +0,0 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import {
AUTO_MERGED,
CANCELLED,
DEFAULT_SETTINGS,
LEAVE_TO_SUBSEQUENT,
LOG_LEVEL_NOTICE,
type FilePathWithPrefix,
type diff_result,
} from "@vrtmrz/livesync-commonlib/compat/common/types";
const modalState = vi.hoisted(() => ({
constructed: 0,
result: undefined as unknown,
postponed: Symbol("postponed"),
}));
vi.mock("@/common/utils.ts", () => ({
displayRev: (revision: string) => revision,
}));
vi.mock("./InteractiveConflictResolving/ConflictResolveModal.ts", () => ({
POSTPONED: modalState.postponed,
ConflictResolveModal: class ConflictResolveModal {
constructor() {
modalState.constructed++;
}
open() {}
async waitForResult() {
return modalState.result;
}
},
}));
import { ModuleInteractiveConflictResolver } from "./ModuleInteractiveConflictResolver.ts";
const path = "note.md" as FilePathWithPrefix;
const conflict: diff_result = {
left: { rev: "2-left", data: "left", ctime: 1, mtime: 2 },
right: { rev: "2-right", data: "right", ctime: 1, mtime: 2 },
diff: [],
};
async function* documents(items: unknown[]) {
for (const item of items) {
yield item;
}
}
function createModule(conflictedRevisions: string[] = ["2-right"]) {
const handlers = {
unresolvedMessages: undefined as undefined | (() => Promise<string[]>),
};
const services = {
API: {
addLog: vi.fn(),
addCommand: vi.fn(),
registerWindow: vi.fn(),
addRibbonIcon: vi.fn(),
registerProtocolHandler: vi.fn(),
},
appLifecycle: {
getUnresolvedMessages: {
addHandler: vi.fn((handler: () => Promise<string[]>) => {
handlers.unresolvedMessages = handler;
}),
},
onScanningStartupIssues: { addHandler: vi.fn() },
onInitialise: { addHandler: vi.fn() },
isSuspended: vi.fn(() => false),
},
conflict: {
resolveByUserInteraction: { addHandler: vi.fn() },
resolveByDeletingRevision: vi.fn(async () => AUTO_MERGED),
queueCheckFor: vi.fn(async () => undefined),
ensureAllProcessed: vi.fn(async () => true),
},
replication: {
replicateUnattendedByEvent: vi.fn(async () => ({ status: "completed" as const })),
},
vault: { getActiveFilePath: vi.fn(() => path) },
path: { getPath: vi.fn((entry: { path: FilePathWithPrefix }) => entry.path) },
};
const core = {
_services: services,
services,
settings: { ...DEFAULT_SETTINGS, syncAfterMerge: false },
localDatabase: {
getDBEntry: vi.fn(async (): Promise<false | { _rev: string; _conflicts?: string[] }> => false),
findAllDocs: vi.fn(() => documents([])),
},
databaseFileAccess: {
getConflictedRevs: vi.fn(async () => conflictedRevisions),
storeContent: vi.fn(async () => true),
},
confirm: {
askSelectString: vi.fn(async (): Promise<string | undefined> => undefined),
},
};
const plugin = { app: {} };
const module = new ModuleInteractiveConflictResolver(plugin as never, core as never);
module._log = vi.fn();
return { core, handlers, module, services };
}
describe("ModuleInteractiveConflictResolver postponement", () => {
beforeEach(() => {
modalState.constructed = 0;
modalState.result = modalState.postponed;
});
it("does not reopen an unchanged conflict after the user chooses Not now", async () => {
const { module } = createModule();
await module._anyResolveConflictByUI(path, conflict);
await module._anyResolveConflictByUI(path, conflict);
expect(modalState.constructed).toBe(1);
});
it("does not treat cancellation by another conflict dialogue as Not now", async () => {
const { module } = createModule();
modalState.result = CANCELLED;
await module._anyResolveConflictByUI(path, conflict);
await module._anyResolveConflictByUI(path, conflict);
expect(modalState.constructed).toBe(2);
});
it("allows an explicit resolution request to reopen a postponed conflict", async () => {
const { module, services } = createModule();
await module._anyResolveConflictByUI(path, conflict);
await (module as any).requestConflictResolution(path);
await module._anyResolveConflictByUI(path, conflict);
expect(services.conflict.queueCheckFor).toHaveBeenCalledWith(path);
expect(services.conflict.ensureAllProcessed).toHaveBeenCalledOnce();
expect(modalState.constructed).toBe(2);
});
it("opens a later conflict after the postponed conflict episode has resolved", async () => {
const conflictedRevisions = ["2-right"];
const { module } = createModule(conflictedRevisions);
await module._anyResolveConflictByUI(path, conflict);
conflictedRevisions.splice(0);
await (module as any).refreshConflictState(path);
conflictedRevisions.push("4-later");
await module._anyResolveConflictByUI(path, conflict);
expect(modalState.constructed).toBe(2);
});
it("contributes the active conflict to the existing unresolved-message display", async () => {
const { core, handlers, module, services } = createModule();
module.onBindFunction(core as never, services as never);
expect(services.appLifecycle.getUnresolvedMessages.addHandler).toHaveBeenCalledOnce();
await expect(handlers.unresolvedMessages?.()).resolves.toEqual(["This file has unresolved conflicts."]);
});
it("removes the active warning once the conflict has resolved", async () => {
const conflictedRevisions = ["2-right"];
const { core, handlers, module, services } = createModule(conflictedRevisions);
module.onBindFunction(core as never, services as never);
await expect(handlers.unresolvedMessages?.()).resolves.toEqual(["This file has unresolved conflicts."]);
conflictedRevisions.splice(0);
await expect(handlers.unresolvedMessages?.()).resolves.toEqual([]);
});
it("reports the number of live versions and reduces it after each resolved pair", async () => {
const conflictedRevisions = ["2-second", "2-third"];
const { core, handlers, module, services } = createModule(conflictedRevisions);
module.onBindFunction(core as never, services as never);
await expect(handlers.unresolvedMessages?.()).resolves.toEqual([
"This file has 3 unresolved versions. They will be reviewed one pair at a time.",
]);
conflictedRevisions.shift();
await (module as any).refreshConflictState(path);
await expect(handlers.unresolvedMessages?.()).resolves.toEqual(["This file has unresolved conflicts."]);
conflictedRevisions.shift();
await (module as any).refreshConflictState(path);
await expect(handlers.unresolvedMessages?.()).resolves.toEqual([]);
});
it("reconstructs the remaining pair after a postponed session is restarted", async () => {
const conflictedRevisions = ["2-second", "2-third"];
const firstSession = createModule(conflictedRevisions);
await firstSession.module._anyResolveConflictByUI(path, conflict);
conflictedRevisions.shift();
const restartedSession = createModule(conflictedRevisions);
restartedSession.module.onBindFunction(restartedSession.core as never, restartedSession.services as never);
await expect(restartedSession.handlers.unresolvedMessages?.()).resolves.toEqual([
"This file has unresolved conflicts.",
]);
await restartedSession.module._anyResolveConflictByUI(path, {
left: { rev: "3-merged", data: "merged", ctime: 1, mtime: 3 },
right: { rev: "2-third", data: "third", ctime: 1, mtime: 2 },
diff: [],
});
expect(modalState.constructed).toBe(2);
});
it("deletes the compared right leaf when concatenating a deterministically selected pair", async () => {
const { core, module, services } = createModule(["2-unrelated", "2-right"]);
modalState.result = LEAVE_TO_SUBSEQUENT;
core.localDatabase.getDBEntry.mockResolvedValue({
_rev: "2-left",
_conflicts: ["2-unrelated", "2-right"],
});
await module._anyResolveConflictByUI(path, conflict);
expect(core.databaseFileAccess.storeContent).toHaveBeenCalledWith(path, "");
expect(services.conflict.resolveByDeletingRevision).toHaveBeenCalledWith(path, "2-right", "UI Concatenated");
});
it("rechecks the live leaves instead of applying a stale dialogue selection", async () => {
const { core, module, services } = createModule(["2-other"]);
modalState.result = "2-right";
core.localDatabase.getDBEntry.mockResolvedValue({
_rev: "3-new-winner",
_conflicts: ["2-other"],
});
await module._anyResolveConflictByUI(path, conflict);
expect(services.conflict.resolveByDeletingRevision).not.toHaveBeenCalled();
expect(services.conflict.queueCheckFor).toHaveBeenCalledWith(path);
});
});
describe("ModuleInteractiveConflictResolver file selection", () => {
beforeEach(() => {
modalState.constructed = 0;
modalState.result = modalState.postponed;
});
it("does not show a no-conflicts notice when an automatic repeat reaches its normal end", async () => {
const { core, module } = createModule();
core.localDatabase.findAllDocs
.mockImplementationOnce(() =>
documents([
{
_id: "note-id",
_rev: "2-left",
_conflicts: ["2-right"],
path,
mtime: 2,
},
])
)
.mockImplementationOnce(() => documents([]));
core.confirm.askSelectString.mockResolvedValue(path);
await module.allConflictCheck();
expect(core.confirm.askSelectString).toHaveBeenCalledOnce();
expect(module._log).not.toHaveBeenCalledWith("There are no conflicted documents", LOG_LEVEL_NOTICE);
});
it("shows one no-conflicts notice for an explicit selection request which starts empty", async () => {
const { module } = createModule();
await module.pickFileForResolve();
expect(module._log).toHaveBeenCalledTimes(1);
expect(module._log).toHaveBeenCalledWith("There are no conflicted documents", LOG_LEVEL_NOTICE);
});
});
+149
View File
@@ -0,0 +1,149 @@
import { LOG_LEVEL_NOTICE } from "octagonal-wheels/common/logger";
import { fireAndForget } from "octagonal-wheels/promises";
import type { NecessaryServices } from "@vrtmrz/livesync-commonlib/compat/interfaces/ServiceModule";
import { createInstanceLogFunction } from "@vrtmrz/livesync-commonlib/compat/services/lib/logUtils";
import {
REPLICATION_PROGRESS_PRESENTATIONS,
USER_INITIATED_REPLICATION_AUTHORITY,
} from "@vrtmrz/livesync-commonlib/replication";
import { $msg } from "@/common/translation";
import { copyFileDatabaseInfo, type FileDatabaseInfoCore } from "@/serviceFeatures/fileDatabaseInfo";
/**
* Services required by the platform-independent command palette actions.
*
* The database report deliberately receives a structural adapter rather than
* the whole host. This keeps the report helper independent from the core while
* retaining the same local database, storage, settings, path, and UI sources.
*/
export type BasicCommandsHost = NecessaryServices<
| "API"
| "appLifecycle"
| "control"
| "database"
| "fileProcessing"
| "path"
| "replication"
| "setting"
| "UI"
| "vault",
"storageAccess"
>;
function createFileDatabaseInfoCore(host: BasicCommandsHost): FileDatabaseInfoCore {
const { services, serviceModules } = host;
return {
localDatabase: services.database.localDatabase,
services: {
path: services.path,
UI: services.UI,
},
settings: services.setting.currentSettings(),
storageAccess: serviceModules.storageAccess,
};
}
/**
* Register the platform-independent command palette actions.
*
* Registration remains tied to `onInitialise`, matching the legacy module's
* timing and allowing hosts to compose the feature before the lifecycle runs.
*/
export function useBasicCommandsFeature(host: BasicCommandsHost): void {
const { services } = host;
const log = createInstanceLogFunction("SF:BasicCommands", services.API);
services.appLifecycle.onInitialise.addHandler(() => {
services.API.addCommand({
id: "livesync-replicate",
name: $msg("Sync now"),
callback: async () => {
await services.replication.replicateUserInitiated({
trigger: "manual",
progressPresentation: REPLICATION_PROGRESS_PRESENTATIONS.QUIET,
interaction: USER_INITIATED_REPLICATION_AUTHORITY,
});
},
});
services.API.addCommand({
id: "livesync-dump",
name: $msg("Copy database information for the active file"),
checkCallback: (checking) => {
const file = services.vault.getActiveFilePath();
if (!file) return false;
if (!checking) {
fireAndForget(() => copyFileDatabaseInfo(createFileDatabaseInfoCore(host), file));
}
return true;
},
});
services.API.addCommand({
id: "livesync-toggle",
name: "Toggle LiveSync",
callback: async () => {
const settings = services.setting.currentSettings();
if (settings.liveSync) {
settings.liveSync = false;
log("LiveSync Disabled.", LOG_LEVEL_NOTICE);
} else {
settings.liveSync = true;
log("LiveSync Enabled.", LOG_LEVEL_NOTICE);
}
await services.control.applySettings();
await services.setting.saveSettingData();
},
});
services.API.addCommand({
id: "livesync-suspendall",
name: "Toggle All Sync.",
callback: async () => {
if (services.appLifecycle.isSuspended()) {
services.appLifecycle.setSuspended(false);
log("Self-hosted LiveSync resumed", LOG_LEVEL_NOTICE);
} else {
services.appLifecycle.setSuspended(true);
log("Self-hosted LiveSync suspended", LOG_LEVEL_NOTICE);
}
await services.control.applySettings();
await services.setting.saveSettingData();
},
});
services.API.addCommand({
id: "livesync-scan-files",
name: "Scan storage and database again",
checkCallback: (checking) => {
if (!services.setting.currentSettings().useAdvancedMode) return false;
if (!checking) {
fireAndForget(() => services.vault.scanVault(true));
}
return true;
},
});
services.API.addCommand({
id: "livesync-runbatch",
name: $msg("Apply pending changes now"),
callback: async () => {
await services.fileProcessing.commitPendingFileEvents();
},
});
services.API.addCommand({
id: "livesync-abortsync",
name: "Abort synchronization immediately",
checkCallback: (checking) => {
if (!services.setting.currentSettings().useAdvancedMode) return false;
if (!checking) {
fireAndForget(() => services.replication.stopActiveTransfer());
}
return true;
},
});
return Promise.resolve(true);
});
}
@@ -0,0 +1,247 @@
import { describe, expect, it, vi } from "vitest";
import type { ICommandCompat } from "@vrtmrz/livesync-commonlib/compat/services/base/IService";
import {
REPLICATION_PROGRESS_PRESENTATIONS,
USER_INITIATED_REPLICATION_AUTHORITY,
} from "@vrtmrz/livesync-commonlib/replication";
import { copyFileDatabaseInfo } from "./fileDatabaseInfo";
import { useBasicCommandsFeature, type BasicCommandsHost } from "./basicCommands";
vi.mock("./fileDatabaseInfo", () => ({
copyFileDatabaseInfo: vi.fn(async () => true),
}));
type RegisteredCommand = ICommandCompat & {
checkCallback?: (checking: boolean) => boolean | void;
};
function createFixture() {
const commands: RegisteredCommand[] = [];
const initialiseHandlers: Array<() => Promise<unknown>> = [];
const settings = {
liveSync: false,
useAdvancedMode: false,
};
const api = {
addCommand: vi.fn((command: RegisteredCommand) => {
commands.push(command);
return command;
}),
addLog: vi.fn(),
};
const services = {
API: api,
appLifecycle: {
onInitialise: {
addHandler: vi.fn((handler: () => Promise<unknown>) => {
initialiseHandlers.push(handler);
}),
},
isSuspended: vi.fn(() => false),
setSuspended: vi.fn(),
},
control: {
applySettings: vi.fn(async () => undefined),
},
database: {
localDatabase: { databaseMarker: "local" },
},
fileProcessing: {
commitPendingFileEvents: vi.fn(async () => true),
},
path: {
path2id: vi.fn(async () => "f:note"),
},
replication: {
replicateUserInitiated: vi.fn(async () => ({ status: "completed" as const })),
stopActiveTransfer: vi.fn(async () => ({ status: "completed" as const })),
},
setting: {
currentSettings: vi.fn(() => settings),
saveSettingData: vi.fn(async () => undefined),
},
UI: {
promptCopyToClipboard: vi.fn(async () => true),
},
vault: {
getActiveFilePath: vi.fn((): string | undefined => "note.md"),
scanVault: vi.fn(async () => true),
},
};
const serviceModules = {
storageAccess: {
isExistsIncludeHidden: vi.fn(async () => true),
statHidden: vi.fn(async () => ({ ctime: 0, mtime: 0, size: 0, type: "file" })),
},
};
const host = { services, serviceModules } as unknown as BasicCommandsHost;
return {
api,
commands,
host,
initialiseHandlers,
services,
serviceModules,
settings,
getCommand(id: string) {
const command = commands.find((candidate) => candidate.id === id);
expect(command, `command ${id}`).toBeDefined();
return command!;
},
};
}
async function initialise(fixture: ReturnType<typeof createFixture>) {
useBasicCommandsFeature(fixture.host);
expect(fixture.initialiseHandlers).toHaveLength(1);
expect(fixture.commands).toHaveLength(0);
await fixture.initialiseHandlers[0]?.();
}
describe("useBasicCommandsFeature", () => {
it("registers all established commands only when initialisation runs", async () => {
const fixture = createFixture();
useBasicCommandsFeature(fixture.host);
expect(fixture.services.appLifecycle.onInitialise.addHandler).toHaveBeenCalledOnce();
expect(fixture.api.addCommand).not.toHaveBeenCalled();
await fixture.initialiseHandlers[0]?.();
expect(fixture.commands.map(({ id }) => id)).toEqual([
"livesync-replicate",
"livesync-dump",
"livesync-toggle",
"livesync-suspendall",
"livesync-scan-files",
"livesync-runbatch",
"livesync-abortsync",
]);
expect(fixture.getCommand("livesync-replicate").name).toBe("Sync now");
expect(fixture.getCommand("livesync-dump").name).toBe("Copy database information for the active file");
expect(fixture.getCommand("livesync-toggle").name).toBe("Toggle LiveSync");
expect(fixture.getCommand("livesync-suspendall").name).toBe("Toggle All Sync.");
expect(fixture.getCommand("livesync-scan-files").name).toBe("Scan storage and database again");
expect(fixture.getCommand("livesync-runbatch").name).toBe("Apply pending changes now");
expect(fixture.getCommand("livesync-abortsync").name).toBe("Abort synchronization immediately");
});
it("retains the manual replication authority and quiet progress presentation", async () => {
const fixture = createFixture();
await initialise(fixture);
await fixture.getCommand("livesync-replicate").callback?.();
expect(fixture.services.replication.replicateUserInitiated).toHaveBeenCalledWith({
trigger: "manual",
progressPresentation: REPLICATION_PROGRESS_PRESENTATIONS.QUIET,
interaction: USER_INITIATED_REPLICATION_AUTHORITY,
});
});
it("toggles LiveSync and persists the updated setting", async () => {
const fixture = createFixture();
await initialise(fixture);
await fixture.getCommand("livesync-toggle").callback?.();
expect(fixture.settings.liveSync).toBe(true);
expect(fixture.api.addLog).toHaveBeenCalledWith("LiveSync Enabled.", expect.anything(), "");
expect(fixture.services.control.applySettings).toHaveBeenCalledOnce();
expect(fixture.services.setting.saveSettingData).toHaveBeenCalledOnce();
expect(fixture.services.control.applySettings.mock.invocationCallOrder[0]).toBeLessThan(
fixture.services.setting.saveSettingData.mock.invocationCallOrder[0]
);
await fixture.getCommand("livesync-toggle").callback?.();
expect(fixture.settings.liveSync).toBe(false);
expect(fixture.api.addLog).toHaveBeenCalledWith("LiveSync Disabled.", expect.anything(), "");
});
it("toggles all synchronisation through the app lifecycle and persists it", async () => {
const fixture = createFixture();
await initialise(fixture);
await fixture.getCommand("livesync-suspendall").callback?.();
expect(fixture.services.appLifecycle.setSuspended).toHaveBeenCalledWith(true);
expect(fixture.api.addLog).toHaveBeenCalledWith("Self-hosted LiveSync suspended", expect.anything(), "");
expect(fixture.services.control.applySettings).toHaveBeenCalledOnce();
expect(fixture.services.setting.saveSettingData).toHaveBeenCalledOnce();
expect(fixture.services.control.applySettings.mock.invocationCallOrder[0]).toBeLessThan(
fixture.services.setting.saveSettingData.mock.invocationCallOrder[0]
);
fixture.services.appLifecycle.isSuspended.mockReturnValue(true);
await fixture.getCommand("livesync-suspendall").callback?.();
expect(fixture.services.appLifecycle.setSuspended).toHaveBeenLastCalledWith(false);
expect(fixture.api.addLog).toHaveBeenCalledWith("Self-hosted LiveSync resumed", expect.anything(), "");
});
it("keeps advanced maintenance checks gated and invokes their exact actions", async () => {
const fixture = createFixture();
await initialise(fixture);
expect(fixture.getCommand("livesync-scan-files").checkCallback?.(true)).toBe(false);
expect(fixture.getCommand("livesync-abortsync").checkCallback?.(true)).toBe(false);
fixture.settings.useAdvancedMode = true;
expect(fixture.getCommand("livesync-scan-files").checkCallback?.(true)).toBe(true);
expect(fixture.getCommand("livesync-abortsync").checkCallback?.(true)).toBe(true);
expect(fixture.services.vault.scanVault).not.toHaveBeenCalled();
expect(fixture.services.replication.stopActiveTransfer).not.toHaveBeenCalled();
fixture.getCommand("livesync-scan-files").checkCallback?.(false);
fixture.getCommand("livesync-abortsync").checkCallback?.(false);
await vi.waitFor(() => {
expect(fixture.services.vault.scanVault).toHaveBeenCalledWith(true);
expect(fixture.services.replication.stopActiveTransfer).toHaveBeenCalledOnce();
});
});
it("copies active-file database information through a narrow structural adapter", async () => {
const fixture = createFixture();
await initialise(fixture);
const dump = fixture.getCommand("livesync-dump");
vi.mocked(copyFileDatabaseInfo).mockClear();
expect(dump.checkCallback?.(true)).toBe(true);
expect(copyFileDatabaseInfo).not.toHaveBeenCalled();
dump.checkCallback?.(false);
await vi.waitFor(() => expect(copyFileDatabaseInfo).toHaveBeenCalledOnce());
const [adapter, path] = vi.mocked(copyFileDatabaseInfo).mock.calls[0] ?? [];
expect(path).toBe("note.md");
expect(adapter).toEqual({
localDatabase: fixture.services.database.localDatabase,
services: {
path: fixture.services.path,
UI: fixture.services.UI,
},
settings: fixture.settings,
storageAccess: fixture.serviceModules.storageAccess,
});
expect(adapter).not.toBe(fixture.host);
});
it("commits pending file events from the batch command", async () => {
const fixture = createFixture();
await initialise(fixture);
await fixture.getCommand("livesync-runbatch").callback?.();
expect(fixture.services.fileProcessing.commitPendingFileEvents).toHaveBeenCalledOnce();
});
it("keeps the active-file report unavailable without an active file", async () => {
const fixture = createFixture();
fixture.services.vault.getActiveFilePath.mockReturnValue(undefined);
await initialise(fixture);
expect(fixture.getCommand("livesync-dump").checkCallback?.(true)).toBe(false);
});
});
@@ -1,109 +0,0 @@
import { describe, expect, it, vi } from "vitest";
import {
runConfiguredStartupLifecycle,
runStartupEntryLifecycle,
type ConfiguredStartupLifecycleRuntime,
} from "./configuredStartupLifecycle";
function createRuntime(): ConfiguredStartupLifecycleRuntime & { events: string[] } {
const events: string[] = [];
return {
events,
databaseReady: true,
reportDatabaseNotReady: vi.fn(() => events.push("database-not-ready")),
hasCompromisedChunks: vi.fn(async () => {
events.push("compromised-chunks");
return true;
}),
hasIncompleteDocuments: vi.fn(async () => {
events.push("incomplete-documents");
return true;
}),
waitForCompatibilityReview: vi.fn(async () => {}),
runDoctor: vi.fn(async () => {
events.push("doctor");
return true;
}),
migrateBulkSend: vi.fn(async () => {
events.push("bulk-send");
}),
};
}
describe("runConfiguredStartupLifecycle", () => {
it("runs configured checks in order before allowing initialisation", async () => {
const runtime = createRuntime();
await expect(runConfiguredStartupLifecycle(runtime)).resolves.toBe(true);
expect(runtime.events).toEqual(["compromised-chunks", "incomplete-documents", "doctor", "bulk-send"]);
});
it("keeps Config Doctor behind the initial compatibility review", async () => {
const runtime = createRuntime();
Object.assign(runtime, {
waitForCompatibilityReview: vi.fn(async () => {
runtime.events.push("compatibility-review");
}),
});
await expect(runConfiguredStartupLifecycle(runtime)).resolves.toBe(true);
expect(runtime.events).toEqual([
"compromised-chunks",
"incomplete-documents",
"compatibility-review",
"doctor",
"bulk-send",
]);
});
it("stops before onboarding or checks when the database is unavailable", async () => {
const runtime = createRuntime();
runtime.databaseReady = false;
await expect(runConfiguredStartupLifecycle(runtime)).resolves.toBe(false);
expect(runtime.events).toEqual(["database-not-ready"]);
});
it("stops the configured sequence at the first failed check", async () => {
const runtime = createRuntime();
vi.mocked(runtime.hasIncompleteDocuments).mockImplementation(async () => {
runtime.events.push("incomplete-documents");
return false;
});
await expect(runConfiguredStartupLifecycle(runtime)).resolves.toBe(false);
expect(runtime.events).toEqual(["compromised-chunks", "incomplete-documents"]);
});
});
describe("runStartupEntryLifecycle", () => {
it("offers onboarding and stops before database initialisation on an unconfigured Vault", () => {
const inviteToOnboarding = vi.fn();
expect(
runStartupEntryLifecycle({
configured: false,
inviteToOnboarding,
})
).toBe(false);
expect(inviteToOnboarding).toHaveBeenCalledOnce();
});
it("allows a configured Vault to continue to database initialisation", () => {
const inviteToOnboarding = vi.fn();
expect(
runStartupEntryLifecycle({
configured: true,
inviteToOnboarding,
})
).toBe(true);
expect(inviteToOnboarding).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,86 @@
import {
LOG_LEVEL_NOTICE,
type FilePath,
type FilePathWithPrefix,
type ObsidianLiveSyncSettings,
} from "@vrtmrz/livesync-commonlib/compat/common/types";
import type { IConflictService, IVaultService } from "@vrtmrz/livesync-commonlib/compat/services/base/IService";
import type { LogFunction } from "@vrtmrz/livesync-commonlib/compat/services/lib/logUtils";
import type { ReactiveSource } from "octagonal-wheels/dataobject/reactive";
import { QueueProcessor } from "octagonal-wheels/concurrency/processor";
export type ConflictCheckingSettings = Pick<ObsidianLiveSyncSettings, "checkConflictOnlyOnOpen">;
export interface ConflictCheckingDependencies {
readonly conflict: Pick<
IConflictService,
"getOptionalConflictCheckMethod" | "queueCheckFor" | "resolve" | "resolveByNewest"
>;
readonly conflictProcessQueueCount: ReactiveSource<number>;
readonly currentSettings: () => ConflictCheckingSettings;
readonly vault: Pick<IVaultService, "getActiveFilePath">;
readonly log: LogFunction;
}
export interface ConflictCheckingHandlers {
readonly queueCheckForIfOpen: (file: FilePathWithPrefix) => Promise<void>;
readonly queueCheckFor: (file: FilePathWithPrefix) => Promise<void>;
readonly ensureAllProcessed: () => Promise<boolean>;
}
/** Create conflict-checking handlers while retaining scheduling state privately. */
export function createConflictCheckingHandlers(dependencies: ConflictCheckingDependencies): ConflictCheckingHandlers {
const conflictQueue = new QueueProcessor<FilePathWithPrefix, void>(
async (filenames: FilePathWithPrefix[]) => {
const filename = filenames[0];
return await dependencies.conflict.resolve(filename);
},
{
suspended: false,
batchSize: 1,
// No need to limit concurrency to `1` here, subsequent process will handle it,
// and some cases do not need to be synchronised (for example, auto-merge).
// Global concurrency is limited by the resolver with the UI.
concurrentLimit: 10,
delay: 0,
keepResultUntilDownstreamConnected: false,
totalRemainingReactiveSource: dependencies.conflictProcessQueueCount,
}
).replaceEnqueueProcessor((queue, newEntity) => {
const newQueue = [...queue].filter((entry) => entry != newEntity);
return [...newQueue, newEntity];
});
const queueCheckForIfOpen = async (file: FilePathWithPrefix): Promise<void> => {
const path = file;
if (dependencies.currentSettings().checkConflictOnlyOnOpen) {
const activeFile: FilePath | undefined = dependencies.vault.getActiveFilePath();
if (activeFile && activeFile != path) {
dependencies.log(`${file} is conflicted, merging process has been postponed.`, LOG_LEVEL_NOTICE);
return;
}
}
await dependencies.conflict.queueCheckFor(path);
};
const queueCheckFor = async (file: FilePathWithPrefix): Promise<void> => {
const optionalConflictResult = await dependencies.conflict.getOptionalConflictCheckMethod(file);
if (optionalConflictResult == true) {
// The conflict has been resolved by another process.
return;
} else if (optionalConflictResult === "newer") {
// The conflict should be resolved by the newer entry.
await dependencies.conflict.resolveByNewest(file);
} else {
conflictQueue.enqueue(file);
}
};
const ensureAllProcessed = (): Promise<boolean> => conflictQueue.waitForAllProcessed();
return {
queueCheckForIfOpen,
queueCheckFor,
ensureAllProcessed,
};
}
@@ -0,0 +1,883 @@
import { describe, expect, it, vi } from "vitest";
import { createServiceContext } from "@vrtmrz/livesync-commonlib/context";
import { InjectableConflictService } from "@vrtmrz/livesync-commonlib/compat/services/implements/injectable/InjectableConflictService";
import {
AUTO_MERGED,
DEFAULT_SETTINGS,
LOG_LEVEL_INFO,
LOG_LEVEL_NOTICE,
MISSING_OR_ERROR,
NOT_CONFLICTED,
type FilePath,
type FilePathWithPrefix,
type MetaEntry,
type ObsidianLiveSyncSettings,
} from "@vrtmrz/livesync-commonlib/compat/common/types";
import { EVENT_CONFLICT_CANCELLED } from "@vrtmrz/livesync-commonlib/compat/events/coreEvents";
import { NO_INTERACTION } from "@vrtmrz/livesync-commonlib/replication";
import type { ConflictResolutionHost } from "./index";
import { createConflictResolutionOperations, useConflictResolutionFeature } from "./index";
import type { ConflictResolutionOperationsDependencies } from "./operations";
type ConflictLeaf = {
rev: string;
data: string;
ctime: number;
mtime: number;
deleted?: boolean;
};
type HarnessOptions = {
files?: FilePathWithPrefix[];
settings?: Partial<ObsidianLiveSyncSettings>;
activeFile?: FilePathWithPrefix;
compose?: boolean;
};
function createHarness(options: HarnessOptions = {}) {
const context = createServiceContext();
const conflict = new InjectableConflictService(context);
const settings = { ...DEFAULT_SETTINGS, ...options.settings };
const tryAutoMerge = vi.fn();
const databaseFileAccess = {
fetchEntryMeta: vi.fn(),
getConflictedRevs: vi.fn(async () => [] as string[]),
storeContent: vi.fn(async () => true),
};
const fileHandler = {
dbToStorage: vi.fn(async () => true),
deleteRevisionFromDB: vi.fn(async () => true),
};
const addLog = vi.fn();
const storageAccess = {
getFileNames: vi.fn(async () => options.files ?? []),
};
const activeFile = vi.fn(() => options.activeFile);
const services = {
API: { addLog },
appLifecycle: { isSuspended: vi.fn(() => false) },
conflict,
context,
database: { localDatabase: { tryAutoMerge } },
replication: { replicateUnattendedByEvent: vi.fn(async () => ({ status: "completed" as const })) },
setting: { currentSettings: vi.fn(() => settings) },
vault: { getActiveFilePath: activeFile },
};
const serviceModules = { databaseFileAccess, fileHandler, storageAccess };
if (options.compose !== false) {
useConflictResolutionFeature({ services, serviceModules } as unknown as ConflictResolutionHost);
}
return {
addLog,
conflict,
context,
databaseFileAccess,
fileHandler,
services,
serviceModules,
storageAccess,
tryAutoMerge,
};
}
function leaf(rev: string, data: string, mtime: number): ConflictLeaf {
return { rev, data, mtime, ctime: mtime, deleted: false };
}
function metadata(path: FilePathWithPrefix, rev: string, mtime: number): MetaEntry {
return {
_id: "doc-id",
_rev: rev,
path,
ctime: mtime,
mtime,
size: 0,
children: [],
type: "plain",
eden: {},
} as unknown as MetaEntry;
}
function createOperationsHarness() {
const events = { emitEvent: vi.fn() };
const tryAutoMerge = vi.fn();
const databaseFileAccess = {
fetchEntryMeta: vi.fn(),
getConflictedRevs: vi.fn(async () => [] as string[]),
storeContent: vi.fn(async () => true),
};
const fileHandler = {
dbToStorage: vi.fn(async () => true),
deleteRevisionFromDB: vi.fn(async () => true),
};
const resolveByDeletingRevision = vi.fn(async () => AUTO_MERGED);
const queueCheckFor = vi.fn(async () => undefined);
const resolveByUserInteraction = vi.fn(async () => false);
const replicateUnattendedByEvent = vi.fn(async () => ({ status: "completed" as const }));
const dependencies = {
events,
databaseFileAccess,
fileHandler,
localDatabase: () => ({ tryAutoMerge }),
conflict: { queueCheckFor, resolveByDeletingRevision, resolveByUserInteraction },
replication: { replicateUnattendedByEvent },
appLifecycle: { isSuspended: vi.fn(() => false) },
vault: { getActiveFilePath: vi.fn(() => undefined) },
storageAccess: { getFileNames: vi.fn(async () => [] as FilePathWithPrefix[]) },
currentSettings: vi.fn(() => ({
disableMarkdownAutoMerge: false,
resolveConflictsByNewerFile: false,
syncAfterMerge: true,
showMergeDialogOnlyOnActive: false,
})),
log: vi.fn(),
} as unknown as ConflictResolutionOperationsDependencies;
return {
...dependencies,
dependencies,
operations: createConflictResolutionOperations(dependencies),
events,
tryAutoMerge,
databaseFileAccess,
fileHandler,
resolveByDeletingRevision,
resolveByUserInteraction,
queueCheckFor,
replicateUnattendedByEvent,
};
}
describe("conflict resolution serviceFeature", () => {
it("keeps resolver operations on narrow collaborators and extension seams", async () => {
const harness = createOperationsHarness();
const path = "same.md" as FilePathWithPrefix;
const leftLeaf = leaf("1-left", "Same content\n", 1000);
const rightLeaf = leaf("1-right", "Same content\n", 2000);
harness.tryAutoMerge.mockResolvedValue({
leftRev: leftLeaf.rev,
rightRev: rightLeaf.rev,
leftLeaf,
rightLeaf,
});
const result = await harness.operations.checkConflictAndPerformAutoMerge(path);
expect(result).toBe(AUTO_MERGED);
expect(harness.resolveByDeletingRevision).toHaveBeenCalledWith(path, "1-left", "same");
expect(harness.fileHandler.deleteRevisionFromDB).not.toHaveBeenCalled();
});
it("deletes the older conflict leaf when matching current content is newer", async () => {
const harness = createOperationsHarness();
const path = "same-newer-current.md" as FilePathWithPrefix;
const leftLeaf = leaf("2-left", "Same content\n", 3000);
const rightLeaf = leaf("1-right", "Same content\n", 2000);
harness.tryAutoMerge.mockResolvedValue({
leftRev: leftLeaf.rev,
rightRev: rightLeaf.rev,
leftLeaf,
rightLeaf,
});
const result = await harness.operations.checkConflictAndPerformAutoMerge(path);
expect(result).toBe(AUTO_MERGED);
expect(harness.resolveByDeletingRevision).toHaveBeenCalledWith(path, "1-right", "same");
});
it.each([
{
description: "binary content",
path: "different.png" as FilePathWithPrefix,
resolveConflictsByNewerFile: false,
expectedSubtitle: "binary",
},
{
description: "the newer-file policy",
path: "different.md" as FilePathWithPrefix,
resolveConflictsByNewerFile: true,
expectedSubtitle: "alwaysNewer",
},
])(
"retains automatic resolution for $description",
async ({ path, resolveConflictsByNewerFile, expectedSubtitle }) => {
const harness = createOperationsHarness();
const leftLeaf = leaf("1-left", "Left content\n", 1000);
const rightLeaf = leaf("2-right", "Right content\n", 2000);
harness.tryAutoMerge.mockResolvedValue({
leftRev: leftLeaf.rev,
rightRev: rightLeaf.rev,
leftLeaf,
rightLeaf,
});
const operations = createConflictResolutionOperations({
...harness.dependencies,
currentSettings: () => ({
disableMarkdownAutoMerge: false,
resolveConflictsByNewerFile,
syncAfterMerge: false,
showMergeDialogOnlyOnActive: false,
}),
});
const result = await operations.checkConflictAndPerformAutoMerge(path);
expect(result).toBe(AUTO_MERGED);
expect(harness.resolveByDeletingRevision).toHaveBeenCalledWith(path, "1-left", expectedSubtitle);
}
);
it("acquires the active local database for each resolution attempt", async () => {
const harness = createOperationsHarness();
const path = "database-reset.md" as FilePathWithPrefix;
const firstTryAutoMerge = vi.fn(async () => ({ ok: NOT_CONFLICTED as typeof NOT_CONFLICTED }));
const replacementTryAutoMerge = vi.fn(async () => ({ ok: NOT_CONFLICTED as typeof NOT_CONFLICTED }));
let activeDatabase = { tryAutoMerge: firstTryAutoMerge };
const dependencies = {
...harness.dependencies,
localDatabase: () => activeDatabase,
};
const operations = createConflictResolutionOperations(dependencies);
await operations.checkConflictAndPerformAutoMerge(path);
activeDatabase = { tryAutoMerge: replacementTryAutoMerge };
await operations.checkConflictAndPerformAutoMerge(path);
expect(firstTryAutoMerge).toHaveBeenCalledOnce();
expect(replacementTryAutoMerge).toHaveBeenCalledOnce();
});
it("returns a manual diff for independently created files with different content", async () => {
const harness = createOperationsHarness();
const path = "independently-created.md" as FilePathWithPrefix;
const leftLeaf = leaf("1-left", "Left content\n", 1000);
const rightLeaf = leaf("1-right", "Right content\n", 2000);
harness.tryAutoMerge.mockResolvedValue({
leftRev: leftLeaf.rev,
rightRev: rightLeaf.rev,
leftLeaf,
rightLeaf,
});
const result = await harness.operations.checkConflictAndPerformAutoMerge(path);
expect(result).toMatchObject({ left: leftLeaf, right: rightLeaf });
expect(result).toHaveProperty("diff");
expect(harness.resolveByDeletingRevision).not.toHaveBeenCalled();
});
it("stores a sensible merge before resolving its conflict leaf", async () => {
const harness = createOperationsHarness();
const path = "sensible.md" as FilePathWithPrefix;
harness.tryAutoMerge.mockResolvedValue({
result: "Title\nLeft changed\nRight changed\n",
conflictedRev: "2-right",
});
const result = await harness.operations.checkConflictAndPerformAutoMerge(path);
expect(result).toBe(AUTO_MERGED);
expect(harness.databaseFileAccess.storeContent).toHaveBeenCalledWith(
path,
"Title\nLeft changed\nRight changed\n"
);
expect(harness.resolveByDeletingRevision).toHaveBeenCalledWith(path, "2-right", "Sensible");
});
it("keeps the conflict leaf when sensible merged content cannot be stored", async () => {
const harness = createOperationsHarness();
const path = "failed-sensible.md" as FilePathWithPrefix;
harness.tryAutoMerge.mockResolvedValue({
result: "Merged content\n",
conflictedRev: "2-right",
});
harness.databaseFileAccess.storeContent.mockResolvedValue(false);
const result = await harness.operations.checkConflictAndPerformAutoMerge(path);
expect(result).toBe(MISSING_OR_ERROR);
expect(harness.resolveByDeletingRevision).not.toHaveBeenCalled();
});
it("stops before emitting or reflecting when conflict revision deletion fails", async () => {
const harness = createOperationsHarness();
const path = "failed-delete.md" as FilePathWithPrefix;
harness.fileHandler.deleteRevisionFromDB.mockResolvedValue(false);
const result = await harness.operations.resolveByDeletingRevision(path, "2-right", "UI Selected");
expect(result).toBe(MISSING_OR_ERROR);
expect(harness.events.emitEvent).not.toHaveBeenCalled();
expect(harness.fileHandler.dbToStorage).not.toHaveBeenCalled();
});
it("returns a safe error and logs when reflecting the resolved revision fails", async () => {
const harness = createOperationsHarness();
const path = "failed-reflection.md" as FilePathWithPrefix;
harness.fileHandler.dbToStorage.mockResolvedValue(false);
const result = await harness.operations.resolveByDeletingRevision(path, "2-right", "UI Selected");
expect(result).toBe(MISSING_OR_ERROR);
expect(harness.fileHandler.dbToStorage).toHaveBeenCalledWith(path, path, true);
expect(harness.dependencies.log).toHaveBeenCalledWith(
`Could not write the resolved content to the storage: ${path}`,
LOG_LEVEL_NOTICE
);
});
it.each([
["plugin metadata", "ps:plugin-metadata" as FilePathWithPrefix],
["customisation metadata", "ix:customisation-metadata" as FilePathWithPrefix],
])("does not reflect %s into the Vault after deleting its revision", async (_description, path) => {
const harness = createOperationsHarness();
const result = await harness.operations.resolveByDeletingRevision(path, "2-right", "UI Selected");
expect(result).toBe(AUTO_MERGED);
expect(harness.fileHandler.deleteRevisionFromDB).toHaveBeenCalledWith(path, "2-right");
expect(harness.fileHandler.dbToStorage).not.toHaveBeenCalled();
});
it("rechecks a remaining manual pair after committing a sensible merge", async () => {
const harness = createOperationsHarness();
const path = "three-versions.md" as FilePathWithPrefix;
const remainingManualPair = {
leftRev: "3-merged",
rightRev: "2-third",
leftLeaf: leaf("3-merged", "Merged\n", 3),
rightLeaf: leaf("2-third", "Overlapping\n", 2),
};
harness.tryAutoMerge
.mockResolvedValueOnce({
result: "Merged\n",
conflictedRev: "2-second",
})
.mockResolvedValueOnce(remainingManualPair);
await harness.operations.resolve(path);
expect(harness.databaseFileAccess.storeContent).toHaveBeenCalledWith(path, "Merged\n");
expect(harness.resolveByDeletingRevision).toHaveBeenCalledWith(path, "2-second", "Sensible");
expect(harness.queueCheckFor).toHaveBeenCalledWith(path);
expect(harness.resolveByUserInteraction).not.toHaveBeenCalled();
await harness.operations.resolve(path);
expect(harness.tryAutoMerge).toHaveBeenCalledTimes(2);
expect(harness.resolveByUserInteraction).toHaveBeenCalledWith(
path,
expect.objectContaining({
left: remainingManualPair.leftLeaf,
right: remainingManualPair.rightLeaf,
})
);
});
it("requeues and replicates through collaborators after an automatic merge", async () => {
const harness = createOperationsHarness();
const path = "merged.md" as FilePathWithPrefix;
harness.tryAutoMerge.mockResolvedValue({ ok: AUTO_MERGED });
await harness.operations.resolve(path);
expect(harness.replicateUnattendedByEvent).toHaveBeenCalledWith({
trigger: "merge",
interaction: NO_INTERACTION,
});
expect(harness.queueCheckFor).toHaveBeenCalledWith(path);
});
it("postpones a manual merge until its file is active when configured", async () => {
const harness = createOperationsHarness();
const path = "inactive.md" as FilePathWithPrefix;
harness.tryAutoMerge.mockResolvedValue({
leftRev: "2-left",
rightRev: "2-right",
leftLeaf: leaf("2-left", "Left\n", 1),
rightLeaf: leaf("2-right", "Right\n", 2),
});
const dependencies = {
...harness.dependencies,
currentSettings: () => ({
disableMarkdownAutoMerge: false,
resolveConflictsByNewerFile: false,
syncAfterMerge: false,
showMergeDialogOnlyOnActive: true,
}),
vault: { getActiveFilePath: () => "other.md" as FilePath },
};
const operations = createConflictResolutionOperations(dependencies);
await operations.resolve(path);
expect(harness.resolveByUserInteraction).not.toHaveBeenCalled();
expect(dependencies.log).toHaveBeenCalledWith(
expect.stringContaining("Merging process has been postponed"),
LOG_LEVEL_NOTICE
);
});
it("opens a manual merge when the active-only setting matches the current file", async () => {
const harness = createOperationsHarness();
const path = "active.md" as FilePathWithPrefix;
const activePath = "active.md" as FilePath;
harness.tryAutoMerge.mockResolvedValue({
leftRev: "2-left",
rightRev: "2-right",
leftLeaf: leaf("2-left", "Left\n", 1),
rightLeaf: leaf("2-right", "Right\n", 2),
});
const operations = createConflictResolutionOperations({
...harness.dependencies,
currentSettings: () => ({
disableMarkdownAutoMerge: false,
resolveConflictsByNewerFile: false,
syncAfterMerge: false,
showMergeDialogOnlyOnActive: true,
}),
vault: { getActiveFilePath: () => activePath },
});
await operations.resolve(path);
expect(harness.resolveByUserInteraction).toHaveBeenCalledWith(
path,
expect.objectContaining({ left: expect.anything(), right: expect.anything() })
);
});
it("cancels an active same-file dialogue before serialising a repeated resolution", async () => {
const harness = createOperationsHarness();
const path = "repeated.md" as FilePathWithPrefix;
harness.tryAutoMerge.mockResolvedValue({
leftRev: "2-left",
rightRev: "2-right",
leftLeaf: leaf("2-left", "Left\n", 1),
rightLeaf: leaf("2-right", "Right\n", 2),
});
let finishDialogue: ((result: boolean) => void) | undefined;
harness.resolveByUserInteraction.mockImplementation(
async () => await new Promise<boolean>((resolve) => (finishDialogue = resolve))
);
harness.events.emitEvent.mockImplementation((event, filename) => {
if (event === EVENT_CONFLICT_CANCELLED && filename === path && finishDialogue) {
const finish = finishDialogue;
finishDialogue = undefined;
finish(false);
}
});
const first = harness.operations.resolve(path);
await vi.waitFor(() => expect(harness.resolveByUserInteraction).toHaveBeenCalledOnce());
const replacement = harness.operations.resolve(path);
await vi.waitFor(() => expect(harness.resolveByUserInteraction).toHaveBeenCalledTimes(2));
expect(harness.events.emitEvent).toHaveBeenCalledWith(EVENT_CONFLICT_CANCELLED, path);
finishDialogue?.(false);
await Promise.all([first, replacement]);
});
it("passes only the newest waiting same-file resolution to the interactive resolver", async () => {
const harness = createOperationsHarness();
const path = "repeated-three-times.md" as FilePathWithPrefix;
harness.tryAutoMerge.mockResolvedValue({
leftRev: "2-left",
rightRev: "2-right",
leftLeaf: leaf("2-left", "Left\n", 1),
rightLeaf: leaf("2-right", "Right\n", 2),
});
let finishFirstDialogue: ((result: boolean) => void) | undefined;
harness.resolveByUserInteraction
.mockImplementationOnce(
async () => await new Promise<boolean>((resolve) => (finishFirstDialogue = resolve))
)
.mockResolvedValue(false);
harness.events.emitEvent.mockImplementation((event, filename) => {
if (event === EVENT_CONFLICT_CANCELLED && filename === path && finishFirstDialogue) {
const finish = finishFirstDialogue;
finishFirstDialogue = undefined;
finish(false);
}
});
const first = harness.operations.resolve(path);
await vi.waitFor(() => expect(harness.resolveByUserInteraction).toHaveBeenCalledOnce());
const superseded = harness.operations.resolve(path);
const replacement = harness.operations.resolve(path);
await Promise.all([first, superseded, replacement]);
expect(harness.resolveByUserInteraction).toHaveBeenCalledTimes(2);
});
it("does not open a superseded dialogue after conflict inspection completes", async () => {
const harness = createOperationsHarness();
const path = "superseded-during-inspection.md" as FilePathWithPrefix;
const manualConflict = {
leftRev: "2-left",
rightRev: "2-right",
leftLeaf: leaf("2-left", "Left\n", 1),
rightLeaf: leaf("2-right", "Right\n", 2),
};
let finishFirstInspection!: (result: typeof manualConflict) => void;
harness.tryAutoMerge
.mockImplementationOnce(
async () => await new Promise<typeof manualConflict>((resolve) => (finishFirstInspection = resolve))
)
.mockResolvedValue(manualConflict);
harness.resolveByUserInteraction.mockResolvedValue(false);
const superseded = harness.operations.resolve(path);
await vi.waitFor(() => expect(harness.tryAutoMerge).toHaveBeenCalledOnce());
const replacement = harness.operations.resolve(path);
finishFirstInspection(manualConflict);
await Promise.all([superseded, replacement]);
expect(harness.resolveByUserInteraction).toHaveBeenCalledOnce();
});
it("registers all conflict service operations during composition", () => {
const harness = createHarness({ compose: false });
const registrations = [
vi.spyOn(harness.conflict.queueCheckForIfOpen, "setHandler"),
vi.spyOn(harness.conflict.queueCheckFor, "setHandler"),
vi.spyOn(harness.conflict.ensureAllProcessed, "setHandler"),
vi.spyOn(harness.conflict.resolveByDeletingRevision, "setHandler"),
vi.spyOn(harness.conflict.resolve, "setHandler"),
vi.spyOn(harness.conflict.resolveByNewest, "setHandler"),
vi.spyOn(harness.conflict.resolveAllConflictedFilesByNewerOnes, "setHandler"),
];
useConflictResolutionFeature({
services: harness.services,
serviceModules: harness.serviceModules,
} as unknown as ConflictResolutionHost);
for (const registration of registrations) {
expect(registration).toHaveBeenCalledOnce();
expect(registration).toHaveBeenCalledWith(expect.any(Function));
}
});
it("applies the active-file gate and deduplicates pending checks for one path", async () => {
const path = "postponed.md" as FilePathWithPrefix;
const harness = createHarness({
activeFile: "other.md" as FilePathWithPrefix,
settings: { checkConflictOnlyOnOpen: true },
});
harness.tryAutoMerge.mockResolvedValue({ ok: NOT_CONFLICTED });
await harness.conflict.queueCheckForIfOpen(path);
expect(harness.tryAutoMerge).not.toHaveBeenCalled();
harness.services.vault.getActiveFilePath = vi.fn(() => path);
await Promise.all([harness.conflict.queueCheckFor(path), harness.conflict.queueCheckFor(path)]);
await harness.conflict.ensureAllProcessed();
expect(harness.tryAutoMerge).toHaveBeenCalledOnce();
expect(harness.tryAutoMerge).toHaveBeenCalledWith(path, true);
expect(harness.addLog).toHaveBeenCalledWith(
`${path} is conflicted, merging process has been postponed.`,
LOG_LEVEL_NOTICE,
""
);
});
it.each([
["the active-file gate is disabled", { checkConflictOnlyOnOpen: false }, "other.md" as FilePathWithPrefix],
["the requested path is active", { checkConflictOnlyOnOpen: true }, "active.md" as FilePathWithPrefix],
])("reaches the resolver when %s", async (_description, settings, activeFile) => {
const path = "active.md" as FilePathWithPrefix;
const harness = createHarness({ settings, activeFile });
harness.tryAutoMerge.mockResolvedValue({ ok: NOT_CONFLICTED });
await harness.conflict.queueCheckForIfOpen(path);
await harness.conflict.ensureAllProcessed();
expect(harness.tryAutoMerge).toHaveBeenCalledOnce();
expect(harness.tryAutoMerge).toHaveBeenCalledWith(path, true);
});
it("limits concurrent conflict checks and reports queued and active work", async () => {
const paths = Array.from({ length: 11 }, (_, index) => `concurrent-${index}.md` as FilePathWithPrefix);
const harness = createHarness();
const finishByPath = new Map<FilePathWithPrefix, () => void>();
harness.tryAutoMerge.mockImplementation(
async (path: FilePathWithPrefix) =>
await new Promise<{ ok: typeof NOT_CONFLICTED }>((resolve) => {
finishByPath.set(path, () => resolve({ ok: NOT_CONFLICTED }));
})
);
await Promise.all(paths.map(async (path) => await harness.conflict.queueCheckFor(path)));
await vi.waitFor(() => expect(harness.tryAutoMerge).toHaveBeenCalledTimes(10));
expect(harness.conflict.conflictProcessQueueCount.value).toBe(11);
let allProcessed = false;
const completion = harness.conflict.ensureAllProcessed().then((result) => {
allProcessed = true;
return result;
});
await Promise.resolve();
expect(allProcessed).toBe(false);
const finishFirst = finishByPath.get(paths[0]);
finishByPath.delete(paths[0]);
finishFirst?.();
await vi.waitFor(() => expect(harness.tryAutoMerge).toHaveBeenCalledTimes(11));
for (const finish of finishByPath.values()) finish();
await expect(completion).resolves.toBe(true);
expect(harness.conflict.conflictProcessQueueCount.value).toBe(0);
});
it("replaces an older same-path check while every resolver slot is occupied", async () => {
const occupiedPaths = Array.from({ length: 10 }, (_, index) => `occupied-${index}.md` as FilePathWithPrefix);
const repeatedPath = "waiting-replacement.md" as FilePathWithPrefix;
const harness = createHarness();
const finishers: Array<{ path: FilePathWithPrefix; finish: () => void }> = [];
harness.tryAutoMerge.mockImplementation(
async (path: FilePathWithPrefix) =>
await new Promise<{ ok: typeof NOT_CONFLICTED }>((resolve) => {
finishers.push({ path, finish: () => resolve({ ok: NOT_CONFLICTED }) });
})
);
await Promise.all(occupiedPaths.map(async (path) => await harness.conflict.queueCheckFor(path)));
await vi.waitFor(() => expect(harness.tryAutoMerge).toHaveBeenCalledTimes(10));
await harness.conflict.queueCheckFor(repeatedPath);
await harness.conflict.queueCheckFor(repeatedPath);
for (const { finish } of finishers.filter(({ path }) => path !== repeatedPath)) finish();
await vi.waitFor(() => expect(harness.conflict.conflictProcessQueueCount.value).toBe(1));
expect(harness.tryAutoMerge.mock.calls.filter(([path]) => path === repeatedPath)).toHaveLength(1);
finishers.find(({ path }) => path === repeatedPath)?.finish();
await expect(harness.conflict.ensureAllProcessed()).resolves.toBe(true);
});
it("waits for a conflict check requeued by an automatic merge", async () => {
const path = "requeued-automatic-merge.md" as FilePathWithPrefix;
const harness = createHarness({ settings: { syncAfterMerge: false } });
let finishFirst!: (result: { ok: typeof AUTO_MERGED }) => void;
harness.tryAutoMerge
.mockImplementationOnce(
async () =>
await new Promise<{ ok: typeof AUTO_MERGED }>((resolve) => {
finishFirst = resolve;
})
)
.mockResolvedValueOnce({ ok: NOT_CONFLICTED });
await harness.conflict.queueCheckFor(path);
await vi.waitFor(() => expect(harness.tryAutoMerge).toHaveBeenCalledOnce());
const completion = harness.conflict.ensureAllProcessed();
finishFirst({ ok: AUTO_MERGED });
await expect(completion).resolves.toBe(true);
expect(harness.tryAutoMerge).toHaveBeenCalledTimes(2);
expect(harness.conflict.conflictProcessQueueCount.value).toBe(0);
});
it("drains repeated manual resolutions for a file with more than two conflicting versions", async () => {
const path = "requeued-manual-merge.md" as FilePathWithPrefix;
const harness = createHarness({ settings: { syncAfterMerge: false } });
const firstPair = {
leftRev: "3-current",
rightRev: "2-second",
leftLeaf: leaf("3-current", "Current\n", 3),
rightLeaf: leaf("2-second", "Second\n", 2),
};
const remainingPair = {
leftRev: "4-merged",
rightRev: "2-third",
leftLeaf: leaf("4-merged", "Merged\n", 4),
rightLeaf: leaf("2-third", "Third\n", 2),
};
harness.tryAutoMerge
.mockResolvedValueOnce(firstPair)
.mockResolvedValueOnce(remainingPair)
.mockResolvedValueOnce({ ok: NOT_CONFLICTED });
const resolvePair = vi.fn(async (filename: FilePathWithPrefix) => {
await harness.conflict.queueCheckFor(filename);
return false;
});
const unregister = harness.conflict.resolveByUserInteraction.addHandler(resolvePair);
try {
await harness.conflict.queueCheckFor(path);
await expect(harness.conflict.ensureAllProcessed()).resolves.toBe(true);
expect(harness.tryAutoMerge).toHaveBeenCalledTimes(3);
expect(resolvePair).toHaveBeenCalledTimes(2);
expect(harness.conflict.conflictProcessQueueCount.value).toBe(0);
} finally {
unregister();
}
});
it("honours optional conflict handlers before entering the check queue", async () => {
const path = "optional.md" as FilePathWithPrefix;
const harness = createHarness();
harness.tryAutoMerge.mockResolvedValue({ ok: NOT_CONFLICTED });
const unregisterResolved = harness.conflict.getOptionalConflictCheckMethod.addHandler(async () => true);
await harness.conflict.queueCheckFor(path);
await harness.conflict.ensureAllProcessed();
expect(harness.tryAutoMerge).not.toHaveBeenCalled();
unregisterResolved();
const unregisterNewer = harness.conflict.getOptionalConflictCheckMethod.addHandler(async () => "newer");
harness.databaseFileAccess.fetchEntryMeta.mockResolvedValue(false);
await harness.conflict.queueCheckFor(path);
expect(harness.databaseFileAccess.fetchEntryMeta).toHaveBeenCalledWith(path, undefined, true);
unregisterNewer();
});
it("keeps unreadable conflict revisions available for explicit repair", async () => {
const path = "missing-conflict-body.md" as FilePathWithPrefix;
const harness = createHarness();
harness.tryAutoMerge.mockResolvedValue({
leftRev: "3-current",
rightRev: "2-unreadable",
leftLeaf: leaf("3-current", "Readable current body\n", 3),
rightLeaf: false,
});
await harness.conflict.resolve(path);
expect(harness.fileHandler.deleteRevisionFromDB).not.toHaveBeenCalled();
expect(harness.fileHandler.dbToStorage).not.toHaveBeenCalled();
expect(harness.addLog).toHaveBeenCalledWith(
`could not read conflicted revision 2-unreadable:${path}`,
LOG_LEVEL_NOTICE,
""
);
expect(MISSING_OR_ERROR).toBeDefined();
});
it("preserves revisions and returns a safe error when the current leaf is unreadable", async () => {
const harness = createOperationsHarness();
const path = "unreadable-current.md" as FilePathWithPrefix;
harness.tryAutoMerge.mockResolvedValue({
leftRev: "3-current",
rightRev: "2-conflict",
leftLeaf: false,
rightLeaf: leaf("2-conflict", "Conflict body\n", 2),
});
const result = await harness.operations.checkConflictAndPerformAutoMerge(path);
expect(result).toBe(MISSING_OR_ERROR);
expect(harness.fileHandler.deleteRevisionFromDB).not.toHaveBeenCalled();
expect(harness.fileHandler.dbToStorage).not.toHaveBeenCalled();
expect(harness.dependencies.log).toHaveBeenCalledWith(
`could not get current revisions:${path}`,
LOG_LEVEL_NOTICE
);
});
it("resolves an identical pair, emits cancellation, and rechecks after merging", async () => {
const path = "independently-created.md" as FilePathWithPrefix;
const harness = createHarness({ settings: { syncAfterMerge: false } });
const cancelled: FilePathWithPrefix[] = [];
harness.context.events.onEvent(EVENT_CONFLICT_CANCELLED, (filename) => cancelled.push(filename));
const leftLeaf = leaf("1-left", "Same content\n", 1000);
const rightLeaf = leaf("1-right", "Same content\n", 2000);
harness.tryAutoMerge
.mockResolvedValueOnce({
leftRev: leftLeaf.rev,
rightRev: rightLeaf.rev,
leftLeaf,
rightLeaf,
})
.mockResolvedValueOnce({ ok: NOT_CONFLICTED });
await harness.conflict.resolve(path);
await harness.conflict.ensureAllProcessed();
expect(harness.fileHandler.deleteRevisionFromDB).toHaveBeenCalledWith(path, "1-left");
expect(harness.fileHandler.dbToStorage).toHaveBeenCalledWith(path, path, true);
expect(cancelled).toEqual([path, path]);
expect(harness.tryAutoMerge).toHaveBeenCalledTimes(2);
expect(AUTO_MERGED).toBeDefined();
});
it("uses deterministic revision ordering and suppresses notices during bulk resolution", async () => {
const files = Array.from({ length: 11 }, (_, index) => `note-${index}.md` as FilePathWithPrefix);
const harness = createHarness({ files });
harness.databaseFileAccess.fetchEntryMeta.mockImplementation(async (path: FilePathWithPrefix, rev?: string) =>
metadata(path, rev ?? "2-current", rev ? 1 : 2)
);
let conflictInspection = 0;
harness.databaseFileAccess.getConflictedRevs.mockImplementation(async () =>
conflictInspection++ % 2 === 0 ? ["1-old"] : []
);
await harness.conflict.resolveAllConflictedFilesByNewerOnes();
expect(harness.fileHandler.deleteRevisionFromDB).toHaveBeenCalledTimes(11);
expect(harness.addLog).toHaveBeenCalledWith(
"Check and Processing 10 / 11",
LOG_LEVEL_NOTICE,
"resolveAllConflictedFilesByNewerOnes"
);
expect(harness.addLog).toHaveBeenCalledWith(
expect.stringContaining("has been merged automatically"),
LOG_LEVEL_INFO,
""
);
});
it("uses revision identifiers to break newest-resolution timestamp ties", async () => {
const harness = createOperationsHarness();
const path = "same-time.md" as FilePathWithPrefix;
harness.databaseFileAccess.fetchEntryMeta.mockImplementation(
async (filename: FilePathWithPrefix, revision?: string) => metadata(filename, revision ?? "2-3", 1000)
);
harness.databaseFileAccess.getConflictedRevs
.mockResolvedValueOnce(["2-10", "2-2"])
.mockResolvedValueOnce(["2-10"])
.mockResolvedValueOnce([]);
await harness.operations.resolveByNewest(path);
expect(harness.fileHandler.deleteRevisionFromDB).toHaveBeenNthCalledWith(1, path, "2-3");
expect(harness.fileHandler.deleteRevisionFromDB).toHaveBeenNthCalledWith(2, path, "2-10");
expect(harness.dependencies.log).toHaveBeenLastCalledWith(
`${path} has been merged automatically`,
LOG_LEVEL_NOTICE
);
});
it.each([
{ description: "no conflicts remain", conflicts: [] as string[], expectedDeletes: [] as string[] },
{
description: "conflict metadata is unreadable",
conflicts: ["2-unreadable"],
expectedDeletes: ["2-unreadable"],
},
])("handles the case where $description while resolving by newest", async ({ conflicts, expectedDeletes }) => {
const harness = createOperationsHarness();
const path = "newest-policy.md" as FilePathWithPrefix;
harness.databaseFileAccess.fetchEntryMeta.mockImplementation(
async (_filename: FilePathWithPrefix, revision?: string) =>
revision === undefined ? metadata(path, "3-current", 1000) : false
);
harness.databaseFileAccess.getConflictedRevs.mockResolvedValueOnce(conflicts).mockResolvedValueOnce([]);
await expect(harness.operations.resolveByNewest(path)).resolves.toBe(true);
expect(harness.fileHandler.deleteRevisionFromDB).toHaveBeenCalledTimes(expectedDeletes.length);
for (const [index, revision] of expectedDeletes.entries()) {
expect(harness.fileHandler.deleteRevisionFromDB).toHaveBeenNthCalledWith(index + 1, path, revision);
}
});
});
@@ -0,0 +1,60 @@
import type { NecessaryServices } from "@vrtmrz/livesync-commonlib/compat/interfaces/ServiceModule";
import { createInstanceLogFunction } from "@vrtmrz/livesync-commonlib/compat/services/lib/logUtils";
import type { InjectableConflictService } from "@vrtmrz/livesync-commonlib/compat/services/implements/injectable/InjectableConflictService";
import type { ServiceContext } from "@vrtmrz/livesync-commonlib/context";
import { createConflictCheckingHandlers } from "./checker";
import { createConflictResolutionOperations } from "./operations";
type ConflictResolutionServices = NecessaryServices<
"API" | "appLifecycle" | "conflict" | "database" | "replication" | "setting" | "vault",
"databaseFileAccess" | "fileHandler" | "storageAccess"
>;
export type ConflictResolutionHost = ConflictResolutionServices & {
readonly services: ConflictResolutionServices["services"] & {
readonly conflict: InjectableConflictService<ServiceContext>;
};
};
/** Compose the host-neutral conflict checker and resolver handlers. */
export function useConflictResolutionFeature(host: ConflictResolutionHost): void {
const { services, serviceModules } = host;
const log = createInstanceLogFunction("SF:ConflictResolution", services.API);
const operations = createConflictResolutionOperations({
events: services.context.events,
databaseFileAccess: serviceModules.databaseFileAccess,
fileHandler: serviceModules.fileHandler,
localDatabase: () => services.database.localDatabase,
conflict: services.conflict,
replication: services.replication,
appLifecycle: services.appLifecycle,
vault: services.vault,
storageAccess: serviceModules.storageAccess,
currentSettings: () => services.setting.currentSettings(),
log,
});
const checking = createConflictCheckingHandlers({
conflict: services.conflict,
conflictProcessQueueCount: services.conflict.conflictProcessQueueCount,
currentSettings: () => services.setting.currentSettings(),
vault: services.vault,
log,
});
services.conflict.queueCheckForIfOpen.setHandler(checking.queueCheckForIfOpen);
services.conflict.queueCheckFor.setHandler(checking.queueCheckFor);
services.conflict.ensureAllProcessed.setHandler(checking.ensureAllProcessed);
services.conflict.resolveByDeletingRevision.setHandler(operations.resolveByDeletingRevision);
services.conflict.resolve.setHandler(operations.resolve);
services.conflict.resolveByNewest.setHandler(operations.resolveByNewest);
services.conflict.resolveAllConflictedFilesByNewerOnes.setHandler(operations.resolveAllConflictedFilesByNewerOnes);
}
export { createConflictCheckingHandlers } from "./checker";
export type { ConflictCheckingDependencies, ConflictCheckingHandlers } from "./checker";
export type {
ConflictResolutionOperations,
ConflictResolutionOperationsDependencies,
ConflictResolutionSettings,
} from "./operations";
export { createConflictResolutionOperations } from "./operations";
@@ -0,0 +1,308 @@
import {
AUTO_MERGED,
CANCELLED,
LOG_LEVEL_INFO,
LOG_LEVEL_NOTICE,
LOG_LEVEL_VERBOSE,
MISSING_OR_ERROR,
NOT_CONFLICTED,
type diff_check_result,
type FilePathWithPrefix,
type ObsidianLiveSyncSettings,
} from "@vrtmrz/livesync-commonlib/compat/common/types";
import { isCustomisationSyncMetadata, isPluginMetadata } from "@vrtmrz/livesync-commonlib/compat/common/typeUtils";
import { TARGET_IS_NEW } from "@vrtmrz/livesync-commonlib/compat/common/models/shared.const.symbols";
import { compareMTime, displayRev } from "@vrtmrz/livesync-commonlib/compat/common/utils";
import type { DatabaseFileAccess } from "@vrtmrz/livesync-commonlib/compat/interfaces/DatabaseFileAccess";
import type { IFileHandler } from "@vrtmrz/livesync-commonlib/compat/interfaces/FileHandler";
import type {
IAppLifecycleService,
IConflictService,
IReplicationService,
IVaultService,
} from "@vrtmrz/livesync-commonlib/compat/services/base/IService";
import type { LiveSyncLocalDB } from "@vrtmrz/livesync-commonlib/compat/pouchdb/LiveSyncLocalDB";
import type { LogFunction } from "@vrtmrz/livesync-commonlib/compat/services/lib/logUtils";
import type { LiveSyncEventHub } from "@vrtmrz/livesync-commonlib/context";
import type { StorageAccess } from "@vrtmrz/livesync-commonlib/compat/interfaces/StorageAccess";
import { EVENT_CONFLICT_CANCELLED } from "@vrtmrz/livesync-commonlib/compat/events/coreEvents";
import { NO_INTERACTION } from "@vrtmrz/livesync-commonlib/replication";
import { isLockAcquired, serialized } from "octagonal-wheels/concurrency/lock";
import diff_match_patch from "diff-match-patch";
import { stripAllPrefixes, isPlainText } from "@vrtmrz/livesync-commonlib/compat/string_and_binary/path";
export type ConflictResolutionSettings = Pick<
ObsidianLiveSyncSettings,
"disableMarkdownAutoMerge" | "resolveConflictsByNewerFile" | "syncAfterMerge" | "showMergeDialogOnlyOnActive"
>;
export interface ConflictResolutionOperationsDependencies {
readonly events: Pick<LiveSyncEventHub, "emitEvent">;
readonly databaseFileAccess: Pick<DatabaseFileAccess, "fetchEntryMeta" | "getConflictedRevs" | "storeContent">;
readonly fileHandler: Pick<IFileHandler, "deleteRevisionFromDB" | "dbToStorage">;
readonly localDatabase: () => Pick<LiveSyncLocalDB, "tryAutoMerge">;
readonly conflict: Pick<
IConflictService,
"queueCheckFor" | "resolveByDeletingRevision" | "resolveByUserInteraction"
>;
readonly replication: Pick<IReplicationService, "replicateUnattendedByEvent">;
readonly appLifecycle: Pick<IAppLifecycleService, "isSuspended">;
readonly vault: Pick<IVaultService, "getActiveFilePath">;
readonly storageAccess: Pick<StorageAccess, "getFileNames">;
readonly currentSettings: () => ConflictResolutionSettings;
readonly log: LogFunction;
}
export interface ConflictResolutionOperations {
readonly resolveByDeletingRevision: (
path: FilePathWithPrefix,
deleteRevision: string,
subTitle?: string,
showNotice?: boolean
) => Promise<typeof MISSING_OR_ERROR | typeof AUTO_MERGED>;
readonly checkConflictAndPerformAutoMerge: (path: FilePathWithPrefix) => Promise<diff_check_result>;
readonly resolve: (filename: FilePathWithPrefix) => Promise<void>;
readonly resolveByNewest: (filename: FilePathWithPrefix, showNotice?: boolean) => Promise<boolean>;
readonly resolveAllConflictedFilesByNewerOnes: () => Promise<void>;
}
export function createConflictResolutionOperations(
dependencies: ConflictResolutionOperationsDependencies
): ConflictResolutionOperations {
const latestResolveRequestByFilename = new Map<FilePathWithPrefix, number>();
let nextResolveRequestId = 0;
const resolveByDeletingRevision = async (
path: FilePathWithPrefix,
deleteRevision: string,
subTitle = "",
showNotice = true
): Promise<typeof MISSING_OR_ERROR | typeof AUTO_MERGED> => {
const title = `Resolving ${subTitle ? `[${subTitle}]` : ""}:`;
if (!(await dependencies.fileHandler.deleteRevisionFromDB(path, deleteRevision))) {
dependencies.log(
`${title} Could not delete conflicted revision ${displayRev(deleteRevision)} of ${path}`,
LOG_LEVEL_NOTICE
);
return MISSING_OR_ERROR;
}
dependencies.events.emitEvent(EVENT_CONFLICT_CANCELLED, path);
dependencies.log(
`${title} Conflicted revision has been deleted ${displayRev(deleteRevision)} ${path}`,
LOG_LEVEL_INFO
);
if ((await dependencies.databaseFileAccess.getConflictedRevs(path)).length != 0) {
dependencies.log(`${title} some conflicts are left in ${path}`, LOG_LEVEL_INFO);
return AUTO_MERGED;
}
if (isPluginMetadata(path) || isCustomisationSyncMetadata(path)) {
dependencies.log(`${title} ${path} is a plugin metadata file, no need to write to storage`, LOG_LEVEL_INFO);
return AUTO_MERGED;
}
// If no conflicts were found, write the resolved content to the storage.
if (!(await dependencies.fileHandler.dbToStorage(path, stripAllPrefixes(path), true))) {
dependencies.log(`Could not write the resolved content to the storage: ${path}`, LOG_LEVEL_NOTICE);
return MISSING_OR_ERROR;
}
const level = subTitle.indexOf("same") !== -1 || !showNotice ? LOG_LEVEL_INFO : LOG_LEVEL_NOTICE;
dependencies.log(`${path} has been merged automatically`, level);
return AUTO_MERGED;
};
const checkConflictAndPerformAutoMerge = async (path: FilePathWithPrefix): Promise<diff_check_result> => {
const ret = await dependencies
.localDatabase()
.tryAutoMerge(path, !dependencies.currentSettings().disableMarkdownAutoMerge);
if ("ok" in ret) {
return ret.ok;
}
if ("result" in ret) {
const p = ret.result;
// 1. Store the merged content to the storage.
if (!(await dependencies.databaseFileAccess.storeContent(path, p))) {
dependencies.log(`Merged content cannot be stored:${path}`, LOG_LEVEL_NOTICE);
return MISSING_OR_ERROR;
}
// 2. Delete the conflicted revision and reflect the result if all conflicts are gone.
return await dependencies.conflict.resolveByDeletingRevision(path, ret.conflictedRev, "Sensible");
}
const { rightRev, leftLeaf, rightLeaf } = ret;
// Should be one or more conflicts.
if (leftLeaf == false) {
dependencies.log(`could not get current revisions:${path}`, LOG_LEVEL_NOTICE);
return MISSING_OR_ERROR;
}
if (rightLeaf == false) {
// A locally unreadable conflict leaf may still be recoverable from another
// replica or backup. Keep it visible for explicit repair instead of treating
// missing chunks as evidence that the branch is obsolete.
dependencies.log(`could not read conflicted revision ${rightRev}:${path}`, LOG_LEVEL_NOTICE);
return MISSING_OR_ERROR;
}
const isSame = leftLeaf.data == rightLeaf.data && leftLeaf.deleted == rightLeaf.deleted;
const isBinary = !isPlainText(path);
const alwaysNewer = dependencies.currentSettings().resolveConflictsByNewerFile;
if (isSame || isBinary || alwaysNewer) {
const result = compareMTime(leftLeaf.mtime, rightLeaf.mtime);
let loser = leftLeaf;
// If lMtime > rMtime.
if (result != TARGET_IS_NEW) {
loser = rightLeaf;
}
const subTitle = [
`${isSame ? "same" : ""}`,
`${isBinary ? "binary" : ""}`,
`${alwaysNewer ? "alwaysNewer" : ""}`,
]
.filter((e) => e.trim())
.join(",");
return await dependencies.conflict.resolveByDeletingRevision(path, loser.rev, subTitle);
}
// Make diff.
const dmp = new diff_match_patch();
const diff = dmp.diff_main(leftLeaf.data, rightLeaf.data);
dmp.diff_cleanupSemantic(diff);
dependencies.log(`conflict(s) found:${path}`);
return {
left: leftLeaf,
right: rightLeaf,
diff: diff,
};
};
const resolve = async (filename: FilePathWithPrefix): Promise<void> => {
const requestId = ++nextResolveRequestId;
latestResolveRequestByFilename.set(filename, requestId);
const serialisationKey = `conflict-resolve:${filename}`;
if (isLockAcquired(serialisationKey)) {
// A later check for the same file makes any open comparison stale.
// Close it before waiting for the current resolver to release the
// per-file lock. Dialogues for other paths remain untouched.
dependencies.events.emitEvent(EVENT_CONFLICT_CANCELLED, filename);
}
return await serialized(serialisationKey, async () => {
if (latestResolveRequestByFilename.get(filename) !== requestId) {
return;
}
try {
const conflictCheckResult = await checkConflictAndPerformAutoMerge(filename);
if (latestResolveRequestByFilename.get(filename) !== requestId) {
return;
}
if (conflictCheckResult === NOT_CONFLICTED) {
dependencies.events.emitEvent(EVENT_CONFLICT_CANCELLED, filename);
dependencies.log(`[conflict] Not conflicted or cancelled: ${filename}`, LOG_LEVEL_VERBOSE);
return;
}
if (conflictCheckResult === MISSING_OR_ERROR || conflictCheckResult === CANCELLED) {
// Nothing to do.
dependencies.log(`[conflict] Not conflicted or cancelled: ${filename}`, LOG_LEVEL_VERBOSE);
return;
}
if (conflictCheckResult === AUTO_MERGED) {
// Auto resolved, but need to check again.
if (dependencies.currentSettings().syncAfterMerge && !dependencies.appLifecycle.isSuspended()) {
// Wait for the running replication, if not running replication, run it once.
await dependencies.replication.replicateUnattendedByEvent({
trigger: "merge",
interaction: NO_INTERACTION,
});
}
dependencies.log("[conflict] Automatically merged, but we have to check it again");
await dependencies.conflict.queueCheckFor(filename);
return;
}
if (dependencies.currentSettings().showMergeDialogOnlyOnActive) {
const activeFile = dependencies.vault.getActiveFilePath();
if (activeFile && activeFile != filename) {
dependencies.log(
`[conflict] ${filename} is conflicted. Merging process has been postponed to the file have got opened.`,
LOG_LEVEL_NOTICE
);
return;
}
}
dependencies.log("[conflict] Manual merge required!");
dependencies.events.emitEvent(EVENT_CONFLICT_CANCELLED, filename);
await dependencies.conflict.resolveByUserInteraction(filename, conflictCheckResult);
} finally {
if (latestResolveRequestByFilename.get(filename) === requestId) {
latestResolveRequestByFilename.delete(filename);
}
}
});
};
const resolveByNewest = async (filename: FilePathWithPrefix, showNotice = true): Promise<boolean> => {
const currentRev = await dependencies.databaseFileAccess.fetchEntryMeta(filename, undefined, true);
if (currentRev == false) {
dependencies.log(`Could not get current revision of ${filename}`);
return Promise.resolve(false);
}
const revs = await dependencies.databaseFileAccess.getConflictedRevs(filename);
if (revs.length == 0) {
return Promise.resolve(true);
}
const mTimeAndRev = (
[
[currentRev.mtime, currentRev._rev],
...(await Promise.all(
revs.map(async (rev) => {
const leaf = await dependencies.databaseFileAccess.fetchEntryMeta(filename, rev);
if (leaf == false) {
return [0, rev];
}
return [leaf.mtime, rev];
})
)),
] as [number, string][]
).sort((a, b) => {
const diff = b[0] - a[0];
if (diff == 0) {
return a[1].localeCompare(b[1], "en", { numeric: true });
}
return diff;
});
dependencies.log(
`Resolving conflict by newest: ${filename} (Newest: ${new Date(mTimeAndRev[0][0]).toLocaleString()}) (${mTimeAndRev.length} revisions exists)`
);
for (let i = 1; i < mTimeAndRev.length; i++) {
dependencies.log(
`conflict: Deleting the older revision ${mTimeAndRev[i][1]} (${new Date(mTimeAndRev[i][0]).toLocaleString()}) of ${filename}`
);
await resolveByDeletingRevision(filename, mTimeAndRev[i][1], "NEWEST", showNotice);
}
return true;
};
const resolveAllConflictedFilesByNewerOnes = async (): Promise<void> => {
dependencies.log(`Resolving conflicts by newer ones`, LOG_LEVEL_NOTICE);
const files = await dependencies.storageAccess.getFileNames();
let i = 0;
for (const file of files) {
i++;
if (i % 10 === 0)
dependencies.log(
`Check and Processing ${i} / ${files.length}`,
LOG_LEVEL_NOTICE,
"resolveAllConflictedFilesByNewerOnes"
);
await resolveByNewest(file, false);
}
dependencies.log(`Done!`, LOG_LEVEL_NOTICE, "resolveAllConflictedFilesByNewerOnes");
};
return {
resolveByDeletingRevision,
checkConflictAndPerformAutoMerge,
resolve,
resolveByNewest,
resolveAllConflictedFilesByNewerOnes,
};
}
@@ -0,0 +1,92 @@
import type { NecessaryServices } from "@vrtmrz/livesync-commonlib/compat/interfaces/ServiceModule";
import { createInstanceLogFunction } from "@vrtmrz/livesync-commonlib/compat/services/lib/logUtils";
import { fireAndForget } from "octagonal-wheels/promises";
import type { Editor, MarkdownFileInfo, MarkdownView } from "@/deps.ts";
import type { FilePathWithPrefix } from "@vrtmrz/livesync-commonlib/compat/common/types";
import { EVENT_CONFLICT_CANCELLED, EVENT_PLUGIN_UNLOADED } from "@/common/events.ts";
import { createInteractiveConflictResolutionOperations } from "./operations";
import type { ConflictResolveDialogueFactory } from "./types";
export type InteractiveConflictResolutionHost = NecessaryServices<
"API" | "UI" | "appLifecycle" | "conflict" | "replication" | "vault" | "database" | "setting" | "path",
"databaseFileAccess"
>;
export function useInteractiveConflictResolutionFeature(
host: InteractiveConflictResolutionHost,
createDialogue: ConflictResolveDialogueFactory
): void {
const services = host.services;
const operations = createInteractiveConflictResolutionOperations({
events: services.context.events,
databaseFileAccess: host.serviceModules.databaseFileAccess,
localDatabase: () => services.database.localDatabase,
confirm: services.UI.confirm,
path: services.path,
vault: services.vault,
appLifecycle: services.appLifecycle,
conflict: services.conflict,
replication: services.replication,
currentSettings: () => services.setting.currentSettings(),
createDialogue,
log: createInstanceLogFunction("SF:InteractiveConflictResolution", services.API),
});
services.appLifecycle.onScanningStartupIssues.addHandler(operations.scanStartupIssues);
services.appLifecycle.onInitialise.addHandler(() => {
services.API.addCommand({
id: "livesync-checkdoc-conflicted",
name: "Resolve if conflicted.",
editorCallback: (editor: Editor, view: MarkdownView | MarkdownFileInfo) => {
const file = view.file;
if (!file) return;
void operations.requestConflictResolution(file.path as FilePathWithPrefix);
},
});
services.API.addCommand({
id: "livesync-conflictcheck",
name: "Pick a file to resolve conflict",
callback: async () => {
await operations.pickFileForResolve();
},
});
services.API.addCommand({
id: "livesync-all-conflictcheck",
name: "Resolve all conflicted files",
callback: async () => {
await operations.allConflictCheck();
},
});
return Promise.resolve(true);
});
services.appLifecycle.getUnresolvedMessages.addHandler(operations.getActiveConflictMessages);
services.conflict.resolveByUserInteraction.addHandler(operations.resolveByUserInteraction);
const eventSubscriptions = new AbortController();
const dispose = () => {
// Stop the refresh listener before cancellation so that unloading does
// not start a database read which can race with database disposal.
eventSubscriptions.abort();
operations.dispose();
};
services.context.events.onEvent(
EVENT_CONFLICT_CANCELLED,
(filename) => {
operations.invalidateWaitingResolution(filename);
fireAndForget(() => operations.refreshConflictState(filename));
},
{ signal: eventSubscriptions.signal }
);
services.context.events.onceEvent(EVENT_PLUGIN_UNLOADED, dispose, { signal: eventSubscriptions.signal });
services.appLifecycle.onUnload.addHandler(() => {
dispose();
return Promise.resolve(true);
});
}
export { createInteractiveConflictResolutionOperations } from "./operations";
export type {
InteractiveConflictResolutionOperations,
InteractiveConflictResolutionOperationsDependencies,
} from "./operations";
export { POSTPONED } from "./types";
export type { ConflictResolveDialogue, ConflictResolveDialogueFactory, MergeDialogResult } from "./types";
@@ -0,0 +1,898 @@
import { describe, expect, it, vi } from "vitest";
import { createServiceContext } from "@vrtmrz/livesync-commonlib/context";
import {
AUTO_MERGED,
CANCELLED,
DEFAULT_SETTINGS,
LEAVE_TO_SUBSEQUENT,
MISSING_OR_ERROR,
type FilePathWithPrefix,
type diff_result,
} from "@vrtmrz/livesync-commonlib/compat/common/types";
import { EVENT_CONFLICT_CANCELLED, EVENT_ON_UNRESOLVED_ERROR, EVENT_PLUGIN_UNLOADED } from "@/common/events";
import {
createInteractiveConflictResolutionOperations,
type InteractiveConflictResolutionOperationsDependencies,
} from "./operations";
import { POSTPONED, type ConflictResolveDialogueFactory, type MergeDialogResult } from "./types";
import { useInteractiveConflictResolutionFeature } from "./index";
const path = "note.md" as FilePathWithPrefix;
const conflict: diff_result = {
left: { rev: "2-left", data: "left", ctime: 1, mtime: 2 },
right: { rev: "2-right", data: "right", ctime: 1, mtime: 2 },
diff: [],
};
async function* documents(items: unknown[]) {
for (const item of items) {
yield item;
}
}
function createOperations(conflictedRevisions: string[] = ["2-right"]) {
const context = createServiceContext();
let dialogueResult: unknown = POSTPONED;
const constructed = { value: 0 };
const getDBEntry = vi.fn(async (): Promise<false | { _rev: string; _conflicts?: string[] }> => false);
const findAllDocs = vi.fn(() => documents([]));
const askSelectString = vi.fn(async (): Promise<string> => "");
const askInPopup = vi.fn();
const queueCheckFor = vi.fn(async () => undefined);
const ensureAllProcessed = vi.fn(async () => true);
const resolveByDeletingRevision = vi.fn(async () => AUTO_MERGED);
const getConflictedRevs = vi.fn(async () => conflictedRevisions);
const storeContent = vi.fn(async () => true);
const isSuspended = vi.fn(() => false);
const getActiveFilePath = vi.fn((): FilePathWithPrefix | undefined => path);
const replicateUnattendedByEvent = vi.fn(async () => ({ status: "completed" as const }));
const currentSettings = vi.fn(() => ({ syncAfterMerge: false }));
const log = vi.fn();
const operations = createInteractiveConflictResolutionOperations({
events: context.events,
databaseFileAccess: {
getConflictedRevs,
storeContent,
},
localDatabase: () => ({ getDBEntry, findAllDocs }),
confirm: {
askSelectString,
askInPopup,
},
path: { getPath: vi.fn((entry: { path: FilePathWithPrefix }) => entry.path) },
vault: { getActiveFilePath },
appLifecycle: { isSuspended },
conflict: { queueCheckFor, ensureAllProcessed, resolveByDeletingRevision },
replication: {
replicateUnattendedByEvent,
},
currentSettings,
createDialogue: vi.fn(() => {
constructed.value++;
return {
open: vi.fn(),
waitForResult: vi.fn(async () => dialogueResult),
};
}),
log,
} as unknown as InteractiveConflictResolutionOperationsDependencies);
return {
askSelectString,
askInPopup,
constructed,
context,
dialogueResult: {
get value() {
return dialogueResult;
},
set value(value: unknown) {
dialogueResult = value;
},
},
findAllDocs,
getDBEntry,
getActiveFilePath,
getConflictedRevs,
ensureAllProcessed,
currentSettings,
isSuspended,
log,
operations,
queueCheckFor,
replicateUnattendedByEvent,
resolveByDeletingRevision,
storeContent,
conflictedRevisions,
};
}
type ControlledDialogue = {
readonly filename: FilePathWithPrefix;
readonly open: ReturnType<typeof vi.fn>;
readonly finish: (result: MergeDialogResult) => void;
readonly waitForResult: () => Promise<MergeDialogResult>;
};
function createControlledDialogueFactory(
context: ReturnType<typeof createServiceContext>,
dialogues: ControlledDialogue[]
): ConflictResolveDialogueFactory {
const createDialogue = vi.fn((filename: FilePathWithPrefix) => {
let settle!: (result: MergeDialogResult) => void;
let offConflictCancelled: (() => void) | undefined;
const result = new Promise<MergeDialogResult>((resolve) => {
settle = resolve;
});
const finish = (dialogueResult: MergeDialogResult) => {
offConflictCancelled?.();
offConflictCancelled = undefined;
settle(dialogueResult);
};
const dialogue: ControlledDialogue = {
filename,
open: vi.fn(() => {
offConflictCancelled = context.events.onEvent(EVENT_CONFLICT_CANCELLED, (cancelledFilename) => {
if (cancelledFilename === filename) {
finish(CANCELLED);
}
});
}),
finish,
waitForResult: () => result,
};
dialogues.push(dialogue);
return dialogue;
});
return createDialogue as unknown as ConflictResolveDialogueFactory;
}
function createDialogueConcurrencyHarness() {
const context = createServiceContext();
const emitEvent = vi.spyOn(context.events, "emitEvent");
const dialogues: ControlledDialogue[] = [];
const createDialogue = createControlledDialogueFactory(context, dialogues);
const operations = createInteractiveConflictResolutionOperations({
events: context.events,
databaseFileAccess: {
getConflictedRevs: vi.fn(async () => ["2-right"]),
storeContent: vi.fn(async () => true),
},
localDatabase: () => ({
getDBEntry: vi.fn(async () => false),
findAllDocs: vi.fn(() => documents([])),
}),
confirm: { askSelectString: vi.fn(), askInPopup: vi.fn() },
path: { getPath: vi.fn() },
vault: { getActiveFilePath: vi.fn(() => path) },
appLifecycle: { isSuspended: vi.fn(() => false) },
conflict: {
queueCheckFor: vi.fn(async () => undefined),
ensureAllProcessed: vi.fn(async () => true),
resolveByDeletingRevision: vi.fn(async () => AUTO_MERGED),
},
replication: {
replicateUnattendedByEvent: vi.fn(async () => ({ status: "completed" as const })),
},
currentSettings: () => ({ syncAfterMerge: false }),
createDialogue: createDialogue as ConflictResolveDialogueFactory,
log: vi.fn(),
} as unknown as InteractiveConflictResolutionOperationsDependencies);
return { context, createDialogue, dialogues, emitEvent, operations };
}
describe("interactive conflict resolution operations", () => {
it("replaces an active same-file dialogue with only the newest waiting request", async () => {
const fixture = createDialogueConcurrencyHarness();
const first = fixture.operations.resolveByUserInteraction(path, conflict);
await vi.waitFor(() => expect(fixture.dialogues).toHaveLength(1));
const superseded = fixture.operations.resolveByUserInteraction(path, conflict);
const replacement = fixture.operations.resolveByUserInteraction(path, conflict);
await vi.waitFor(() => expect(fixture.dialogues).toHaveLength(2));
expect(fixture.emitEvent).toHaveBeenCalledWith(EVENT_CONFLICT_CANCELLED, path);
expect(fixture.dialogues[1].filename).toBe(path);
expect(fixture.createDialogue).toHaveBeenCalledTimes(2);
fixture.dialogues[1].finish(CANCELLED);
await Promise.all([first, superseded, replacement]);
});
it("keeps a same-file replacement request when its cancellation refresh sees the active dialogue", async () => {
const dialogues: ControlledDialogue[] = [];
const fixture = createFeatureHarness((context) => createControlledDialogueFactory(context, dialogues));
const resolveByUserInteraction = fixture.handlers.resolveByUserInteraction!;
const first = resolveByUserInteraction(path, conflict);
await vi.waitFor(() => expect(dialogues).toHaveLength(1));
const replacement = resolveByUserInteraction(path, conflict);
await vi.waitFor(() => expect(dialogues).toHaveLength(2));
expect(dialogues[1]?.filename).toBe(path);
dialogues[1]?.finish(CANCELLED);
await Promise.all([first, replacement]);
});
it("keeps a different file waiting until the active dialogue finishes", async () => {
const fixture = createDialogueConcurrencyHarness();
const otherPath = "other.md" as FilePathWithPrefix;
const first = fixture.operations.resolveByUserInteraction(path, conflict);
await vi.waitFor(() => expect(fixture.dialogues).toHaveLength(1));
const waiting = fixture.operations.resolveByUserInteraction(otherPath, conflict);
await Promise.resolve();
expect(fixture.dialogues).toHaveLength(1);
expect(fixture.emitEvent).not.toHaveBeenCalledWith(EVENT_CONFLICT_CANCELLED, otherPath);
fixture.dialogues[0].finish(CANCELLED);
await vi.waitFor(() => expect(fixture.dialogues).toHaveLength(2));
expect(fixture.dialogues[1].filename).toBe(otherPath);
fixture.dialogues[1].finish(CANCELLED);
await Promise.all([first, waiting]);
});
it("does not reopen an unchanged conflict after the user chooses Not now", async () => {
const { constructed, operations } = createOperations();
await operations.resolveByUserInteraction(path, conflict);
await operations.resolveByUserInteraction(path, conflict);
expect(constructed.value).toBe(1);
});
it("does not treat cancellation by another conflict dialogue as Not now", async () => {
const { constructed, dialogueResult, operations } = createOperations();
dialogueResult.value = CANCELLED;
await operations.resolveByUserInteraction(path, conflict);
await operations.resolveByUserInteraction(path, conflict);
expect(constructed.value).toBe(2);
});
it("allows an explicit resolution request to reopen a postponed conflict", async () => {
const { constructed, dialogueResult, ensureAllProcessed, operations, queueCheckFor } = createOperations();
await operations.resolveByUserInteraction(path, conflict);
await operations.requestConflictResolution(path);
await operations.resolveByUserInteraction(path, conflict);
expect(queueCheckFor).toHaveBeenCalledWith(path);
expect(queueCheckFor).toHaveBeenCalledOnce();
expect(ensureAllProcessed).toHaveBeenCalledOnce();
expect(constructed.value).toBe(2);
expect(dialogueResult.value).toBe(POSTPONED);
});
it("opens a later conflict after the postponed conflict episode has resolved", async () => {
const conflictedRevisions = ["2-right"];
const { constructed, operations } = createOperations(conflictedRevisions);
await operations.resolveByUserInteraction(path, conflict);
conflictedRevisions.splice(0);
await operations.refreshConflictState(path);
conflictedRevisions.push("4-later");
await operations.resolveByUserInteraction(path, conflict);
expect(constructed.value).toBe(2);
});
it.each([
{ label: "cannot be read", result: false as const, refreshes: false },
{ label: "has no conflicts", result: { _rev: "2-left", _conflicts: [] as string[] }, refreshes: true },
])("exits safely when the live database $label after a user selection", async ({ result, refreshes }) => {
const fixture = createOperations();
fixture.dialogueResult.value = "2-right";
fixture.getDBEntry.mockResolvedValue(result);
const emitEvent = vi.spyOn(fixture.context.events, "emitEvent");
await expect(fixture.operations.resolveByUserInteraction(path, conflict)).resolves.toBe(false);
expect(fixture.resolveByDeletingRevision).not.toHaveBeenCalled();
expect(fixture.replicateUnattendedByEvent).not.toHaveBeenCalled();
expect(fixture.queueCheckFor).not.toHaveBeenCalled();
if (refreshes) {
expect(fixture.getConflictedRevs).toHaveBeenCalledWith(path);
expect(emitEvent).toHaveBeenCalledWith(EVENT_ON_UNRESOLVED_ERROR);
} else {
expect(fixture.getConflictedRevs).not.toHaveBeenCalled();
expect(emitEvent).not.toHaveBeenCalled();
}
});
it("contributes the active conflict to the existing unresolved-message display", async () => {
const { operations } = createOperations();
await expect(operations.getActiveConflictMessages()).resolves.toEqual(["This file has unresolved conflicts."]);
});
it("returns no unresolved message when there is no active file", async () => {
const { getActiveFilePath, getConflictedRevs, operations } = createOperations();
getActiveFilePath.mockReturnValue(undefined);
await expect(operations.getActiveConflictMessages()).resolves.toEqual([]);
expect(getConflictedRevs).not.toHaveBeenCalled();
});
it("logs a failed conflict inspection and returns no unresolved messages", async () => {
const fixture = createOperations();
const error = new Error("database unavailable");
fixture.getConflictedRevs.mockRejectedValue(error);
await expect(fixture.operations.getActiveConflictMessages()).resolves.toEqual([]);
expect(fixture.log).toHaveBeenCalledWith("Could not inspect the conflict state of note.md", expect.anything());
expect(fixture.log).toHaveBeenCalledWith(error, expect.anything());
});
it("removes the active warning once the conflict has resolved", async () => {
const conflictedRevisions = ["2-right"];
const { operations } = createOperations(conflictedRevisions);
await expect(operations.getActiveConflictMessages()).resolves.toEqual(["This file has unresolved conflicts."]);
conflictedRevisions.splice(0);
await expect(operations.getActiveConflictMessages()).resolves.toEqual([]);
});
it("reports the number of live versions and reduces it after each resolved pair", async () => {
const conflictedRevisions = ["2-second", "2-third"];
const { operations } = createOperations(conflictedRevisions);
await expect(operations.getActiveConflictMessages()).resolves.toEqual([
"This file has 3 unresolved versions. They will be reviewed one pair at a time.",
]);
conflictedRevisions.shift();
await operations.refreshConflictState(path);
await expect(operations.getActiveConflictMessages()).resolves.toEqual(["This file has unresolved conflicts."]);
conflictedRevisions.shift();
await operations.refreshConflictState(path);
await expect(operations.getActiveConflictMessages()).resolves.toEqual([]);
});
it("reconstructs the remaining pair after a postponed session is restarted", async () => {
const conflictedRevisions = ["2-second", "2-third"];
const firstSession = createOperations(conflictedRevisions);
await firstSession.operations.resolveByUserInteraction(path, conflict);
conflictedRevisions.shift();
const restartedSession = createOperations(conflictedRevisions);
await expect(restartedSession.operations.getActiveConflictMessages()).resolves.toEqual([
"This file has unresolved conflicts.",
]);
await restartedSession.operations.resolveByUserInteraction(path, {
left: { rev: "3-merged", data: "merged", ctime: 1, mtime: 3 },
right: { rev: "2-third", data: "third", ctime: 1, mtime: 2 },
diff: [],
});
expect(restartedSession.constructed.value).toBe(1);
});
it("stores the exact concatenated diff before deleting a deterministically selected pair", async () => {
const fixture = createOperations(["2-unrelated", "2-right"]);
fixture.dialogueResult.value = LEAVE_TO_SUBSEQUENT;
fixture.getDBEntry.mockResolvedValue({
_rev: "2-left",
_conflicts: ["2-unrelated", "2-right"],
});
await fixture.operations.resolveByUserInteraction(path, {
...conflict,
diff: [
[0, "left"],
[1, "\n"],
[1, "right"],
],
});
expect(fixture.storeContent).toHaveBeenCalledWith(path, "left\nright");
expect(fixture.resolveByDeletingRevision).toHaveBeenCalledWith(path, "2-right", "UI Concatenated");
});
it("does not replicate or requeue when concatenated revision deletion fails", async () => {
const fixture = createOperations();
fixture.dialogueResult.value = LEAVE_TO_SUBSEQUENT;
fixture.getDBEntry.mockResolvedValue({ _rev: "2-left", _conflicts: ["2-right"] });
fixture.currentSettings.mockReturnValue({ syncAfterMerge: true });
fixture.resolveByDeletingRevision.mockResolvedValue(MISSING_OR_ERROR);
await fixture.operations.resolveByUserInteraction(path, {
...conflict,
diff: [
[0, "left"],
[1, "\nright"],
],
});
expect(fixture.storeContent).toHaveBeenCalledWith(path, "left\nright");
expect(fixture.resolveByDeletingRevision).toHaveBeenCalledWith(path, "2-right", "UI Concatenated");
expect(fixture.replicateUnattendedByEvent).not.toHaveBeenCalled();
expect(fixture.queueCheckFor).not.toHaveBeenCalled();
});
it("rechecks the live leaves instead of applying a stale dialogue selection", async () => {
const fixture = createOperations(["2-other"]);
fixture.dialogueResult.value = "2-right";
fixture.getDBEntry.mockResolvedValue({
_rev: "3-new-winner",
_conflicts: ["2-other"],
});
await fixture.operations.resolveByUserInteraction(path, conflict);
expect(fixture.resolveByDeletingRevision).not.toHaveBeenCalled();
expect(fixture.queueCheckFor).toHaveBeenCalledWith(path);
});
it("does not delete a revision when concatenated content cannot be stored", async () => {
const fixture = createOperations();
fixture.dialogueResult.value = LEAVE_TO_SUBSEQUENT;
fixture.getDBEntry.mockResolvedValue({ _rev: "2-left", _conflicts: ["2-right"] });
fixture.storeContent.mockResolvedValue(false);
await fixture.operations.resolveByUserInteraction(path, conflict);
expect(fixture.resolveByDeletingRevision).not.toHaveBeenCalled();
expect(fixture.replicateUnattendedByEvent).not.toHaveBeenCalled();
expect(fixture.queueCheckFor).not.toHaveBeenCalled();
});
it("does not replicate or requeue when selected revision deletion fails", async () => {
const fixture = createOperations();
fixture.dialogueResult.value = "2-right";
fixture.getDBEntry.mockResolvedValue({ _rev: "2-left", _conflicts: ["2-right"] });
fixture.resolveByDeletingRevision.mockResolvedValue(MISSING_OR_ERROR);
await fixture.operations.resolveByUserInteraction(path, conflict);
expect(fixture.replicateUnattendedByEvent).not.toHaveBeenCalled();
expect(fixture.queueCheckFor).not.toHaveBeenCalled();
});
it("rejects an unexpected dialogue result without changing revisions", async () => {
const fixture = createOperations();
fixture.dialogueResult.value = "unexpected-result";
fixture.getDBEntry.mockResolvedValue({ _rev: "2-left", _conflicts: ["2-right"] });
await fixture.operations.resolveByUserInteraction(path, conflict);
expect(fixture.log).toHaveBeenCalledWith(
`Merge: Something went wrong: ${path}, (unexpected-result)`,
expect.anything()
);
expect(fixture.resolveByDeletingRevision).not.toHaveBeenCalled();
expect(fixture.replicateUnattendedByEvent).not.toHaveBeenCalled();
expect(fixture.queueCheckFor).not.toHaveBeenCalled();
});
it("replicates and requeues after a selected revision is resolved", async () => {
const fixture = createOperations();
fixture.dialogueResult.value = "2-right";
fixture.getDBEntry.mockResolvedValue({ _rev: "2-left", _conflicts: ["2-right"] });
fixture.currentSettings.mockReturnValue({ syncAfterMerge: true });
await fixture.operations.resolveByUserInteraction(path, conflict);
expect(fixture.resolveByDeletingRevision).toHaveBeenCalledWith(path, "2-right", "UI Selected");
expect(fixture.replicateUnattendedByEvent).toHaveBeenCalledWith({
trigger: "merge",
interaction: expect.anything(),
});
expect(fixture.queueCheckFor).toHaveBeenCalledWith(path);
});
it("requeues without replication while the app is suspended", async () => {
const fixture = createOperations();
fixture.dialogueResult.value = "2-right";
fixture.getDBEntry.mockResolvedValue({ _rev: "2-left", _conflicts: ["2-right"] });
fixture.currentSettings.mockReturnValue({ syncAfterMerge: true });
fixture.isSuspended.mockReturnValue(true);
await fixture.operations.resolveByUserInteraction(path, conflict);
expect(fixture.replicateUnattendedByEvent).not.toHaveBeenCalled();
expect(fixture.queueCheckFor).toHaveBeenCalledWith(path);
});
it("does not show a no-conflicts notice when an automatic repeat reaches its normal end", async () => {
const fixture = createOperations();
fixture.findAllDocs
.mockImplementationOnce(() =>
documents([
{
_id: "note-id",
_rev: "2-left",
_conflicts: ["2-right"],
path,
mtime: 2,
},
])
)
.mockImplementationOnce(() => documents([]));
fixture.askSelectString.mockResolvedValue(path);
await fixture.operations.allConflictCheck();
expect(fixture.askSelectString).toHaveBeenCalledOnce();
expect(fixture.log).not.toHaveBeenCalledWith("There are no conflicted documents", expect.anything());
});
it("shows one no-conflicts notice for an explicit selection request which starts empty", async () => {
const fixture = createOperations();
await fixture.operations.pickFileForResolve();
expect(fixture.askSelectString).not.toHaveBeenCalled();
expect(fixture.log).toHaveBeenCalledWith("There are no conflicted documents", expect.anything());
});
it("sorts multiple picker candidates newest first and returns safely on cancellation", async () => {
const fixture = createOperations();
fixture.findAllDocs.mockReturnValue(
documents([
{ _id: "ordinary-id", path: "ordinary.md", mtime: 40 },
{ _id: "old-id", path: "old.md", _conflicts: ["2-old"], mtime: 10 },
{ _id: "new-id", path: "new.md", _conflicts: ["2-new"], mtime: 30 },
{ _id: "middle-id", path: "middle.md", _conflicts: ["2-middle"], mtime: 20 },
])
);
fixture.askSelectString.mockResolvedValue("");
await expect(fixture.operations.pickFileForResolve()).resolves.toBe(false);
expect(fixture.askSelectString).toHaveBeenCalledWith("File to resolve conflict", [
"new.md",
"middle.md",
"old.md",
]);
expect(fixture.queueCheckFor).not.toHaveBeenCalled();
expect(fixture.ensureAllProcessed).not.toHaveBeenCalled();
});
it("reports conflicted documents and wires the startup popup action", async () => {
const fixture = createOperations();
fixture.findAllDocs.mockImplementation(() =>
documents([
{ _id: "ordinary-id", path: "ordinary.md", mtime: 30 },
{ _id: "first-id", path: "first.md", _conflicts: ["2-first"], mtime: 10 },
{ _id: "second-id", path: "second.md", _conflicts: ["2-second"], mtime: 20 },
])
);
await expect(fixture.operations.scanStartupIssues()).resolves.toBe(true);
expect(fixture.askInPopup).toHaveBeenCalledWith(
"conflicting-detected-on-safety",
expect.stringContaining("Some files have been left conflicted!"),
expect.any(Function)
);
expect(fixture.log).toHaveBeenCalledWith("Conflicted: first.md");
expect(fixture.log).toHaveBeenCalledWith("Conflicted: second.md");
expect(fixture.log).not.toHaveBeenCalledWith("Conflicted: ordinary.md");
const popupAction = fixture.askInPopup.mock.calls[0]?.[2] as (anchor: HTMLAnchorElement) => void;
const addEventListener = vi.fn();
const anchor = { text: "", addEventListener } as unknown as HTMLAnchorElement;
popupAction(anchor);
expect(anchor.text).toBe("HERE");
expect(addEventListener).toHaveBeenCalledWith("click", expect.any(Function));
const clickHandler = addEventListener.mock.calls[0]?.[1] as () => void;
clickHandler();
await vi.waitFor(() =>
expect(fixture.askSelectString).toHaveBeenCalledWith("File to resolve conflict", ["second.md", "first.md"])
);
});
});
function createFeatureHarness(
createDialogueForContext?: (context: ReturnType<typeof createServiceContext>) => ConflictResolveDialogueFactory
) {
const context = createServiceContext();
type FeatureLocalDatabase = {
getDBEntry: (...args: unknown[]) => Promise<unknown>;
findAllDocs: (...args: unknown[]) => AsyncGenerator<unknown>;
};
const initialLocalDatabase = {
getDBEntry: vi.fn(async () => false),
findAllDocs: vi.fn(() => documents([])),
};
let activeLocalDatabase: FeatureLocalDatabase = initialLocalDatabase;
const getLocalDatabase = vi.fn(() => activeLocalDatabase);
const replaceLocalDatabase = (replacement: FeatureLocalDatabase) => {
activeLocalDatabase = replacement;
};
const database = {} as { readonly localDatabase: ReturnType<typeof getLocalDatabase> };
Object.defineProperty(database, "localDatabase", { get: getLocalDatabase });
const handlers = {
initialise: undefined as undefined | (() => Promise<boolean>),
onUnload: undefined as undefined | (() => Promise<boolean>),
scanning: undefined as undefined | (() => Promise<boolean>),
unresolvedMessages: undefined as undefined | (() => Promise<string[]>),
resolveByUserInteraction: undefined as
| undefined
| ((filename: FilePathWithPrefix, result: diff_result) => Promise<boolean>),
};
const getConflictedRevs = vi.fn(async () => ["2-right"]);
const services = {
API: {
addCommand: vi.fn(),
addLog: vi.fn(),
},
UI: {
confirm: {
askSelectString: vi.fn(async () => ""),
askInPopup: vi.fn(),
},
},
appLifecycle: {
getUnresolvedMessages: {
addHandler: vi.fn((handler: () => Promise<string[]>) => {
handlers.unresolvedMessages = handler;
}),
},
onInitialise: {
addHandler: vi.fn((handler: () => Promise<boolean>) => {
handlers.initialise = handler;
}),
},
onScanningStartupIssues: {
addHandler: vi.fn((handler: () => Promise<boolean>) => {
handlers.scanning = handler;
}),
},
onUnload: {
addHandler: vi.fn((handler: () => Promise<boolean>) => {
handlers.onUnload = handler;
}),
},
isSuspended: vi.fn(() => false),
},
conflict: {
resolveByUserInteraction: {
addHandler: vi.fn(
(handler: (filename: FilePathWithPrefix, result: diff_result) => Promise<boolean>) => {
handlers.resolveByUserInteraction = handler;
}
),
},
queueCheckFor: vi.fn(async () => undefined),
ensureAllProcessed: vi.fn(async () => true),
resolveByDeletingRevision: vi.fn(async () => AUTO_MERGED),
},
replication: {
replicateUnattendedByEvent: vi.fn(async () => ({ status: "completed" as const })),
},
vault: { getActiveFilePath: vi.fn(() => path) },
path: { getPath: vi.fn((entry: { path: FilePathWithPrefix }) => entry.path) },
database,
setting: { currentSettings: vi.fn(() => ({ ...DEFAULT_SETTINGS, syncAfterMerge: false })) },
context,
};
const serviceModules = {
databaseFileAccess: {
getConflictedRevs,
storeContent: vi.fn(async () => true),
},
};
const createDialogue =
createDialogueForContext?.(context) ??
vi.fn(() => ({
open: vi.fn(),
waitForResult: vi.fn(async (): Promise<typeof POSTPONED> => POSTPONED),
}));
useInteractiveConflictResolutionFeature({ services, serviceModules } as never, createDialogue);
return {
context,
createDialogue,
getConflictedRevs,
getLocalDatabase,
handlers,
initialLocalDatabase,
replaceLocalDatabase,
services,
};
}
describe("interactive conflict resolution feature composition", () => {
it("registers lifecycle, command, conflict, and cancellation handlers", async () => {
const fixture = createFeatureHarness();
expect(fixture.services.appLifecycle.onScanningStartupIssues.addHandler).toHaveBeenCalledOnce();
expect(fixture.services.appLifecycle.onInitialise.addHandler).toHaveBeenCalledOnce();
expect(fixture.services.appLifecycle.getUnresolvedMessages.addHandler).toHaveBeenCalledOnce();
expect(fixture.services.conflict.resolveByUserInteraction.addHandler).toHaveBeenCalledOnce();
expect(fixture.services.appLifecycle.onUnload.addHandler).toHaveBeenCalledOnce();
expect(fixture.getLocalDatabase).not.toHaveBeenCalled();
await fixture.handlers.initialise?.();
expect(fixture.services.API.addCommand).toHaveBeenCalledTimes(3);
expect(fixture.services.API.addCommand).toHaveBeenNthCalledWith(
1,
expect.objectContaining({ id: "livesync-checkdoc-conflicted", name: "Resolve if conflicted." })
);
expect(fixture.services.API.addCommand).toHaveBeenNthCalledWith(
2,
expect.objectContaining({ id: "livesync-conflictcheck", name: "Pick a file to resolve conflict" })
);
expect(fixture.services.API.addCommand).toHaveBeenNthCalledWith(
3,
expect.objectContaining({ id: "livesync-all-conflictcheck", name: "Resolve all conflicted files" })
);
});
it("refreshes unresolved state through the host context event channel and disposes the listener", async () => {
const fixture = createFeatureHarness();
fixture.context.events.emitEvent(EVENT_CONFLICT_CANCELLED, path);
await vi.waitFor(() => expect(fixture.getConflictedRevs).toHaveBeenCalledOnce());
await fixture.handlers.onUnload?.();
fixture.getConflictedRevs.mockClear();
fixture.context.events.emitEvent(EVENT_CONFLICT_CANCELLED, path);
await Promise.resolve();
expect(fixture.getConflictedRevs).not.toHaveBeenCalled();
});
it("closes the active dialogue and drops waiting dialogues on unload", async () => {
const dialogues: ControlledDialogue[] = [];
const fixture = createFeatureHarness((context) => createControlledDialogueFactory(context, dialogues));
const resolveByUserInteraction = fixture.handlers.resolveByUserInteraction!;
const active = resolveByUserInteraction(path, conflict);
await vi.waitFor(() => expect(dialogues).toHaveLength(1));
const waiting = resolveByUserInteraction("waiting.md" as FilePathWithPrefix, conflict);
await Promise.resolve();
fixture.context.events.emitEvent(EVENT_PLUGIN_UNLOADED);
let completed = false;
const allResolutions = Promise.all([active, waiting]).then(() => {
completed = true;
});
await new Promise<void>((resolve) => setTimeout(resolve, 25));
const completedOnUnload = completed;
if (!completedOnUnload) {
dialogues[0]?.finish(CANCELLED);
await new Promise<void>((resolve) => setTimeout(resolve, 0));
dialogues[1]?.finish(CANCELLED);
await allResolutions;
}
expect(completedOnUnload).toBe(true);
expect(dialogues).toHaveLength(1);
await fixture.handlers.onUnload?.();
});
it("rejects a resolution request after unload without opening a dialogue", async () => {
const dialogues: ControlledDialogue[] = [];
const fixture = createFeatureHarness((context) => createControlledDialogueFactory(context, dialogues));
const resolveByUserInteraction = fixture.handlers.resolveByUserInteraction!;
await fixture.handlers.onUnload?.();
await expect(resolveByUserInteraction(path, conflict)).resolves.toBe(false);
expect(dialogues).toHaveLength(0);
expect(fixture.createDialogue).not.toHaveBeenCalled();
});
it("drops a waiting dialogue when its conflict is resolved elsewhere", async () => {
const dialogues: ControlledDialogue[] = [];
const fixture = createFeatureHarness((context) => createControlledDialogueFactory(context, dialogues));
const resolveByUserInteraction = fixture.handlers.resolveByUserInteraction!;
const waitingPath = "resolved-while-waiting.md" as FilePathWithPrefix;
const active = resolveByUserInteraction(path, conflict);
await vi.waitFor(() => expect(dialogues).toHaveLength(1));
const waiting = resolveByUserInteraction(waitingPath, conflict);
await Promise.resolve();
fixture.context.events.emitEvent(EVENT_CONFLICT_CANCELLED, waitingPath);
dialogues[0].finish(CANCELLED);
let completed = false;
const allResolutions = Promise.all([active, waiting]).then(() => {
completed = true;
});
await new Promise<void>((resolve) => setTimeout(resolve, 25));
const completedWithoutOpening = completed;
if (!completedWithoutOpening) {
dialogues[1]?.finish(CANCELLED);
await allResolutions;
}
expect(completedWithoutOpening).toBe(true);
expect(dialogues).toHaveLength(1);
await fixture.handlers.onUnload?.();
});
it("uses the replacement local database for operations after a reset", async () => {
const fixture = createFeatureHarness();
const replacementLocalDatabase = {
getDBEntry: vi.fn(async () => false),
findAllDocs: vi.fn(() => documents([])),
};
await fixture.handlers.scanning?.();
fixture.replaceLocalDatabase(replacementLocalDatabase);
await fixture.handlers.scanning?.();
expect(fixture.initialLocalDatabase.findAllDocs).toHaveBeenCalledOnce();
expect(replacementLocalDatabase.findAllDocs).toHaveBeenCalledOnce();
expect(fixture.getLocalDatabase).toHaveBeenCalledTimes(2);
});
it("routes registered command callbacks to conflict operations", async () => {
const fixture = createFeatureHarness();
await fixture.handlers.initialise?.();
const commands = fixture.services.API.addCommand.mock.calls.map(
([command]) => command as Record<string, unknown>
);
(commands[0].editorCallback as (editor: unknown, view: unknown) => void)({}, { file: { path } });
await vi.waitFor(() => expect(fixture.services.conflict.ensureAllProcessed).toHaveBeenCalledOnce());
await (commands[1].callback as () => Promise<void>)();
await (commands[2].callback as () => Promise<void>)();
expect(fixture.services.conflict.queueCheckFor).toHaveBeenCalledWith(path);
expect(fixture.getLocalDatabase).toHaveBeenCalledTimes(2);
});
it("reads the host's current sync setting after a composed resolution", async () => {
const fixture = createFeatureHarness(() => () => ({
open: vi.fn(),
waitForResult: vi.fn(async (): Promise<MergeDialogResult> => "2-right"),
}));
fixture.replaceLocalDatabase({
getDBEntry: vi.fn(async () => ({ _rev: "2-left", _conflicts: ["2-right"] })),
findAllDocs: vi.fn(() => documents([])),
});
fixture.services.setting.currentSettings.mockReturnValue({
...DEFAULT_SETTINGS,
syncAfterMerge: true,
});
await fixture.handlers.resolveByUserInteraction?.(path, conflict);
expect(fixture.services.setting.currentSettings).toHaveBeenCalledOnce();
expect(fixture.services.replication.replicateUnattendedByEvent).toHaveBeenCalledOnce();
});
it("does nothing when the editor command has no active file", async () => {
const fixture = createFeatureHarness();
await fixture.handlers.initialise?.();
const command = fixture.services.API.addCommand.mock.calls[0]?.[0] as {
editorCallback: (editor: unknown, view: { file: null }) => void;
};
command.editorCallback({}, { file: null });
await Promise.resolve();
expect(fixture.services.conflict.queueCheckFor).not.toHaveBeenCalled();
expect(fixture.services.conflict.ensureAllProcessed).not.toHaveBeenCalled();
expect(fixture.createDialogue).not.toHaveBeenCalled();
});
it("reports a failed startup scan without escaping the lifecycle handler", async () => {
const fixture = createFeatureHarness();
async function* failedDocuments() {
throw new Error("database unavailable");
}
fixture.replaceLocalDatabase({
getDBEntry: vi.fn(async () => false),
findAllDocs: vi.fn(() => failedDocuments()),
});
await expect(fixture.handlers.scanning?.()).resolves.toBe(false);
expect(fixture.services.API.addLog).toHaveBeenCalled();
});
});
@@ -0,0 +1,364 @@
import {
CANCELLED,
LEAVE_TO_SUBSEQUENT,
LOG_LEVEL_INFO,
LOG_LEVEL_NOTICE,
LOG_LEVEL_VERBOSE,
MISSING_OR_ERROR,
type DocumentID,
type FilePathWithPrefix,
type ObsidianLiveSyncSettings,
type diff_result,
} from "@vrtmrz/livesync-commonlib/compat/common/types";
import type { Confirm } from "@vrtmrz/livesync-commonlib/compat/interfaces/Confirm";
import type { DatabaseFileAccess } from "@vrtmrz/livesync-commonlib/compat/interfaces/DatabaseFileAccess";
import type {
IAppLifecycleService,
IConflictService,
IPathService,
IReplicationService,
IVaultService,
} from "@vrtmrz/livesync-commonlib/compat/services/base/IService";
import type { LiveSyncLocalDB } from "@vrtmrz/livesync-commonlib/compat/pouchdb/LiveSyncLocalDB";
import type { LiveSyncEventHub } from "@vrtmrz/livesync-commonlib/context";
import { fireAndForget } from "octagonal-wheels/promises";
import { serialized } from "octagonal-wheels/concurrency/lock";
import { NO_INTERACTION } from "@vrtmrz/livesync-commonlib/replication";
import { displayRev } from "@vrtmrz/livesync-commonlib/compat/common/utils";
import { EVENT_CONFLICT_CANCELLED, EVENT_ON_UNRESOLVED_ERROR } from "@/common/events.ts";
import { $msg } from "@/common/translation.ts";
import { stripAllPrefixes } from "@vrtmrz/livesync-commonlib/compat/string_and_binary/path";
import type { LogFunction } from "@vrtmrz/livesync-commonlib/compat/services/lib/logUtils";
import {
POSTPONED,
type ConflictResolveDialogue,
type ConflictResolveDialogueFactory,
type MergeDialogResult,
} from "./types";
export interface InteractiveConflictResolutionOperationsDependencies {
readonly events: Pick<LiveSyncEventHub, "emitEvent">;
readonly databaseFileAccess: Pick<DatabaseFileAccess, "getConflictedRevs" | "storeContent">;
readonly localDatabase: () => Pick<LiveSyncLocalDB, "getDBEntry" | "findAllDocs">;
readonly confirm: Pick<Confirm, "askSelectString" | "askInPopup">;
readonly path: Pick<IPathService, "getPath">;
readonly vault: Pick<IVaultService, "getActiveFilePath">;
readonly appLifecycle: Pick<IAppLifecycleService, "isSuspended">;
readonly conflict: Pick<IConflictService, "queueCheckFor" | "ensureAllProcessed" | "resolveByDeletingRevision">;
readonly replication: Pick<IReplicationService, "replicateUnattendedByEvent">;
readonly currentSettings: () => Pick<ObsidianLiveSyncSettings, "syncAfterMerge">;
readonly createDialogue: ConflictResolveDialogueFactory;
readonly log: LogFunction;
}
export interface InteractiveConflictResolutionOperations {
readonly dispose: () => void;
readonly invalidateWaitingResolution: (filename: FilePathWithPrefix) => void;
readonly getActiveConflictMessages: () => Promise<string[]>;
readonly refreshConflictState: (filename: FilePathWithPrefix) => Promise<void>;
readonly requestConflictResolution: (filename: FilePathWithPrefix) => Promise<void>;
readonly resolveByUserInteraction: (
filename: FilePathWithPrefix,
conflictCheckResult: diff_result
) => Promise<boolean>;
readonly allConflictCheck: () => Promise<void>;
readonly pickFileForResolve: (notifyIfEmpty?: boolean) => Promise<boolean>;
readonly scanStartupIssues: () => Promise<boolean>;
}
export function createInteractiveConflictResolutionOperations(
dependencies: InteractiveConflictResolutionOperationsDependencies
): InteractiveConflictResolutionOperations {
// This state deliberately belongs to one feature composition. It must not
// survive a plug-in unload or be shared with another host context.
const postponedConflictEpisodes = new Set<FilePathWithPrefix>();
const dialogueSerialisationKey = Symbol("conflict-resolve-ui");
const latestRequestByFilename = new Map<FilePathWithPrefix, number>();
let nextRequestId = 0;
let activeDialogue: { filename: FilePathWithPrefix; dialogue: ConflictResolveDialogue } | undefined;
let disposed = false;
const invalidateWaitingResolution = (filename: FilePathWithPrefix): void => {
// An active dialogue consumes this event itself. Removing its request
// here would also remove a same-path replacement which emitted the
// event to close that active dialogue. A non-active request is stale
// and must not open after an external resolution.
if (activeDialogue?.filename === filename) return;
latestRequestByFilename.delete(filename);
};
const dispose = (): void => {
if (disposed) return;
disposed = true;
latestRequestByFilename.clear();
postponedConflictEpisodes.clear();
const activeFilename = activeDialogue?.filename;
if (activeFilename !== undefined) {
dependencies.events.emitEvent(EVENT_CONFLICT_CANCELLED, activeFilename);
activeDialogue = undefined;
}
};
const getConflictVersionCount = async (filename: FilePathWithPrefix): Promise<number | undefined> => {
try {
const conflictCount = (await dependencies.databaseFileAccess.getConflictedRevs(filename)).length;
return conflictCount === 0 ? 0 : conflictCount + 1;
} catch (error) {
dependencies.log(`Could not inspect the conflict state of ${filename}`, LOG_LEVEL_VERBOSE);
dependencies.log(error, LOG_LEVEL_VERBOSE);
return undefined;
}
};
const getActiveConflictMessages = async (): Promise<string[]> => {
const filename = dependencies.vault.getActiveFilePath();
if (!filename) return [];
const versionCount = await getConflictVersionCount(filename);
if (versionCount === 0) {
postponedConflictEpisodes.delete(filename);
return [];
}
if (versionCount !== undefined && versionCount >= 3) {
return [
$msg("This file has ${COUNT} unresolved versions. They will be reviewed one pair at a time.", {
COUNT: `${versionCount}`,
}),
];
}
if (versionCount === 2 || postponedConflictEpisodes.has(filename)) {
return [$msg("This file has unresolved conflicts.")];
}
return [];
};
const refreshConflictState = async (filename: FilePathWithPrefix): Promise<void> => {
if ((await getConflictVersionCount(filename)) === 0) {
postponedConflictEpisodes.delete(filename);
}
dependencies.events.emitEvent(EVENT_ON_UNRESOLVED_ERROR);
};
const requestConflictResolution = async (filename: FilePathWithPrefix): Promise<void> => {
postponedConflictEpisodes.delete(filename);
dependencies.events.emitEvent(EVENT_ON_UNRESOLVED_ERROR);
await dependencies.conflict.queueCheckFor(filename);
await dependencies.conflict.ensureAllProcessed();
};
const resolveByUserInteraction = async (
filename: FilePathWithPrefix,
conflictCheckResult: diff_result
): Promise<boolean> => {
if (disposed) return false;
const requestId = ++nextRequestId;
if (activeDialogue?.filename === filename) {
dependencies.events.emitEvent(EVENT_CONFLICT_CANCELLED, filename);
activeDialogue = undefined;
}
latestRequestByFilename.set(filename, requestId);
// UI for resolving different files should proceed one-by-one. A newer
// request for the active file replaces its dialogue instead of waiting
// behind a comparison which is already stale.
return await serialized(dialogueSerialisationKey, async () => {
if (disposed || latestRequestByFilename.get(filename) !== requestId) {
return false;
}
try {
if (postponedConflictEpisodes.has(filename)) {
dependencies.log(`Merge: Postponed ${filename}`, LOG_LEVEL_VERBOSE);
dependencies.events.emitEvent(EVENT_ON_UNRESOLVED_ERROR);
return false;
}
dependencies.log("Merge:open conflict dialog", LOG_LEVEL_VERBOSE);
const dialogue = dependencies.createDialogue(filename, conflictCheckResult);
activeDialogue = { filename, dialogue };
let selected: MergeDialogResult;
try {
dialogue.open();
selected = await dialogue.waitForResult();
} finally {
if (activeDialogue?.dialogue === dialogue) {
activeDialogue = undefined;
}
}
if (selected === POSTPONED) {
postponedConflictEpisodes.add(filename);
dependencies.events.emitEvent(EVENT_ON_UNRESOLVED_ERROR);
dependencies.log(`Merge: Postponed ${filename}`, LOG_LEVEL_INFO);
return false;
}
if (selected === CANCELLED) {
// Cancelled by UI, or another conflict.
dependencies.log(`Merge: Cancelled ${filename}`, LOG_LEVEL_INFO);
return false;
}
const testDoc = await dependencies
.localDatabase()
.getDBEntry(filename, { conflicts: true }, false, true, true);
if (testDoc === false) {
dependencies.log(`Merge: Could not read ${filename} from the local database`, LOG_LEVEL_VERBOSE);
return false;
}
if (!testDoc._conflicts || testDoc._conflicts.length === 0) {
dependencies.log(`Merge: Nothing to do ${filename}`, LOG_LEVEL_VERBOSE);
await refreshConflictState(filename);
return false;
}
if (
testDoc._rev !== conflictCheckResult.left.rev ||
!testDoc._conflicts.includes(conflictCheckResult.right.rev)
) {
dependencies.log(
`Merge: The compared revisions changed while the dialogue was open: ${filename}`,
LOG_LEVEL_INFO
);
await refreshConflictState(filename);
await dependencies.conflict.queueCheckFor(filename);
return false;
}
const toDelete = selected;
// const toKeep = conflictCheckResult.left.rev != toDelete ? conflictCheckResult.left.rev : conflictCheckResult.right.rev;
if (toDelete === LEAVE_TO_SUBSEQUENT) {
// Concatenate both conflicted revisions.
// Create a new file by concatenating both conflicted revisions.
const p = conflictCheckResult.diff.map((e) => e[1]).join("");
const delRev = conflictCheckResult.right.rev;
if (!(await dependencies.databaseFileAccess.storeContent(filename, p))) {
dependencies.log(`Concatenated content cannot be stored:${filename}`, LOG_LEVEL_NOTICE);
return false;
}
// 2. As usual, delete the conflicted revision and if there are no conflicts, write the resolved content to the storage.
if (
(await dependencies.conflict.resolveByDeletingRevision(filename, delRev, "UI Concatenated")) ==
MISSING_OR_ERROR
) {
dependencies.log(
`Concatenated saved, but cannot delete conflicted revisions: ${filename}, (${displayRev(delRev)})`,
LOG_LEVEL_NOTICE
);
return false;
}
} else if (
typeof toDelete === "string" &&
(toDelete === conflictCheckResult.left.rev || toDelete === conflictCheckResult.right.rev)
) {
// Select one of the conflicted revision to delete.
if (
(await dependencies.conflict.resolveByDeletingRevision(filename, toDelete, "UI Selected")) ==
MISSING_OR_ERROR
) {
dependencies.log(`Merge: Something went wrong: ${filename}, (${toDelete})`, LOG_LEVEL_NOTICE);
return false;
}
} else {
dependencies.log(
`Merge: Something went wrong: ${filename}, (${String(toDelete)})`,
LOG_LEVEL_NOTICE
);
return false;
}
// In here, some merge has been processed.
// So we have to run replication if configured.
// TODO: Make this is as a event request
if (dependencies.currentSettings().syncAfterMerge && !dependencies.appLifecycle.isSuspended()) {
await dependencies.replication.replicateUnattendedByEvent({
trigger: "merge",
interaction: NO_INTERACTION,
});
}
// And, check it again.
await dependencies.conflict.queueCheckFor(filename);
return false;
} finally {
if (latestRequestByFilename.get(filename) === requestId) {
latestRequestByFilename.delete(filename);
}
}
});
};
const pickFileForResolve = async (notifyIfEmpty = true): Promise<boolean> => {
const notes: { id: DocumentID; path: FilePathWithPrefix; dispPath: string; mtime: number }[] = [];
for await (const doc of dependencies.localDatabase().findAllDocs({ conflicts: true })) {
if (!("_conflicts" in doc)) continue;
notes.push({
id: doc._id,
path: dependencies.path.getPath(doc),
dispPath: stripAllPrefixes(dependencies.path.getPath(doc)),
mtime: doc.mtime,
});
}
notes.sort((a, b) => b.mtime - a.mtime);
const notesList = notes.map((e) => e.dispPath);
if (notesList.length == 0) {
if (notifyIfEmpty) {
dependencies.log("There are no conflicted documents", LOG_LEVEL_NOTICE);
}
return false;
}
const target = await dependencies.confirm.askSelectString("File to resolve conflict", notesList);
if (target) {
const targetItem = notes.find((e) => e.dispPath == target)!;
await requestConflictResolution(targetItem.path);
return true;
}
return false;
};
const allConflictCheck = async (): Promise<void> => {
let notifyIfEmpty = true;
while (await pickFileForResolve(notifyIfEmpty)) {
notifyIfEmpty = false;
}
};
const scanStartupIssues = async (): Promise<boolean> => {
const notes: { path: string; mtime: number }[] = [];
dependencies.log(`Checking conflicted files`, LOG_LEVEL_VERBOSE);
try {
for await (const doc of dependencies.localDatabase().findAllDocs({ conflicts: true })) {
if (!("_conflicts" in doc)) continue;
notes.push({ path: dependencies.path.getPath(doc), mtime: doc.mtime });
}
if (notes.length > 0) {
dependencies.confirm.askInPopup(
`conflicting-detected-on-safety`,
`Some files have been left conflicted! Press {HERE} to resolve them, or you can do it later by "Pick a file to resolve conflict`,
(anchor) => {
anchor.text = "HERE";
anchor.addEventListener("click", () => {
fireAndForget(() => allConflictCheck());
});
}
);
dependencies.log(
`Some files have been left conflicted! Please resolve them by "Pick a file to resolve conflict". The list is written in the log.`,
LOG_LEVEL_VERBOSE
);
for (const note of notes) {
dependencies.log(`Conflicted: ${note.path}`);
}
} else {
dependencies.log(`There are no conflicting files`, LOG_LEVEL_VERBOSE);
}
} catch (error) {
dependencies.log(`Error while scanning conflicted files...`, LOG_LEVEL_NOTICE);
dependencies.log(error, LOG_LEVEL_VERBOSE);
return false;
}
return true;
};
return {
dispose,
invalidateWaitingResolution,
getActiveConflictMessages,
refreshConflictState,
requestConflictResolution,
resolveByUserInteraction,
allConflictCheck,
pickFileForResolve,
scanStartupIssues,
};
}
@@ -0,0 +1,20 @@
import {
CANCELLED,
LEAVE_TO_SUBSEQUENT,
type diff_result,
type FilePathWithPrefix,
} from "@vrtmrz/livesync-commonlib/compat/common/types";
export const POSTPONED = Symbol("postponed");
export type MergeDialogResult = typeof CANCELLED | typeof POSTPONED | typeof LEAVE_TO_SUBSEQUENT | string;
export interface ConflictResolveDialogue {
open(): void;
waitForResult(): Promise<MergeDialogResult>;
}
export type ConflictResolveDialogueFactory = (
filename: FilePathWithPrefix,
conflictCheckResult: diff_result
) => ConflictResolveDialogue;
@@ -1,27 +1,35 @@
import { addIcon } from "@/deps.ts";
import { $msg } from "@/common/translation";
import type { LiveSyncCore } from "@/main.ts";
import { AbstractModule } from "@/modules/AbstractModule.ts";
import type { NecessaryServices } from "@vrtmrz/livesync-commonlib/compat/interfaces/ServiceModule";
import {
REPLICATION_PROGRESS_PRESENTATIONS,
USER_INITIATED_REPLICATION_AUTHORITY,
} from "@vrtmrz/livesync-commonlib/replication";
// Obsidian specific menu commands.
export class ModuleObsidianMenu extends AbstractModule {
_everyOnloadStart(): Promise<boolean> {
// UI
addIcon(
"replicate",
`<g transform="matrix(1.15 0 0 1.15 -8.31 -9.52)" fill="currentColor" fill-rule="evenodd">
export type ObsidianReplicationRibbonHost = NecessaryServices<"API" | "appLifecycle" | "replication", never>;
/** The established SVG used by the Obsidian replication ribbon action. */
const REPLICATE_ICON_SVG = `<g transform="matrix(1.15 0 0 1.15 -8.31 -9.52)" fill="currentColor" fill-rule="evenodd">
<path d="m85 22.2c-0.799-4.74-4.99-8.37-9.88-8.37-0.499 0-1.1 0.101-1.6 0.101-2.4-3.03-6.09-4.94-10.3-4.94-6.09 0-11.2 4.14-12.8 9.79-5.59 1.11-9.78 6.05-9.78 12 0 6.76 5.39 12.2 12 12.2h29.9c5.79 0 10.1-4.74 10.1-10.6 0-4.84-3.29-8.88-7.68-10.2zm-2.99 14.7h-29.5c-2.3-0.202-4.29-1.51-5.29-3.53-0.899-2.12-0.699-4.54 0.698-6.46 1.2-1.61 2.99-2.52 4.89-2.52 0.299 0 0.698 0 0.998 0.101l1.8 0.303v-2.02c0-3.63 2.4-6.76 5.89-7.57 0.599-0.101 1.2-0.202 1.8-0.202 2.89 0 5.49 1.62 6.79 4.24l0.598 1.21 1.3-0.504c0.599-0.202 1.3-0.303 2-0.303 1.3 0 2.5 0.404 3.59 1.11 1.6 1.21 2.6 3.13 2.6 5.15v1.61h2c2.6 0 4.69 2.12 4.69 4.74-0.099 2.52-2.2 4.64-4.79 4.64z"/>
<path d="m53.2 49.2h-41.6c-1.8 0-3.2 1.4-3.2 3.2v28.6c0 1.8 1.4 3.2 3.2 3.2h15.8v4h-7v6h24v-6h-7v-4h15.8c1.8 0 3.2-1.4 3.2-3.2v-28.6c0-1.8-1.4-3.2-3.2-3.2zm-2.8 29h-36v-23h36z"/>
<path d="m73 49.2c1.02 1.29 1.53 2.97 1.53 4.56 0 2.97-1.74 5.65-4.39 7.04v-4.06l-7.46 7.33 7.46 7.14v-4.06c7.66-1.98 12.2-9.61 10-17-0.102-0.297-0.205-0.595-0.307-0.892z"/>
<path d="m24.1 43c-0.817-0.991-1.53-2.97-1.53-4.56 0-2.97 1.74-5.65 4.39-7.04v4.06l7.46-7.33-7.46-7.14v4.06c-7.66 1.98-12.2 9.61-10 17 0.102 0.297 0.205 0.595 0.307 0.892z"/>
</g>`
);
</g>`;
this.addRibbonIcon("replicate", $msg("moduleObsidianMenu.replicate"), async () => {
await this.services.replication.replicateUserInitiated({
/**
* Register the Obsidian-only replication ribbon action.
*
* The icon and ribbon element are intentionally kept out of the generic Basic
* commands feature; other hosts can compose the latter without Obsidian UI.
*/
export function useObsidianReplicationRibbonFeature(host: ObsidianReplicationRibbonHost): void {
const { services } = host;
services.appLifecycle.onInitialise.addHandler(() => {
addIcon("replicate", REPLICATE_ICON_SVG);
services.API.addRibbonIcon("replicate", $msg("moduleObsidianMenu.replicate"), async () => {
await services.replication.replicateUserInitiated({
trigger: "manual",
progressPresentation: REPLICATION_PROGRESS_PRESENTATIONS.NOTICE,
interaction: USER_INITIATED_REPLICATION_AUTHORITY,
@@ -29,9 +37,5 @@ export class ModuleObsidianMenu extends AbstractModule {
}).addClass("livesync-ribbon-replicate");
return Promise.resolve(true);
}
override onBindFunction(core: LiveSyncCore, services: typeof core.services): void {
services.appLifecycle.onInitialise.addHandler(this._everyOnloadStart.bind(this));
}
});
}
@@ -0,0 +1,60 @@
import { describe, expect, it, vi } from "vitest";
const addIcon = vi.hoisted(() => vi.fn());
vi.mock("@/deps.ts", () => ({ addIcon }));
import {
REPLICATION_PROGRESS_PRESENTATIONS,
USER_INITIATED_REPLICATION_AUTHORITY,
} from "@vrtmrz/livesync-commonlib/replication";
import { $msg } from "@/common/translation";
import { useObsidianReplicationRibbonFeature, type ObsidianReplicationRibbonHost } from "./obsidianReplicationRibbon";
describe("useObsidianReplicationRibbonFeature", () => {
it("registers the established icon and ribbon callback during initialisation", async () => {
let initialise: (() => Promise<unknown>) | undefined;
let ribbonCallback: (() => Promise<void>) | undefined;
const addClass = vi.fn();
const replicateUserInitiated = vi.fn(async () => ({ status: "completed" as const }));
const addRibbonIcon = vi.fn((_icon: string, _title: string, callback: () => Promise<void>) => {
ribbonCallback = callback;
return { addClass } as unknown as HTMLElement;
});
const host = {
services: {
API: { addRibbonIcon },
appLifecycle: {
onInitialise: {
addHandler: vi.fn((handler: () => Promise<unknown>) => {
initialise = handler;
}),
},
},
replication: { replicateUserInitiated },
},
} as unknown as ObsidianReplicationRibbonHost;
useObsidianReplicationRibbonFeature(host);
expect(addIcon).not.toHaveBeenCalled();
expect(addRibbonIcon).not.toHaveBeenCalled();
await expect(initialise?.()).resolves.toBe(true);
expect(addIcon).toHaveBeenCalledWith("replicate", expect.any(String));
expect(addIcon).toHaveBeenCalledWith("replicate", expect.stringContaining("c-7.66 1.98-12.2 9.61-10 17"));
expect(addRibbonIcon).toHaveBeenCalledWith(
"replicate",
$msg("moduleObsidianMenu.replicate"),
expect.any(Function)
);
expect(addClass).toHaveBeenCalledWith("livesync-ribbon-replicate");
await ribbonCallback?.();
expect(replicateUserInitiated).toHaveBeenCalledWith({
trigger: "manual",
progressPresentation: REPLICATION_PROGRESS_PRESENTATIONS.NOTICE,
interaction: USER_INITIATED_REPLICATION_AUTHORITY,
});
});
});
@@ -15,7 +15,10 @@ const taskMocks = vi.hoisted(() => ({
vi.mock("octagonal-wheels/concurrency/task", () => taskMocks);
import { ModuleConflictResolver } from "@/modules/coreFeatures/ModuleConflictResolver";
import {
createConflictResolutionOperations,
type ConflictResolutionOperationsDependencies,
} from "@/serviceFeatures/conflictResolution";
import { ModuleObsidianEvents } from "@/modules/essentialObsidian/ModuleObsidianEvents";
import {
createReplicationSchedulingContext,
@@ -224,18 +227,34 @@ describe("automatic replication triggers while P2P is active", () => {
const replicateUnattendedByEvent = vi.fn(async () => ({ status: "completed" as const }));
const queueCheckFor = vi.fn(async () => undefined);
const path = "merged.md" as FilePathWithPrefix;
const module = {
settings: p2pSettings({ syncAfterMerge: true }),
services: {
appLifecycle: { isSuspended: vi.fn(() => false) },
conflict: { queueCheckFor },
replication: { replicateUnattendedByEvent },
const operations = createConflictResolutionOperations({
events: { emitEvent: vi.fn() },
databaseFileAccess: {
fetchEntryMeta: vi.fn(),
getConflictedRevs: vi.fn(async () => []),
storeContent: vi.fn(async () => true),
},
checkConflictAndPerformAutoMerge: vi.fn(async () => AUTO_MERGED),
_log: vi.fn(),
};
fileHandler: {
deleteRevisionFromDB: vi.fn(async () => true),
dbToStorage: vi.fn(async () => true),
},
localDatabase: () => ({
tryAutoMerge: vi.fn(async () => ({ ok: AUTO_MERGED })),
}),
conflict: {
queueCheckFor,
resolveByDeletingRevision: vi.fn(async () => AUTO_MERGED),
resolveByUserInteraction: vi.fn(async () => false),
},
replication: { replicateUnattendedByEvent },
appLifecycle: { isSuspended: vi.fn(() => false) },
vault: { getActiveFilePath: vi.fn(() => undefined) },
storageAccess: { getFileNames: vi.fn(async () => []) },
currentSettings: () => p2pSettings({ syncAfterMerge: true }),
log: vi.fn(),
} as unknown as ConflictResolutionOperationsDependencies);
await (ModuleConflictResolver.prototype as any)._resolveConflict.call(module, path);
await operations.resolve(path);
expect(replicateUnattendedByEvent).toHaveBeenCalledOnce();
expect(replicateUnattendedByEvent).toHaveBeenCalledWith({
@@ -0,0 +1,23 @@
import { LOG_LEVEL_NOTICE } from "@vrtmrz/livesync-commonlib/compat/common/logger";
import type { LogFunction } from "@vrtmrz/livesync-commonlib/compat/services/lib/logUtils";
import { $msg } from "@/common/translation";
import { disableLegacyBulkChunkPreSend } from "@/common/compatibilitySettings";
import type { LegacyBulkSendSettings } from "./types";
/** Collaborators required to persist the obsolete bulk-send setting migration. */
export interface BulkSettingMigrationDependencies {
readonly settings: LegacyBulkSendSettings;
readonly log: LogFunction;
readonly saveSettings: () => Promise<void>;
}
/**
* Disable the removed automatic bulk chunk pre-send setting, retaining the
* former notice text and persistence boundary.
*/
export async function migrateBulkSendSetting(dependencies: BulkSettingMigrationDependencies): Promise<void> {
if (disableLegacyBulkChunkPreSend(dependencies.settings)) {
dependencies.log($msg("moduleMigration.logBulkSendCorrupted"), LOG_LEVEL_NOTICE);
await dependencies.saveSettings();
}
}
@@ -0,0 +1,33 @@
import { describe, expect, it, vi } from "vitest";
import { migrateBulkSendSetting, type BulkSettingMigrationDependencies } from "./bulkSettingMigration";
function createDependencies(settings: { sendChunksBulk: boolean; sendChunksBulkMaxSize: number }) {
const dependencies: BulkSettingMigrationDependencies = {
settings,
log: vi.fn(),
saveSettings: vi.fn(async () => undefined),
};
return dependencies;
}
describe("migrateBulkSendSetting", () => {
it("disables and persists an enabled obsolete bulk-send setting", async () => {
const dependencies = createDependencies({ sendChunksBulk: true, sendChunksBulkMaxSize: 16 });
await migrateBulkSendSetting(dependencies);
expect(dependencies.settings).toEqual({ sendChunksBulk: false, sendChunksBulkMaxSize: 1 });
expect(dependencies.log).toHaveBeenCalledWith(expect.any(String), expect.anything());
expect(dependencies.saveSettings).toHaveBeenCalledOnce();
});
it("does not persist an already disabled obsolete setting", async () => {
const dependencies = createDependencies({ sendChunksBulk: false, sendChunksBulkMaxSize: 16 });
await migrateBulkSendSetting(dependencies);
expect(dependencies.settings).toEqual({ sendChunksBulk: false, sendChunksBulkMaxSize: 16 });
expect(dependencies.log).not.toHaveBeenCalled();
expect(dependencies.saveSettings).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,99 @@
import {
LOG_LEVEL_NOTICE,
LOG_LEVEL_VERBOSE,
Logger,
type LOG_LEVEL,
} from "@vrtmrz/livesync-commonlib/compat/common/logger";
import type { ObsidianLiveSyncSettings } from "@vrtmrz/livesync-commonlib/compat/common/types";
import { countCompromisedChunks } from "@vrtmrz/livesync-commonlib/compat/pouchdb/negotiation";
import type { Rebuilder } from "@vrtmrz/livesync-commonlib/compat/interfaces/DatabaseRebuilder";
import type { Confirm } from "@vrtmrz/livesync-commonlib/compat/interfaces/Confirm";
import { $msg } from "@/common/translation";
interface CompromisedChunkCounter {
countCompromisedChunks(): Promise<number | boolean>;
}
/** Focused collaborators for checking and recovering insecure chunks. */
export interface CompromisedChunksDependencies {
readonly settings: Pick<ObsidianLiveSyncSettings, "encrypt">;
readonly localDatabase: {
readonly localDatabase: Parameters<typeof countCompromisedChunks>[0];
};
readonly isOnline: boolean | (() => boolean);
readonly getActiveReplicator: () => object | undefined;
readonly confirm: Pick<Confirm, "askSelectStringDialogue">;
readonly rebuilder: Pick<Rebuilder, "scheduleRebuild" | "scheduleFetch">;
readonly performRestart: () => void;
readonly log: (message: unknown, level?: LOG_LEVEL) => void;
}
function hasCompromisedChunkCounter(value: object | undefined): value is CompromisedChunkCounter {
return (
value !== undefined && "countCompromisedChunks" in value && typeof value.countCompromisedChunks === "function"
);
}
function readOnline(value: boolean | (() => boolean)): boolean {
return typeof value === "function" ? value() : value;
}
/**
* Check local and active-remote databases for insecure chunks and apply the
* former rebuild, fetch, or dismiss dialogue semantics.
*/
export async function checkCompromisedChunks(dependencies: CompromisedChunksDependencies): Promise<boolean> {
Logger(`Checking for compromised chunks...`, LOG_LEVEL_VERBOSE);
if (!dependencies.settings.encrypt) {
// If not encrypted, we do not need to check for compromised chunks.
return true;
}
// Check local database for compromised chunks
const localCompromised = await countCompromisedChunks(dependencies.localDatabase.localDatabase);
const remote = dependencies.getActiveReplicator();
const remoteCompromised =
readOnline(dependencies.isOnline) && hasCompromisedChunkCounter(remote)
? await remote.countCompromisedChunks()
: 0;
if (localCompromised === false) {
Logger(`Failed to count compromised chunks in local database`, LOG_LEVEL_NOTICE);
return false;
}
if (remoteCompromised === false) {
Logger(`Failed to count compromised chunks in remote database`, LOG_LEVEL_NOTICE);
return false;
}
if (remoteCompromised === 0 && localCompromised === 0) {
return true;
}
Logger(`Found compromised chunks : ${localCompromised} in local, ${remoteCompromised} in remote`, LOG_LEVEL_NOTICE);
const title = $msg("moduleMigration.insecureChunkExist.title");
const msg = $msg("moduleMigration.insecureChunkExist.message");
const REBUILD = $msg("moduleMigration.insecureChunkExist.buttons.rebuild");
const FETCH = $msg("moduleMigration.insecureChunkExist.buttons.fetch");
const DISMISS = $msg("moduleMigration.insecureChunkExist.buttons.later");
const buttons = [REBUILD, FETCH, DISMISS];
if (remoteCompromised != 0) {
buttons.splice(buttons.indexOf(FETCH), 1);
}
const result = await dependencies.confirm.askSelectStringDialogue(msg, buttons, {
title,
defaultAction: DISMISS,
timeout: 0,
});
if (result === REBUILD) {
// Rebuild the database
await dependencies.rebuilder.scheduleRebuild();
dependencies.performRestart();
return false;
} else if (result === FETCH) {
// Fetch the latest data from remote
await dependencies.rebuilder.scheduleFetch();
dependencies.performRestart();
return false;
} else {
// User chose to dismiss the issue
dependencies.log($msg("moduleMigration.insecureChunkExist.laterMessage"), LOG_LEVEL_NOTICE);
}
return true;
}
@@ -0,0 +1,145 @@
import { describe, expect, it, vi } from "vitest";
import { countCompromisedChunks } from "@vrtmrz/livesync-commonlib/compat/pouchdb/negotiation";
import { checkCompromisedChunks, type CompromisedChunksDependencies } from "./compromisedChunks";
vi.mock("@vrtmrz/livesync-commonlib/compat/pouchdb/negotiation", () => ({
countCompromisedChunks: vi.fn(),
}));
function selectButton(index: number) {
return async (...args: unknown[]): Promise<string | false> => {
const buttons = args[1] as readonly string[];
return buttons[index] ?? false;
};
}
function createDependencies() {
const askSelectStringDialogue = vi.fn(selectButton(2));
const scheduleRebuild = vi.fn(async () => true);
const scheduleFetch = vi.fn(async () => true);
const performRestart = vi.fn();
const getActiveReplicator = vi.fn((): object | undefined => undefined);
const log = vi.fn();
const dependencies: CompromisedChunksDependencies = {
settings: { encrypt: true },
localDatabase: { localDatabase: {} as never },
isOnline: true,
getActiveReplicator,
confirm: { askSelectStringDialogue },
rebuilder: { scheduleRebuild, scheduleFetch },
performRestart,
log,
};
return {
askSelectStringDialogue,
dependencies,
getActiveReplicator,
log,
performRestart,
scheduleFetch,
scheduleRebuild,
};
}
describe("checkCompromisedChunks", () => {
it("skips the database scan when encryption is disabled", async () => {
const fixture = createDependencies();
fixture.dependencies.settings.encrypt = false;
await expect(checkCompromisedChunks(fixture.dependencies)).resolves.toBe(true);
expect(countCompromisedChunks).not.toHaveBeenCalled();
});
it("allows start-up when local and active remote databases contain no compromised chunks", async () => {
const fixture = createDependencies();
vi.mocked(countCompromisedChunks).mockResolvedValue(0);
await expect(checkCompromisedChunks(fixture.dependencies)).resolves.toBe(true);
expect(fixture.askSelectStringDialogue).not.toHaveBeenCalled();
});
it("short-circuits when local chunk inspection fails", async () => {
const fixture = createDependencies();
vi.mocked(countCompromisedChunks).mockResolvedValue(false);
await expect(checkCompromisedChunks(fixture.dependencies)).resolves.toBe(false);
expect(fixture.askSelectStringDialogue).not.toHaveBeenCalled();
expect(fixture.performRestart).not.toHaveBeenCalled();
});
it("short-circuits when the active remote chunk inspection fails", async () => {
const fixture = createDependencies();
const remoteCount = vi.fn(async () => false);
vi.mocked(countCompromisedChunks).mockResolvedValue(0);
fixture.getActiveReplicator.mockReturnValue({ countCompromisedChunks: remoteCount });
await expect(checkCompromisedChunks(fixture.dependencies)).resolves.toBe(false);
expect(remoteCount).toHaveBeenCalledOnce();
expect(fixture.askSelectStringDialogue).not.toHaveBeenCalled();
expect(fixture.scheduleRebuild).not.toHaveBeenCalled();
expect(fixture.scheduleFetch).not.toHaveBeenCalled();
expect(fixture.performRestart).not.toHaveBeenCalled();
});
it("removes the fetch choice when compromised chunks are found on the remote", async () => {
const fixture = createDependencies();
vi.mocked(countCompromisedChunks).mockResolvedValue(1);
fixture.getActiveReplicator.mockReturnValue({
countCompromisedChunks: vi.fn(async () => 2),
});
fixture.askSelectStringDialogue.mockImplementation(selectButton(1));
await expect(checkCompromisedChunks(fixture.dependencies)).resolves.toBe(true);
expect(fixture.askSelectStringDialogue).toHaveBeenCalledWith(
expect.any(String),
expect.any(Array),
expect.objectContaining({ timeout: 0 })
);
expect(fixture.askSelectStringDialogue.mock.calls[0]?.[1]).toHaveLength(2);
expect(fixture.scheduleFetch).not.toHaveBeenCalled();
expect(fixture.log).toHaveBeenCalled();
});
it("schedules the selected recovery and stops start-up", async () => {
const fixture = createDependencies();
vi.mocked(countCompromisedChunks).mockResolvedValue(1);
fixture.askSelectStringDialogue.mockImplementation(selectButton(0));
await expect(checkCompromisedChunks(fixture.dependencies)).resolves.toBe(false);
expect(fixture.scheduleRebuild).toHaveBeenCalledOnce();
expect(fixture.scheduleFetch).not.toHaveBeenCalled();
expect(fixture.performRestart).toHaveBeenCalledOnce();
});
it("fetches local-only compromised chunks when FETCH is selected", async () => {
const fixture = createDependencies();
vi.mocked(countCompromisedChunks).mockResolvedValue(1);
fixture.askSelectStringDialogue.mockImplementation(selectButton(1));
await expect(checkCompromisedChunks(fixture.dependencies)).resolves.toBe(false);
expect(fixture.askSelectStringDialogue.mock.calls[0]?.[1]).toHaveLength(3);
expect(fixture.scheduleRebuild).not.toHaveBeenCalled();
expect(fixture.scheduleFetch).toHaveBeenCalledOnce();
expect(fixture.performRestart).toHaveBeenCalledOnce();
});
it("keeps start-up running when compromised chunks are explicitly dismissed", async () => {
const fixture = createDependencies();
vi.mocked(countCompromisedChunks).mockResolvedValue(1);
fixture.askSelectStringDialogue.mockImplementation(selectButton(2));
await expect(checkCompromisedChunks(fixture.dependencies)).resolves.toBe(true);
expect(fixture.scheduleRebuild).not.toHaveBeenCalled();
expect(fixture.scheduleFetch).not.toHaveBeenCalled();
expect(fixture.performRestart).not.toHaveBeenCalled();
expect(fixture.log).toHaveBeenCalled();
});
});
@@ -0,0 +1,60 @@
import { performDoctorConsultation, RebuildOptions } from "@vrtmrz/livesync-commonlib/compat/common/configForDoc";
import type { ObsidianLiveSyncSettings } from "@vrtmrz/livesync-commonlib/compat/common/types";
import type { Confirm } from "@vrtmrz/livesync-commonlib/compat/interfaces/Confirm";
import type { Rebuilder } from "@vrtmrz/livesync-commonlib/compat/interfaces/DatabaseRebuilder";
import type { MessageTranslator } from "@vrtmrz/livesync-commonlib/context";
/** Collaborators required to run one Config Doctor consultation. */
export interface ConfigDoctorDependencies {
readonly confirm: Confirm;
readonly translate: MessageTranslator;
readonly settings: ObsidianLiveSyncSettings;
readonly setSettings: (settings: ObsidianLiveSyncSettings) => void;
readonly saveSettings: () => Promise<void>;
readonly rebuilder: Pick<Rebuilder, "scheduleRebuild" | "scheduleFetch">;
readonly performRestart: () => void;
}
/**
* Run Config Doctor and, when requested by its result, reserve the next-start
* rebuild or fetch operation before restarting the application.
*
* The positional arguments retain the established defaults and operation
* semantics for both start-up and request-event callers.
*/
export async function runConfigDoctor(
dependencies: ConfigDoctorDependencies,
skipRebuild: boolean = false,
activateReason = "updated",
forceRescan = false
): Promise<boolean> {
const { shouldRebuild, shouldRebuildLocal, isModified, settings } = await performDoctorConsultation(
{
confirm: dependencies.confirm,
translate: dependencies.translate,
},
dependencies.settings,
{
localRebuild: skipRebuild ? RebuildOptions.SkipEvenIfRequired : RebuildOptions.AutomaticAcceptable,
remoteRebuild: skipRebuild ? RebuildOptions.SkipEvenIfRequired : RebuildOptions.AutomaticAcceptable,
activateReason,
forceRescan,
}
);
if (isModified) {
dependencies.setSettings(settings);
await dependencies.saveSettings();
}
if (!skipRebuild) {
if (shouldRebuild) {
await dependencies.rebuilder.scheduleRebuild();
dependencies.performRestart();
return false;
} else if (shouldRebuildLocal) {
await dependencies.rebuilder.scheduleFetch();
dependencies.performRestart();
return false;
}
}
return true;
}
@@ -0,0 +1,112 @@
import { describe, expect, it, vi } from "vitest";
import { performDoctorConsultation } from "@vrtmrz/livesync-commonlib/compat/common/configForDoc";
import { runConfigDoctor, type ConfigDoctorDependencies } from "./configDoctor";
vi.mock("@vrtmrz/livesync-commonlib/compat/common/configForDoc", async () => {
const actual = await vi.importActual<typeof import("@vrtmrz/livesync-commonlib/compat/common/configForDoc")>(
"@vrtmrz/livesync-commonlib/compat/common/configForDoc"
);
return {
...actual,
performDoctorConsultation: vi.fn(),
};
});
function createDependencies() {
const settings = { isConfigured: true } as never;
const setSettings = vi.fn();
const saveSettings = vi.fn(async () => undefined);
const scheduleRebuild = vi.fn(async () => true);
const scheduleFetch = vi.fn(async () => true);
const performRestart = vi.fn();
const dependencies: ConfigDoctorDependencies = {
confirm: {} as never,
translate: String,
settings,
setSettings,
saveSettings,
rebuilder: { scheduleRebuild, scheduleFetch },
performRestart,
};
return { dependencies, performRestart, saveSettings, scheduleFetch, scheduleRebuild, setSettings, settings };
}
describe("runConfigDoctor", () => {
it("persists a modified setting and keeps the configured start-up sequence running", async () => {
const fixture = createDependencies();
const nextSettings = { isConfigured: true, changed: true } as never;
vi.mocked(performDoctorConsultation).mockResolvedValue({
settings: nextSettings,
shouldRebuild: false,
shouldRebuildLocal: false,
isModified: true,
});
await expect(runConfigDoctor(fixture.dependencies)).resolves.toBe(true);
expect(performDoctorConsultation).toHaveBeenCalledWith(
{ confirm: fixture.dependencies.confirm, translate: fixture.dependencies.translate },
fixture.settings,
expect.objectContaining({
activateReason: "updated",
forceRescan: false,
})
);
expect(fixture.setSettings).toHaveBeenCalledWith(nextSettings);
expect(fixture.saveSettings).toHaveBeenCalledOnce();
expect(fixture.performRestart).not.toHaveBeenCalled();
});
it("schedules a rebuild and restarts when Doctor requires remote reconstruction", async () => {
const fixture = createDependencies();
vi.mocked(performDoctorConsultation).mockResolvedValue({
settings: fixture.settings,
shouldRebuild: true,
shouldRebuildLocal: false,
isModified: false,
});
await expect(runConfigDoctor(fixture.dependencies, false, "manual", true)).resolves.toBe(false);
expect(fixture.scheduleRebuild).toHaveBeenCalledOnce();
expect(fixture.scheduleFetch).not.toHaveBeenCalled();
expect(fixture.performRestart).toHaveBeenCalledOnce();
expect(performDoctorConsultation).toHaveBeenCalledWith(
expect.anything(),
expect.anything(),
expect.objectContaining({ activateReason: "manual", forceRescan: true })
);
});
it("schedules a local fetch and restarts when Doctor requires local reconstruction", async () => {
const fixture = createDependencies();
vi.mocked(performDoctorConsultation).mockResolvedValue({
settings: fixture.settings,
shouldRebuild: false,
shouldRebuildLocal: true,
isModified: false,
});
await expect(runConfigDoctor(fixture.dependencies)).resolves.toBe(false);
expect(fixture.scheduleRebuild).not.toHaveBeenCalled();
expect(fixture.scheduleFetch).toHaveBeenCalledOnce();
expect(fixture.performRestart).toHaveBeenCalledOnce();
});
it("skips both recovery schedules and restart when rebuilds are skipped", async () => {
const fixture = createDependencies();
vi.mocked(performDoctorConsultation).mockResolvedValue({
settings: fixture.settings,
shouldRebuild: true,
shouldRebuildLocal: true,
isModified: false,
});
await expect(runConfigDoctor(fixture.dependencies, true)).resolves.toBe(true);
expect(fixture.scheduleRebuild).not.toHaveBeenCalled();
expect(fixture.scheduleFetch).not.toHaveBeenCalled();
expect(fixture.performRestart).not.toHaveBeenCalled();
});
});
@@ -1,34 +1,30 @@
export interface ConfiguredStartupLifecycleRuntime {
databaseReady: boolean;
reportDatabaseNotReady(): void;
hasCompromisedChunks(): Promise<boolean>;
hasIncompleteDocuments(): Promise<boolean>;
waitForCompatibilityReview(): Promise<void>;
runDoctor(): Promise<boolean>;
migrateBulkSend(): Promise<void>;
}
import type { ConfiguredStartupLifecycleOperations, StartupLifecycleValue } from "./types";
export interface StartupEntryLifecycleRuntime {
configured: boolean;
inviteToOnboarding(): void;
function readValue<T>(value: StartupLifecycleValue<T>): T {
return typeof value === "function" ? (value as () => T)() : value;
}
/**
* Keeps an unconfigured Vault outside database initialisation and all
* configured-only start-up work while offering an explicit setup action.
*/
export interface StartupEntryLifecycleRuntime {
readonly configured: StartupLifecycleValue<boolean>;
readonly inviteToOnboarding: () => void;
}
export function runStartupEntryLifecycle(runtime: StartupEntryLifecycleRuntime): boolean {
if (runtime.configured) return true;
if (readValue(runtime.configured)) return true;
runtime.inviteToOnboarding();
return false;
}
/**
* Separates the inert, unconfigured startup path from checks which must run
* Separates the inert, unconfigured start-up path from checks which must run
* before an already configured device is allowed to synchronise.
*/
export async function runConfiguredStartupLifecycle(runtime: ConfiguredStartupLifecycleRuntime): Promise<boolean> {
if (!runtime.databaseReady) {
export async function runConfiguredStartupLifecycle(runtime: ConfiguredStartupLifecycleOperations): Promise<boolean> {
if (!readValue(runtime.databaseReady)) {
runtime.reportDatabaseNotReady();
return false;
}
@@ -0,0 +1,129 @@
import { describe, expect, it, vi } from "vitest";
import {
runConfiguredStartupLifecycle,
runStartupEntryLifecycle,
type StartupEntryLifecycleRuntime,
} from "./configuredStartupLifecycle";
import type { ConfiguredStartupLifecycleOperations } from "./types";
function createRuntime(events: string[] = []): ConfiguredStartupLifecycleOperations {
return {
databaseReady: true,
reportDatabaseNotReady: vi.fn(() => events.push("database-not-ready")),
hasCompromisedChunks: vi.fn(async () => {
events.push("compromised-chunks");
return true;
}),
hasIncompleteDocuments: vi.fn(async () => {
events.push("incomplete-documents");
return true;
}),
waitForCompatibilityReview: vi.fn(async () => {
events.push("compatibility-review");
}),
runDoctor: vi.fn(async () => {
events.push("doctor");
return true;
}),
migrateBulkSend: vi.fn(async () => {
events.push("bulk-send");
}),
};
}
describe("runConfiguredStartupLifecycle", () => {
it("runs all configured checks in their established order", async () => {
const events: string[] = [];
await expect(runConfiguredStartupLifecycle(createRuntime(events))).resolves.toBe(true);
expect(events).toEqual([
"compromised-chunks",
"incomplete-documents",
"compatibility-review",
"doctor",
"bulk-send",
]);
});
it("does not invoke later operations after database or integrity failure", async () => {
const events: string[] = [];
const runtime = createRuntime(events);
Object.assign(runtime, { databaseReady: false });
await expect(runConfiguredStartupLifecycle(runtime)).resolves.toBe(false);
expect(events).toEqual(["database-not-ready"]);
Object.assign(runtime, { databaseReady: true });
vi.mocked(runtime.hasCompromisedChunks).mockImplementation(async () => {
events.push("compromised-chunks");
return false;
});
await expect(runConfiguredStartupLifecycle(runtime)).resolves.toBe(false);
expect(events).toEqual(["database-not-ready", "compromised-chunks"]);
expect(runtime.hasIncompleteDocuments).not.toHaveBeenCalled();
});
it("does not invoke later operations when incomplete-document checking fails", async () => {
const events: string[] = [];
const runtime = createRuntime(events);
vi.mocked(runtime.hasIncompleteDocuments).mockImplementation(async () => {
events.push("incomplete-documents");
return false;
});
await expect(runConfiguredStartupLifecycle(runtime)).resolves.toBe(false);
expect(events).toEqual(["compromised-chunks", "incomplete-documents"]);
expect(runtime.waitForCompatibilityReview).not.toHaveBeenCalled();
expect(runtime.runDoctor).not.toHaveBeenCalled();
expect(runtime.migrateBulkSend).not.toHaveBeenCalled();
});
it("does not migrate bulk-send settings when Config Doctor fails", async () => {
const events: string[] = [];
const runtime = createRuntime(events);
vi.mocked(runtime.runDoctor).mockImplementation(async () => {
events.push("doctor");
return false;
});
await expect(runConfiguredStartupLifecycle(runtime)).resolves.toBe(false);
expect(events).toEqual(["compromised-chunks", "incomplete-documents", "compatibility-review", "doctor"]);
expect(runtime.migrateBulkSend).not.toHaveBeenCalled();
});
it("calls Config Doctor without start-up-only arguments", async () => {
const runtime = createRuntime();
await runConfiguredStartupLifecycle(runtime);
expect(runtime.runDoctor).toHaveBeenCalledWith();
});
});
describe("runStartupEntryLifecycle", () => {
it("invites an unconfigured Vault and stops configured start-up", () => {
const inviteToOnboarding = vi.fn();
const runtime: StartupEntryLifecycleRuntime = {
configured: false,
inviteToOnboarding,
};
expect(runStartupEntryLifecycle(runtime)).toBe(false);
expect(inviteToOnboarding).toHaveBeenCalledOnce();
});
it("admits a configured Vault without inviting it to onboarding", () => {
const inviteToOnboarding = vi.fn();
expect(
runStartupEntryLifecycle({
configured: true,
inviteToOnboarding,
})
).toBe(true);
expect(inviteToOnboarding).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,213 @@
import {
LOG_LEVEL_INFO,
LOG_LEVEL_NOTICE,
LOG_LEVEL_VERBOSE,
Logger,
} from "@vrtmrz/livesync-commonlib/compat/common/logger";
import {
isDeletedEntry,
isDocContentSame,
isLoadedEntry,
readAsBlob,
} from "@vrtmrz/livesync-commonlib/compat/common/utils";
import { isMetaEntry } from "@vrtmrz/livesync-commonlib/compat/common/types";
import type { LiveSyncLocalDB } from "@vrtmrz/livesync-commonlib/compat/pouchdb/LiveSyncLocalDB";
import type { StorageAccess } from "@vrtmrz/livesync-commonlib/compat/interfaces/StorageAccess";
import type { IFileHandler } from "@vrtmrz/livesync-commonlib/compat/interfaces/FileHandler";
import type { KeyValueDatabase } from "@vrtmrz/livesync-commonlib/compat/interfaces/KeyValueDatabase";
import type { Confirm } from "@vrtmrz/livesync-commonlib/compat/interfaces/Confirm";
import type { LogFunction } from "@vrtmrz/livesync-commonlib/compat/services/lib/logUtils";
import { $msg } from "@/common/translation";
import { isValidPath } from "@/common/utils";
import type { StartupPathReader } from "./types";
type NoticeGroups = {
setItem(groupKey: string, itemKey: string, item: { message: string }): void;
finish(groupKey: string): void;
};
/** Focused collaborators for the incomplete-document integrity scan and repair. */
export interface IncompleteDocumentsDependencies {
readonly localDatabase: Pick<LiveSyncLocalDB, "findAllNormalDocs" | "getDBEntryFromMeta">;
readonly getPath: StartupPathReader;
readonly isTargetFile: (path: string) => Promise<boolean>;
readonly storageAccess: Pick<StorageAccess, "readHiddenFileBinary" | "getFileStub">;
readonly fileHandler: Pick<IFileHandler, "storeFileToDB">;
readonly keyValueDB: Pick<KeyValueDatabase, "get" | "set">;
readonly noticeGroups: NoticeGroups;
readonly confirm: Pick<Confirm, "askSelectStringDialogue">;
readonly log: LogFunction;
}
type ErrorInfo = {
path: string;
recordedSize: number;
actualSize: number;
storageSize: number;
contentMatched: boolean;
isConflicted?: boolean;
};
const INCOMPLETE_DOCUMENT_NOTICE_GROUP = "startup-integrity-check";
/**
* Scan database metadata against hidden storage and preserve the former
* recoverable-file dialogue and repair rules.
*/
export async function checkIncompleteDocuments(
dependencies: IncompleteDocumentsDependencies,
force: boolean = false
): Promise<boolean> {
const incompleteDocsChecked = (await dependencies.keyValueDB.get<boolean>("checkIncompleteDocs")) || false;
if (incompleteDocsChecked && !force) {
dependencies.log("Incomplete docs check already done, skipping.", LOG_LEVEL_VERBOSE);
return Promise.resolve(true);
}
const noticeGroups = dependencies.noticeGroups;
noticeGroups.setItem(INCOMPLETE_DOCUMENT_NOTICE_GROUP, "checking", {
message: "Checking for incomplete documents...",
});
dependencies.log("Checking for incomplete documents...", LOG_LEVEL_VERBOSE);
try {
const errorFiles = [] as ErrorInfo[];
for await (const metaDoc of dependencies.localDatabase.findAllNormalDocs({ conflicts: true })) {
const path = dependencies.getPath(metaDoc);
if (!isValidPath(path)) {
continue;
}
if (!(await dependencies.isTargetFile(path))) {
continue;
}
if (!isMetaEntry(metaDoc)) {
continue;
}
const doc = await dependencies.localDatabase.getDBEntryFromMeta(metaDoc);
if (!doc || !isLoadedEntry(doc)) {
continue;
}
if (isDeletedEntry(doc)) {
continue;
}
const isConflicted = metaDoc?._conflicts && metaDoc._conflicts.length > 0;
let storageFileContent;
try {
storageFileContent = await dependencies.storageAccess.readHiddenFileBinary(path);
} catch (e) {
Logger(`Failed to read file ${path}: Possibly unprocessed or missing`);
Logger(e, LOG_LEVEL_VERBOSE);
continue;
}
// const storageFileBlob = createBlob(storageFileContent);
const sizeOnStorage = storageFileContent.byteLength;
const recordedSize = doc.size;
const docBlob = readAsBlob(doc);
const actualSize = docBlob.size;
if (
recordedSize !== actualSize ||
sizeOnStorage !== actualSize ||
sizeOnStorage !== recordedSize ||
isConflicted
) {
const contentMatched = await isDocContentSame(doc.data, storageFileContent);
errorFiles.push({
path,
recordedSize,
actualSize,
storageSize: sizeOnStorage,
contentMatched,
isConflicted,
});
Logger(
`Size mismatch for ${path}: ${recordedSize} (DB Recorded) , ${actualSize} (DB Stored) , ${sizeOnStorage} (Storage Stored), ${contentMatched ? "Content Matched" : "Content Mismatched"} ${isConflicted ? "Conflicted" : "Not Conflicted"}`
);
}
}
if (errorFiles.length == 0) {
Logger("No size mismatches found", LOG_LEVEL_INFO);
noticeGroups.setItem(INCOMPLETE_DOCUMENT_NOTICE_GROUP, "result", {
message: "No size mismatches found",
});
await dependencies.keyValueDB.set("checkIncompleteDocs", true);
return Promise.resolve(true);
}
Logger(`Found ${errorFiles.length} size mismatches`, LOG_LEVEL_INFO);
noticeGroups.setItem(INCOMPLETE_DOCUMENT_NOTICE_GROUP, "result", {
message: `Found ${errorFiles.length} size mismatches`,
});
// We have to repair them following rules and situations:
// A. DB Recorded != DB Stored
// A.1. DB Recorded == Storage Stored
// Possibly recoverable from storage. Just overwrite the DB content with storage content.
// A.2. Neither
// Probably it cannot be resolved on this device. Even if the storage content is larger than DB Recorded, it possibly corrupted.
// We do not fix it automatically. Leave it as is. Possibly other device can do this.
// B. DB Recorded == DB Stored , < Storage Stored
// Very fragile, if DB Recorded size is less than Storage Stored size, we possibly repair the content (The issue was `unexpectedly shortened file`).
// We do not fix it automatically, but it will be automatically overwritten in other process.
// C. DB Recorded == DB Stored , > Storage Stored
// Probably restored by the user by resolving A or B on other device, We should overwrite the storage
// Also do not fix it automatically. It should be overwritten by replication.
const recoverable = errorFiles.filter((e) => {
return e.recordedSize === e.storageSize && !e.isConflicted;
});
const unrecoverable = errorFiles.filter((e) => {
return e.recordedSize !== e.storageSize || e.isConflicted;
});
const fileInfo = (e: (typeof errorFiles)[0]) => {
return `${e.path} (M: ${e.recordedSize}, A: ${e.actualSize}, S: ${e.storageSize}) ${e.isConflicted ? "(Conflicted)" : ""}`;
};
const messageUnrecoverable =
unrecoverable.length > 0
? $msg("moduleMigration.fix0256.messageUnrecoverable", {
filesNotRecoverable: unrecoverable.map((e) => `- ${fileInfo(e)}`).join("\n"),
})
: "";
const message = $msg("moduleMigration.fix0256.message", {
files: recoverable.map((e) => `- ${fileInfo(e)}`).join("\n"),
messageUnrecoverable,
});
const CHECK_IT_LATER = $msg("moduleMigration.fix0256.buttons.checkItLater");
const FIX = $msg("moduleMigration.fix0256.buttons.fix");
const DISMISS = $msg("moduleMigration.fix0256.buttons.DismissForever");
const ret = await dependencies.confirm.askSelectStringDialogue(message, [CHECK_IT_LATER, FIX, DISMISS], {
title: $msg("moduleMigration.fix0256.title"),
defaultAction: CHECK_IT_LATER,
});
if (ret == FIX) {
for (const file of recoverable) {
// Overwrite the database with the files on the storage
const stubFile = await dependencies.storageAccess.getFileStub(file.path);
if (stubFile == null) {
Logger(`Could not find stub file for ${file.path}`, LOG_LEVEL_NOTICE);
continue;
}
stubFile.stat.mtime = Date.now();
const result = await dependencies.fileHandler.storeFileToDB(stubFile, true, false);
if (result) {
Logger(`Successfully restored ${file.path} from storage`);
} else {
Logger(`Failed to restore ${file.path} from storage`, LOG_LEVEL_NOTICE);
}
}
} else if (ret === DISMISS) {
// User chose to dismiss the issue
await dependencies.keyValueDB.set("checkIncompleteDocs", true);
}
return Promise.resolve(true);
} catch (error) {
noticeGroups.setItem(INCOMPLETE_DOCUMENT_NOTICE_GROUP, "result", {
message: "The incomplete document check could not be completed.",
});
throw error;
} finally {
noticeGroups.finish(INCOMPLETE_DOCUMENT_NOTICE_GROUP);
}
}
@@ -0,0 +1,217 @@
import { describe, expect, it, vi } from "vitest";
import type { LoadedEntry, MetaEntry, UXFileInfoStub } from "@vrtmrz/livesync-commonlib/compat/common/types";
import { checkIncompleteDocuments, type IncompleteDocumentsDependencies } from "./incompleteDocuments";
vi.mock("@/common/utils", () => ({
isValidPath: () => true,
}));
type DocumentFixture = {
meta: MetaEntry;
loaded: LoadedEntry;
storage: ArrayBuffer;
stub: UXFileInfoStub;
};
type FindAllNormalDocs = () => AsyncGenerator<MetaEntry>;
async function* noDocuments(): AsyncGenerator<MetaEntry> {
return;
}
async function* failedDocumentScan(): AsyncGenerator<MetaEntry> {
throw new Error("scan failed");
}
function documentFixture(
path: string,
options: { recordedSize?: number; storageContent?: string; conflicts?: string[] } = {}
): DocumentFixture {
const storageContent = options.storageContent ?? "hello";
const meta = {
_id: `f:${path}`,
_rev: "1-test",
path,
ctime: 1,
mtime: 2,
size: options.recordedSize ?? storageContent.length,
children: ["h:test"],
type: "plain",
eden: {},
...(options.conflicts ? { _conflicts: options.conflicts } : {}),
} as MetaEntry;
const loaded = {
...meta,
data: "abc",
datatype: "plain",
} as LoadedEntry;
const stub = {
name: path.split("/").pop() ?? path,
path,
stat: {
ctime: 1,
mtime: 2,
size: storageContent.length,
type: "file",
},
} as UXFileInfoStub;
return {
meta,
loaded,
storage: new TextEncoder().encode(storageContent).buffer as ArrayBuffer,
stub,
};
}
function documentsFrom(fixtures: DocumentFixture[]): FindAllNormalDocs {
return async function* () {
yield* fixtures.map((fixture) => fixture.meta);
};
}
function selectButton(index: number) {
return async (...args: unknown[]): Promise<string | false> => {
const buttons = args[1] as readonly string[];
return buttons[index] ?? false;
};
}
function createDependencies(findAllNormalDocs: FindAllNormalDocs = noDocuments, fixtures: DocumentFixture[] = []) {
const fixtureByPath = new Map<string, DocumentFixture>(
fixtures.map((fixture) => [fixture.meta.path as string, fixture])
);
const noticeGroups = {
setItem: vi.fn(),
finish: vi.fn(),
};
const getFixture = (path: string) => fixtureByPath.get(path);
const getDBEntryFromMeta = vi.fn(async (meta: { path: string }) => getFixture(meta.path)?.loaded);
const readHiddenFileBinary = vi.fn(async (path: string) => getFixture(path)?.storage ?? new ArrayBuffer(0));
const getFileStub = vi.fn(async (path: string) => getFixture(path)?.stub ?? null);
const storeFileToDB = vi.fn(async () => true);
const askSelectStringDialogue = vi.fn();
const dependencies = {
localDatabase: {
findAllNormalDocs,
getDBEntryFromMeta,
},
getPath: vi.fn((entry: { path: string }) => entry.path),
isTargetFile: vi.fn(async () => true),
storageAccess: {
readHiddenFileBinary,
getFileStub,
},
fileHandler: { storeFileToDB },
keyValueDB: {
get: vi.fn(async () => false),
set: vi.fn(async () => undefined),
},
noticeGroups,
confirm: { askSelectStringDialogue },
log: vi.fn(),
} as unknown as IncompleteDocumentsDependencies;
return {
askSelectStringDialogue,
dependencies,
getFileStub,
noticeGroups,
readHiddenFileBinary,
storeFileToDB,
};
}
describe("checkIncompleteDocuments", () => {
it("keeps the check and result in one persistent named group", async () => {
const { dependencies, noticeGroups } = createDependencies();
await expect(checkIncompleteDocuments(dependencies)).resolves.toBe(true);
expect(noticeGroups.setItem).toHaveBeenNthCalledWith(1, "startup-integrity-check", "checking", {
message: "Checking for incomplete documents...",
});
expect(noticeGroups.setItem).toHaveBeenNthCalledWith(2, "startup-integrity-check", "result", {
message: "No size mismatches found",
});
expect(noticeGroups.finish).toHaveBeenCalledWith("startup-integrity-check");
expect(dependencies.keyValueDB.set).toHaveBeenCalledWith("checkIncompleteDocs", true);
});
it("skips the non-forced check after a successful prior scan", async () => {
const { dependencies, noticeGroups } = createDependencies();
vi.mocked(dependencies.keyValueDB.get).mockResolvedValue(true);
await expect(checkIncompleteDocuments(dependencies)).resolves.toBe(true);
expect(noticeGroups.setItem).not.toHaveBeenCalled();
expect(noticeGroups.finish).not.toHaveBeenCalled();
});
it("finishes the group with a failure result when the scan throws", async () => {
const { dependencies, noticeGroups } = createDependencies(failedDocumentScan);
await expect(checkIncompleteDocuments(dependencies)).rejects.toThrow("scan failed");
expect(noticeGroups.setItem).toHaveBeenLastCalledWith("startup-integrity-check", "result", {
message: "The incomplete document check could not be completed.",
});
expect(noticeGroups.finish).toHaveBeenCalledWith("startup-integrity-check");
});
it("repairs a recoverable document when FIX is selected", async () => {
const recoverable = documentFixture("recoverable.md");
const fixture = createDependencies(documentsFrom([recoverable]), [recoverable]);
fixture.askSelectStringDialogue.mockImplementation(selectButton(1));
await expect(checkIncompleteDocuments(fixture.dependencies)).resolves.toBe(true);
expect(fixture.askSelectStringDialogue.mock.calls[0]?.[1]).toHaveLength(3);
expect(fixture.getFileStub).toHaveBeenCalledWith("recoverable.md");
expect(fixture.storeFileToDB).toHaveBeenCalledWith(recoverable.stub, true, false);
expect(fixture.dependencies.keyValueDB.set).not.toHaveBeenCalled();
});
it("leaves recoverable documents unchanged when CHECK_IT_LATER is selected", async () => {
const recoverable = documentFixture("recoverable.md");
const fixture = createDependencies(documentsFrom([recoverable]), [recoverable]);
fixture.askSelectStringDialogue.mockImplementation(selectButton(0));
await expect(checkIncompleteDocuments(fixture.dependencies)).resolves.toBe(true);
expect(fixture.getFileStub).not.toHaveBeenCalled();
expect(fixture.storeFileToDB).not.toHaveBeenCalled();
expect(fixture.dependencies.keyValueDB.set).not.toHaveBeenCalled();
});
it("records a permanent dismissal of recoverable document warnings", async () => {
const recoverable = documentFixture("recoverable.md");
const fixture = createDependencies(documentsFrom([recoverable]), [recoverable]);
fixture.askSelectStringDialogue.mockImplementation(selectButton(2));
await expect(checkIncompleteDocuments(fixture.dependencies)).resolves.toBe(true);
expect(fixture.getFileStub).not.toHaveBeenCalled();
expect(fixture.storeFileToDB).not.toHaveBeenCalled();
expect(fixture.dependencies.keyValueDB.set).toHaveBeenCalledWith("checkIncompleteDocs", true);
});
it("stores only recoverable, non-conflicted documents from a mixed scan", async () => {
const recoverable = documentFixture("recoverable.md");
const unrecoverable = documentFixture("unrecoverable.md", { recordedSize: 4 });
const conflicted = documentFixture("conflicted.md", { conflicts: ["2-conflict"] });
const fixture = createDependencies(documentsFrom([recoverable, unrecoverable, conflicted]), [
recoverable,
unrecoverable,
conflicted,
]);
fixture.askSelectStringDialogue.mockImplementation(selectButton(1));
await expect(checkIncompleteDocuments(fixture.dependencies)).resolves.toBe(true);
expect(fixture.getFileStub).toHaveBeenCalledTimes(1);
expect(fixture.getFileStub).toHaveBeenCalledWith("recoverable.md");
expect(fixture.storeFileToDB).toHaveBeenCalledTimes(1);
expect(fixture.storeFileToDB).toHaveBeenCalledWith(recoverable.stub, true, false);
expect(fixture.askSelectStringDialogue.mock.calls[0]?.[0]).toEqual(expect.stringContaining("unrecoverable.md"));
expect(fixture.askSelectStringDialogue.mock.calls[0]?.[0]).toEqual(expect.stringContaining("conflicted.md"));
});
});
@@ -0,0 +1,20 @@
export { runConfiguredStartupLifecycle, runStartupEntryLifecycle } from "./configuredStartupLifecycle";
export type { StartupEntryLifecycleRuntime } from "./configuredStartupLifecycle";
export { runConfigDoctor } from "./configDoctor";
export { checkCompromisedChunks } from "./compromisedChunks";
export { checkIncompleteDocuments } from "./incompleteDocuments";
export { migrateBulkSendSetting } from "./bulkSettingMigration";
export { STARTUP_LIFECYCLE_LAYOUT_PRIORITY, useStartupLifecycleFeature } from "./startupLifecycle";
export type { BulkSettingMigrationDependencies } from "./bulkSettingMigration";
export type { CompromisedChunksDependencies } from "./compromisedChunks";
export type { ConfigDoctorDependencies } from "./configDoctor";
export type { IncompleteDocumentsDependencies } from "./incompleteDocuments";
export type {
ConfiguredStartupLifecycleOperations,
LegacyBulkSendSettings,
StartupLifecycleContext,
StartupLifecycleFeatureOptions,
StartupLifecycleHost,
StartupLifecycleValue,
StartupPathReader,
} from "./types";
@@ -0,0 +1,135 @@
import { describe, expect, it, vi } from "vitest";
const operationMocks = vi.hoisted(() => ({
checkCompromisedChunks: vi.fn(),
checkIncompleteDocuments: vi.fn(),
migrateBulkSendSetting: vi.fn(),
runConfigDoctor: vi.fn(),
}));
vi.mock("./compromisedChunks", () => ({
checkCompromisedChunks: operationMocks.checkCompromisedChunks,
}));
vi.mock("./incompleteDocuments", () => ({
checkIncompleteDocuments: operationMocks.checkIncompleteDocuments,
}));
vi.mock("./bulkSettingMigration", () => ({
migrateBulkSendSetting: operationMocks.migrateBulkSendSetting,
}));
vi.mock("./configDoctor", () => ({
runConfigDoctor: operationMocks.runConfigDoctor,
}));
import { useStartupLifecycleFeature, type StartupLifecycleHost } from "./index";
describe("useStartupLifecycleFeature default operation wiring", () => {
it("maps the host services to every configured start-up operation in order", async () => {
const order: string[] = [];
const log = vi.fn();
const settings = { isConfigured: true, encrypt: true, sendChunksBulk: false, sendChunksBulkMaxSize: 1 };
const localDatabase = { isReady: true, localDatabase: { name: "local" } };
const confirm = { askSelectStringDialogue: vi.fn() };
const activeReplicator = { name: "remote" };
const storageAccess = { name: "storage" };
const fileHandler = { name: "file-handler" };
const rebuilder = { name: "rebuilder" };
const kvDB = { name: "key-value" };
const addLayoutHandler = vi.fn();
const addFirstInitialiseHandler = vi.fn();
const setting = {
settings,
currentSettings: vi.fn(() => settings),
saveSettingData: vi.fn(async () => undefined),
};
const appLifecycle = {
onLayoutReady: { addHandler: addLayoutHandler },
onFirstInitialise: { addHandler: addFirstInitialiseHandler },
performRestart: vi.fn(),
};
const path = { getPath: vi.fn(() => "note.md") };
const vault = { isTargetFile: vi.fn(async () => true) };
const host = {
services: {
API: { isOnline: true },
UI: { confirm },
appLifecycle,
context: {
events: { onEvent: vi.fn(() => vi.fn()) },
noticeGroups: { setItem: vi.fn(), finish: vi.fn() },
translate: String,
},
database: { localDatabase },
keyValueDB: { kvDB },
path,
replicator: { getActiveReplicator: vi.fn(() => activeReplicator) },
setting,
vault,
},
serviceModules: { fileHandler, rebuilder, storageAccess },
} as unknown as StartupLifecycleHost;
operationMocks.checkCompromisedChunks.mockImplementation(async () => {
order.push("compromised");
return true;
});
operationMocks.checkIncompleteDocuments.mockImplementation(async () => {
order.push("incomplete");
return true;
});
operationMocks.runConfigDoctor.mockImplementation(async () => {
order.push("doctor");
return true;
});
operationMocks.migrateBulkSendSetting.mockImplementation(async () => {
order.push("bulk");
});
const waitForCompatibilityReview = vi.fn(async () => {
order.push("compatibility");
});
useStartupLifecycleFeature(host, {
inviteToOnboarding: vi.fn(),
waitForCompatibilityReview,
log,
});
expect(operationMocks.checkCompromisedChunks).not.toHaveBeenCalled();
expect(operationMocks.checkIncompleteDocuments).not.toHaveBeenCalled();
expect(operationMocks.runConfigDoctor).not.toHaveBeenCalled();
expect(operationMocks.migrateBulkSendSetting).not.toHaveBeenCalled();
expect(waitForCompatibilityReview).not.toHaveBeenCalled();
const layoutAdmission = addLayoutHandler.mock.calls[0]?.[0] as () => Promise<boolean>;
const firstInitialise = addFirstInitialiseHandler.mock.calls[0]?.[0] as () => Promise<boolean>;
await expect(layoutAdmission()).resolves.toBe(true);
await expect(firstInitialise()).resolves.toBe(true);
expect(order).toEqual(["compromised", "incomplete", "compatibility", "doctor", "bulk"]);
const compromised = operationMocks.checkCompromisedChunks.mock.calls[0]?.[0];
expect(compromised).toMatchObject({ settings, localDatabase, confirm, rebuilder, log });
expect(compromised?.isOnline()).toBe(true);
expect(compromised?.getActiveReplicator()).toBe(activeReplicator);
compromised?.performRestart();
expect(appLifecycle.performRestart).toHaveBeenCalledOnce();
const [incomplete, force] = operationMocks.checkIncompleteDocuments.mock.calls[0] ?? [];
expect(force).toBe(false);
expect(incomplete).toMatchObject({ localDatabase, storageAccess, fileHandler, keyValueDB: kvDB, confirm, log });
expect(incomplete?.getPath({} as never)).toBe("note.md");
await expect(incomplete?.isTargetFile("note.md")).resolves.toBe(true);
const doctor = operationMocks.runConfigDoctor.mock.calls[0]?.[0];
expect(doctor).toMatchObject({ confirm, settings, rebuilder });
const nextSettings = { ...settings, liveSync: true } as never;
doctor?.setSettings(nextSettings);
expect(setting.settings).toBe(nextSettings);
await doctor?.saveSettings();
expect(setting.saveSettingData).toHaveBeenCalledOnce();
const bulk = operationMocks.migrateBulkSendSetting.mock.calls[0]?.[0];
expect(bulk).toMatchObject({ settings, log });
await bulk?.saveSettings();
expect(setting.saveSettingData).toHaveBeenCalledTimes(2);
});
});
@@ -0,0 +1,174 @@
import { EVENT_REQUEST_RUN_DOCTOR, EVENT_REQUEST_RUN_FIX_INCOMPLETE, EVENT_SETTING_SAVED } from "@/common/events";
import { $msg } from "@/common/translation";
import { createInstanceLogFunction } from "@vrtmrz/livesync-commonlib/compat/services/lib/logUtils";
import { LOG_LEVEL_NOTICE } from "@vrtmrz/livesync-commonlib/compat/common/logger";
import type {
StartupLifecycleHost,
StartupLifecycleFeatureOptions,
ConfiguredStartupLifecycleOperations,
} from "./types";
import { runConfiguredStartupLifecycle, runStartupEntryLifecycle } from "./configuredStartupLifecycle";
import { runConfigDoctor } from "./configDoctor";
import { checkCompromisedChunks } from "./compromisedChunks";
import { checkIncompleteDocuments } from "./incompleteDocuments";
import { migrateBulkSendSetting } from "./bulkSettingMigration";
/** Layout admission runs after ordinary priority-0 host handlers. */
export const STARTUP_LIFECYCLE_LAYOUT_PRIORITY = 1 as const;
function readValue<T>(value: T | (() => T)): T {
return typeof value === "function" ? (value as () => T)() : value;
}
function createDefaultOperations(
host: StartupLifecycleHost,
options: StartupLifecycleFeatureOptions,
log: ReturnType<typeof createInstanceLogFunction>
): ConfiguredStartupLifecycleOperations {
const { services } = host;
const defaultOperations = {
databaseReady: () => services.database.localDatabase.isReady,
reportDatabaseNotReady: () => log($msg("moduleMigration.logLocalDatabaseNotReady"), LOG_LEVEL_NOTICE),
hasCompromisedChunks: () =>
checkCompromisedChunks({
settings: services.setting.currentSettings(),
localDatabase: services.database.localDatabase,
isOnline: () => services.API.isOnline,
getActiveReplicator: () => services.replicator.getActiveReplicator(),
confirm: services.UI.confirm,
rebuilder: host.serviceModules.rebuilder,
performRestart: () => services.appLifecycle.performRestart(),
log,
}),
hasIncompleteDocuments: (force = false) =>
checkIncompleteDocuments(
{
localDatabase: services.database.localDatabase,
getPath: (entry) => services.path.getPath(entry),
isTargetFile: (path) => services.vault.isTargetFile(path),
storageAccess: host.serviceModules.storageAccess,
fileHandler: host.serviceModules.fileHandler,
keyValueDB: services.keyValueDB.kvDB,
noticeGroups: services.context.noticeGroups,
confirm: services.UI.confirm,
log,
},
force
),
runDoctor: (skipRebuild = false, activateReason = "updated", forceRescan = false) =>
runConfigDoctor(
{
confirm: services.UI.confirm,
translate: services.context.translate,
settings: services.setting.currentSettings(),
setSettings: (settings) => {
services.setting.settings = settings;
},
saveSettings: () => services.setting.saveSettingData(),
rebuilder: host.serviceModules.rebuilder,
performRestart: () => services.appLifecycle.performRestart(),
},
skipRebuild,
activateReason,
forceRescan
),
migrateBulkSend: () =>
migrateBulkSendSetting({
settings: services.setting.currentSettings(),
log,
saveSettings: () => services.setting.saveSettingData(),
}),
} satisfies Omit<ConfiguredStartupLifecycleOperations, "waitForCompatibilityReview">;
return {
databaseReady: options.databaseReady ?? defaultOperations.databaseReady,
reportDatabaseNotReady: options.reportDatabaseNotReady ?? defaultOperations.reportDatabaseNotReady,
hasCompromisedChunks: options.hasCompromisedChunks ?? defaultOperations.hasCompromisedChunks,
hasIncompleteDocuments: options.hasIncompleteDocuments ?? defaultOperations.hasIncompleteDocuments,
waitForCompatibilityReview: options.waitForCompatibilityReview,
runDoctor: options.runDoctor ?? defaultOperations.runDoctor,
migrateBulkSend: options.migrateBulkSend ?? defaultOperations.migrateBulkSend,
};
}
/**
* Compose configured Vault admission, start-up integrity checks, migrations,
* and their request events around one host-owned service context.
*
* Event listeners are deliberately registered from the successful layout
* admission handler. An unconfigured Vault therefore cannot receive a
* Config Doctor or incomplete-document request before onboarding.
*/
export function useStartupLifecycleFeature(host: StartupLifecycleHost, options: StartupLifecycleFeatureOptions): void {
const log = options.log ?? createInstanceLogFunction("SF:StartupLifecycle", host.services.API);
const operations = createDefaultOperations(host, options, log);
let layoutAdmitted = false;
let layoutEvaluated = false;
let generationRetired = false;
let eventsBound = false;
let eventUnsubscribers: Array<() => void> = [];
const isConfigured = () => {
const configured = options.configured;
return configured === undefined
? host.services.setting.currentSettings().isConfigured === true
: readValue(configured);
};
const isDatabaseReady = () => readValue(operations.databaseReady);
const retireGeneration = () => {
if (generationRetired) return;
generationRetired = true;
layoutAdmitted = false;
for (const unsubscribe of eventUnsubscribers) unsubscribe();
eventUnsubscribers = [];
};
const bindRequestEvents = () => {
if (eventsBound || generationRetired) return;
eventsBound = true;
eventUnsubscribers = [
host.services.context.events.onEvent(EVENT_REQUEST_RUN_DOCTOR, async (reason) => {
if (!layoutAdmitted || generationRetired || !isConfigured() || !isDatabaseReady()) return;
await operations.runDoctor(false, reason, true);
}),
host.services.context.events.onEvent(EVENT_REQUEST_RUN_FIX_INCOMPLETE, async () => {
if (!layoutAdmitted || generationRetired || !isConfigured() || !isDatabaseReady()) return;
await operations.hasIncompleteDocuments(true);
}),
host.services.context.events.onEvent(EVENT_SETTING_SAVED, (settings) => {
if (settings.isConfigured !== true) retireGeneration();
}),
];
};
const layoutAdmission = (): Promise<boolean> => {
if (generationRetired) return Promise.resolve(false);
if (layoutEvaluated) {
if (layoutAdmitted && !isConfigured()) retireGeneration();
return Promise.resolve(layoutAdmitted);
}
layoutEvaluated = true;
const admitted = runStartupEntryLifecycle({
configured: isConfigured,
inviteToOnboarding: options.inviteToOnboarding,
});
if (!admitted) {
retireGeneration();
return Promise.resolve(false);
}
layoutAdmitted = true;
bindRequestEvents();
return Promise.resolve(true);
};
const firstInitialise = async (): Promise<boolean> => {
if (!layoutAdmitted || generationRetired || !isConfigured()) return false;
return await runConfiguredStartupLifecycle(operations);
};
host.services.appLifecycle.onLayoutReady.addHandler(layoutAdmission, STARTUP_LIFECYCLE_LAYOUT_PRIORITY);
host.services.appLifecycle.onFirstInitialise.addHandler(firstInitialise);
}
@@ -0,0 +1,220 @@
import { describe, expect, it, vi } from "vitest";
import { EVENT_REQUEST_RUN_DOCTOR, EVENT_REQUEST_RUN_FIX_INCOMPLETE, EVENT_SETTING_SAVED } from "@/common/events";
vi.mock("@/common/utils", () => ({
isValidPath: () => true,
}));
import {
STARTUP_LIFECYCLE_LAYOUT_PRIORITY,
useStartupLifecycleFeature,
type StartupLifecycleFeatureOptions,
type StartupLifecycleHost,
} from "./index";
function createHost() {
const addLayoutHandler = vi.fn();
const addFirstInitialiseHandler = vi.fn();
const eventHandlers = new Map<
string,
{ callback: (...args: unknown[]) => unknown; unsubscribe: ReturnType<typeof vi.fn> }
>();
const onEvent = vi.fn((event: string, callback: (...args: unknown[]) => unknown) => {
const unsubscribe = vi.fn();
eventHandlers.set(event, { callback, unsubscribe });
return unsubscribe;
});
const host = {
services: {
API: {},
UI: {},
appLifecycle: {
onLayoutReady: { addHandler: addLayoutHandler },
onFirstInitialise: { addHandler: addFirstInitialiseHandler },
},
context: {
events: { onEvent },
noticeGroups: {},
translate: String,
},
setting: {
currentSettings: vi.fn(() => ({ isConfigured: true })),
},
},
serviceModules: {},
} as unknown as StartupLifecycleHost;
return { addFirstInitialiseHandler, addLayoutHandler, eventHandlers, host, onEvent };
}
function createOptions(events: string[] = []): StartupLifecycleFeatureOptions {
return {
inviteToOnboarding: vi.fn(() => events.push("invite")),
waitForCompatibilityReview: vi.fn(async () => {
events.push("compatibility-review");
}),
databaseReady: true,
reportDatabaseNotReady: vi.fn(() => events.push("database-not-ready")),
hasCompromisedChunks: vi.fn(async () => {
events.push("compromised-chunks");
return true;
}),
hasIncompleteDocuments: vi.fn(async () => {
events.push("incomplete-documents");
return true;
}),
runDoctor: vi.fn(async () => {
events.push("doctor");
return true;
}),
migrateBulkSend: vi.fn(async () => {
events.push("bulk-send");
}),
log: vi.fn(),
};
}
describe("useStartupLifecycleFeature", () => {
it("registers layout admission at priority 1 and first-initialise in the established order", async () => {
const events: string[] = [];
const { addFirstInitialiseHandler, addLayoutHandler, host } = createHost();
const options = createOptions(events);
useStartupLifecycleFeature(host, options);
expect(addLayoutHandler).toHaveBeenCalledWith(expect.any(Function), STARTUP_LIFECYCLE_LAYOUT_PRIORITY);
expect(addFirstInitialiseHandler).toHaveBeenCalledWith(expect.any(Function));
const layoutAdmission = addLayoutHandler.mock.calls[0][0] as () => Promise<boolean>;
await expect(layoutAdmission()).resolves.toBe(true);
const firstInitialise = addFirstInitialiseHandler.mock.calls[0][0] as () => Promise<boolean>;
await expect(firstInitialise()).resolves.toBe(true);
expect(events).toEqual([
"compromised-chunks",
"incomplete-documents",
"compatibility-review",
"doctor",
"bulk-send",
]);
});
it("short-circuits first-initialise when database readiness or an integrity check fails", async () => {
const events: string[] = [];
const { addFirstInitialiseHandler, addLayoutHandler, host } = createHost();
const options = createOptions(events);
let databaseReady = false;
Object.assign(options, { databaseReady: () => databaseReady });
useStartupLifecycleFeature(host, options);
const layoutAdmission = addLayoutHandler.mock.calls[0][0] as () => Promise<boolean>;
await expect(layoutAdmission()).resolves.toBe(true);
const firstInitialise = addFirstInitialiseHandler.mock.calls[0][0] as () => Promise<boolean>;
await expect(firstInitialise()).resolves.toBe(false);
expect(events).toEqual(["database-not-ready"]);
databaseReady = true;
vi.mocked(options.hasCompromisedChunks!).mockImplementation(async () => {
events.push("compromised-chunks");
return false;
});
await expect(firstInitialise()).resolves.toBe(false);
expect(events).toEqual(["database-not-ready", "compromised-chunks"]);
expect(options.hasIncompleteDocuments).not.toHaveBeenCalled();
});
it("does not re-admit a Vault which was unconfigured at its first layout invocation", async () => {
const { addFirstInitialiseHandler, addLayoutHandler, eventHandlers, host } = createHost();
const inviteToOnboarding = vi.fn();
const options = {
...createOptions(),
configured: false,
inviteToOnboarding,
} satisfies StartupLifecycleFeatureOptions;
useStartupLifecycleFeature(host, options);
const layoutAdmission = addLayoutHandler.mock.calls[0][0] as () => Promise<boolean>;
await expect(layoutAdmission()).resolves.toBe(false);
expect(inviteToOnboarding).toHaveBeenCalledOnce();
expect(eventHandlers.has(EVENT_REQUEST_RUN_DOCTOR)).toBe(false);
expect(eventHandlers.has(EVENT_REQUEST_RUN_FIX_INCOMPLETE)).toBe(false);
Object.assign(options, { configured: true });
await expect(layoutAdmission()).resolves.toBe(false);
expect(eventHandlers.has(EVENT_REQUEST_RUN_DOCTOR)).toBe(false);
expect(eventHandlers.has(EVENT_REQUEST_RUN_FIX_INCOMPLETE)).toBe(false);
const firstInitialise = addFirstInitialiseHandler.mock.calls[0][0] as () => Promise<boolean>;
await expect(firstInitialise()).resolves.toBe(false);
expect(options.runDoctor).not.toHaveBeenCalled();
expect(eventHandlers.has(EVENT_SETTING_SAVED)).toBe(false);
});
it("retires an admitted generation when settings become unconfigured and guards request races", async () => {
const { addFirstInitialiseHandler, addLayoutHandler, eventHandlers, host } = createHost();
let configured = true;
let databaseReady = true;
const options = {
...createOptions(),
configured: () => configured,
databaseReady: () => databaseReady,
} satisfies StartupLifecycleFeatureOptions;
useStartupLifecycleFeature(host, options);
const layoutAdmission = addLayoutHandler.mock.calls[0][0] as () => Promise<boolean>;
await expect(layoutAdmission()).resolves.toBe(true);
expect(eventHandlers.get(EVENT_REQUEST_RUN_DOCTOR)).toBeDefined();
expect(eventHandlers.get(EVENT_REQUEST_RUN_FIX_INCOMPLETE)).toBeDefined();
const runDoctor = eventHandlers.get(EVENT_REQUEST_RUN_DOCTOR)!.callback as (reason: string) => Promise<void>;
const fixIncomplete = eventHandlers.get(EVENT_REQUEST_RUN_FIX_INCOMPLETE)!.callback as () => Promise<void>;
const settingSaved = eventHandlers.get(EVENT_SETTING_SAVED)!.callback as (settings: unknown) => unknown;
await settingSaved({ isConfigured: true });
expect(eventHandlers.get(EVENT_REQUEST_RUN_DOCTOR)!.unsubscribe).not.toHaveBeenCalled();
expect(eventHandlers.get(EVENT_REQUEST_RUN_FIX_INCOMPLETE)!.unsubscribe).not.toHaveBeenCalled();
databaseReady = false;
await runDoctor("database race");
await fixIncomplete();
expect(options.runDoctor).not.toHaveBeenCalled();
expect(options.hasIncompleteDocuments).not.toHaveBeenCalled();
databaseReady = true;
configured = false;
await runDoctor("configuration race");
await fixIncomplete();
expect(options.runDoctor).not.toHaveBeenCalled();
expect(options.hasIncompleteDocuments).not.toHaveBeenCalled();
await settingSaved({ isConfigured: false });
expect(eventHandlers.get(EVENT_REQUEST_RUN_DOCTOR)!.unsubscribe).toHaveBeenCalledOnce();
expect(eventHandlers.get(EVENT_REQUEST_RUN_FIX_INCOMPLETE)!.unsubscribe).toHaveBeenCalledOnce();
expect(eventHandlers.get(EVENT_SETTING_SAVED)!.unsubscribe).toHaveBeenCalledOnce();
configured = true;
await expect(layoutAdmission()).resolves.toBe(false);
await runDoctor("retired generation");
await fixIncomplete();
expect(options.runDoctor).not.toHaveBeenCalled();
expect(options.hasIncompleteDocuments).not.toHaveBeenCalled();
const firstInitialise = addFirstInitialiseHandler.mock.calls[0][0] as () => Promise<boolean>;
await expect(firstInitialise()).resolves.toBe(false);
});
it("keeps doctor and incomplete-document request operations behind layout admission", async () => {
const { addLayoutHandler, eventHandlers, host, onEvent } = createHost();
const options = createOptions();
useStartupLifecycleFeature(host, options);
const layoutAdmission = addLayoutHandler.mock.calls[0][0] as () => Promise<boolean>;
await layoutAdmission();
await layoutAdmission();
expect(onEvent.mock.calls.filter(([event]) => event === EVENT_REQUEST_RUN_DOCTOR)).toHaveLength(1);
expect(onEvent.mock.calls.filter(([event]) => event === EVENT_REQUEST_RUN_FIX_INCOMPLETE)).toHaveLength(1);
const runDoctor = eventHandlers.get(EVENT_REQUEST_RUN_DOCTOR)!.callback as (reason: string) => Promise<void>;
const fixIncomplete = eventHandlers.get(EVENT_REQUEST_RUN_FIX_INCOMPLETE)!.callback as () => Promise<void>;
await runDoctor("manual request");
await fixIncomplete();
expect(options.runDoctor).toHaveBeenCalledWith(false, "manual request", true);
expect(options.hasIncompleteDocuments).toHaveBeenCalledWith(true);
});
});
@@ -0,0 +1,56 @@
import type { AnyEntry, ObsidianLiveSyncSettings } from "@vrtmrz/livesync-commonlib/compat/common/types";
import type { NecessaryServices } from "@vrtmrz/livesync-commonlib/compat/interfaces/ServiceModule";
import type { LiveSyncEventHub, ServiceContext } from "@vrtmrz/livesync-commonlib/context";
import type { LogFunction } from "@vrtmrz/livesync-commonlib/compat/services/lib/logUtils";
import type { ObsidianNoticeGroups } from "@/modules/services/ObsidianNoticeGroups";
/** A value which may be read once or evaluated when a lifecycle handler runs. */
export type StartupLifecycleValue<T> = T | (() => T);
/** The minimum Context extension needed by the Obsidian start-up integrity check. */
export type StartupLifecycleContext = ServiceContext & {
readonly events: LiveSyncEventHub;
readonly noticeGroups: Pick<ObsidianNoticeGroups, "setItem" | "finish">;
};
/** Services and ServiceModules consumed by the start-up feature composer. */
export type StartupLifecycleHost = NecessaryServices<
"API" | "UI" | "appLifecycle" | "setting" | "replicator" | "vault" | "path" | "keyValueDB" | "database",
"storageAccess" | "fileHandler" | "rebuilder"
> & {
services: NecessaryServices<
"API" | "UI" | "appLifecycle" | "setting" | "replicator" | "vault" | "path" | "keyValueDB" | "database",
"storageAccess" | "fileHandler" | "rebuilder"
>["services"] & {
context: StartupLifecycleContext;
};
};
/** Focused operations which make up the configured Vault first-initialise gate. */
export interface ConfiguredStartupLifecycleOperations {
readonly databaseReady: StartupLifecycleValue<boolean>;
readonly reportDatabaseNotReady: () => void;
readonly hasCompromisedChunks: () => Promise<boolean>;
readonly hasIncompleteDocuments: (force?: boolean) => Promise<boolean>;
readonly waitForCompatibilityReview: () => Promise<void>;
readonly runDoctor: (skipRebuild?: boolean, activateReason?: string, forceRescan?: boolean) => Promise<boolean>;
readonly migrateBulkSend: () => Promise<void>;
}
/** Explicit host decisions and optional operation overrides for composition. */
export interface StartupLifecycleFeatureOptions extends Partial<ConfiguredStartupLifecycleOperations> {
/** Invites an unconfigured Vault to begin onboarding. */
readonly inviteToOnboarding: () => void;
/** Waits for the compatibility review before opening Config Doctor. */
readonly waitForCompatibilityReview: () => Promise<void>;
/** Current configured-state query; defaults to the loaded setting. */
readonly configured?: StartupLifecycleValue<boolean>;
/** Logger used by the default operations. */
readonly log?: LogFunction;
}
/** Minimal mutable settings view required by the obsolete bulk-send migration. */
export type LegacyBulkSendSettings = Pick<ObsidianLiveSyncSettings, "sendChunksBulk" | "sendChunksBulkMaxSize">;
/** Keep path conversion visible at the incomplete-document operation boundary. */
export type StartupPathReader = (entry: AnyEntry) => string;