Refactor conflict resolution into service features

This commit is contained in:
vorotamoroz
2026-09-03 12:35:58 +00:00
parent 3b2d5aa5af
commit 6ea906b575
19 changed files with 2322 additions and 1240 deletions
@@ -1,7 +1,7 @@
---
date: 2026-08-30
commonlib-version: "0.1.19"
self-hosted-livesync-version: "1.0.21"
date: 2026-09-03
commonlib-version: "0.1.21"
self-hosted-livesync-version: "1.0.24"
status: accepted
---
@@ -133,48 +133,17 @@ Commonlib's `targetFilter.ts` keeps each cache or readiness gate in the factory
The state remains private to the composed feature. It does not become a `LiveSyncBaseCore` property or a ServiceModule merely because it persists across calls.
### Legacy example to improve when touched: conflict checking
### Implemented composition: conflict resolution
`ModuleConflictChecker` currently combines:
Conflict checking and resolution are composed for every host by `useConflictResolutionFeature`. The feature owns the two `QueueProcessor` instances privately and registers the conflict Service handlers directly. Its operations receive explicit collaborators for settings, active-file state, database and storage access, replication, logging, and host events. No consumer locates a conflict Module or retains either queue.
- conflict policy decisions;
- two `QueueProcessor` owners;
- cancellation signalling;
- access to settings and active-file state; and
- registration into the conflict Service.
The existing queue pipeline remains one state owner. It continues to publish `conflictProcessQueueCount`, coalesce pending checks for the same path, and make `ensureAllProcessed()` wait for the complete check-and-resolve pipeline. Repeated resolver invocations for one path retain only the newest waiting request and close an active comparison for that path before waiting for the per-file resolver, while comparisons for other paths remain open. Resolution remains host-neutral and communicates dialogue cancellation through `services.context.events`, so CLI, WebApp, and Obsidian compositions use their own selected event channel.
Its queues are class fields which dereference `this.services` during field initialisation, and its public handlers are bound later in `onBindFunction()`.
Interactive resolution is a separate Obsidian-owned serviceFeature. It registers the manual conflict handler, commands, start-up scan, unresolved-message contribution, cancellation listener, and unload clean-up. Its postponed-conflict set, active dialogue, and dialogue queue are private, session-local state. Manual comparisons are shown one at a time: a request for the active file publishes `EVENT_CONFLICT_CANCELLED` to cancel and replace its dialogue, while a request for another file waits. A resolution received through replication closes an open dialogue for the resolved path through the same event, or discards its waiting request before a stale dialogue can open. On unload, the feature drops waiting requests and publishes the same event for the active path before the host event channel is retired, so the dialogue closes and its waiting operation completes. The feature receives a dialogue-opening adapter and connects to the common feature only through the conflict Service; it does not expose an Obsidian application or dialogue as a general capability.
A bounded change to this area should prefer a shape such as:
Both operation layers acquire the active local database through an operation-time accessor. Composition occurs before the database is opened, and a reset may replace the active instance, so retaining the database object at composition time would violate both start-up and reset boundaries.
```typescript
interface ConflictCheckContext {
readonly checkQueue: QueueProcessor<FilePathWithPrefix, unknown>;
readonly resolveQueue: QueueProcessor<FilePathWithPrefix, unknown>;
}
interface ConflictCheckDependencies {
readonly conflict: ConflictCapability;
readonly currentSettings: () => ConflictSettings;
readonly getActiveFilePath: () => FilePathWithPrefix | undefined;
readonly log: LogFunction;
}
function queueConflictCheck(
context: ConflictCheckContext,
dependencies: ConflictCheckDependencies,
path: FilePathWithPrefix
): Promise<void> {
// Make the decision and enqueue through explicit collaborators.
}
export function useConflictChecking(host: ConflictCheckingHost): void {
const context = createConflictCheckContext(host);
host.services.conflict.queueCheckFor.setHandler((path) => queueConflictCheck(context, dependencies, path));
}
```
The exact extraction should be made only when conflict-checking behaviour changes. The example describes the intended ownership boundary; it is not a request to convert the Module in an unrelated documentation change.
`ConflictResolveModal` remains a focused class. One instance owns one dialogue's result promise, event subscription, and close lifetime, which is stable identity and resource ownership rather than application composition. This preserves the distinction between a useful object lifetime and a legacy Module used as a service locator.
## Interaction-based testing
+2 -4
View File
@@ -22,8 +22,6 @@ 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";
@@ -33,6 +31,7 @@ import type { Constructor } from "@vrtmrz/livesync-commonlib/compat/common/utils
import { useReplicationScheduling, type ReplicationSchedulingControl } from "./serviceFeatures/replicationScheduling";
import { createCentralReplicatorProviderDefinitions } from "./common/replicatorProviders";
import { useReplicationFeature } from "./serviceFeatures/replication";
import { useConflictResolutionFeature } from "./serviceFeatures/conflictResolution";
/** Focused views returned by serviceFeatures which the host may consume during composition. */
export interface LiveSyncCoreFeatureViews {
@@ -157,8 +156,6 @@ 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));
@@ -290,6 +287,7 @@ export class LiveSyncBaseCore<
* (Please refer `serviceFeatures` for more details)
*/
initialiseServiceFeatures(): LiveSyncCoreFeatureViews {
useConflictResolutionFeature(this);
useTargetFilters(this);
// enable target filter feature.
usePrepareDatabaseForUse(this);
+5 -2
View File
@@ -6,7 +6,6 @@ import { HiddenFileSync } from "./features/HiddenFileSync/CmdHiddenFileSync.ts";
import { ConfigSync } from "./features/ConfigSync/CmdConfigSync.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";
@@ -46,6 +45,8 @@ import { createOpenReplicationUI, createOpenRebuildUI } from "./features/P2PSync
import { useCompatibilityReview } from "./serviceFeatures/compatibilityReview.ts";
import { createObsidianCompatibilityReviewUi } from "./serviceFeatures/compatibilityReviewObsidian.ts";
import { createFileReflectionProvenance } from "./serviceModules/FileReflectionProvenance.ts";
import { useInteractiveConflictResolutionFeature } from "./serviceFeatures/interactiveConflictResolution";
import { ConflictResolveModal } from "./modules/features/InteractiveConflictResolving/ConflictResolveModal.ts";
export type LiveSyncCore = LiveSyncBaseCore<ObsidianServiceContext, LiveSyncCommands>;
export default class ObsidianLiveSyncPlugin extends Plugin {
core: LiveSyncCore;
@@ -160,7 +161,6 @@ export default class ObsidianLiveSyncPlugin extends Plugin {
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?
@@ -196,6 +196,9 @@ 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)
@@ -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,
})
);
});
});
@@ -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,8 @@ export class ConflictResolveModal extends Modal {
readOnly: boolean = false;
localName: string = "Base";
remoteName: string = "Conflicted";
offEvent?: ReturnType<typeof eventHub.onEvent>;
offConflictCancelled?: ReturnType<typeof eventHub.onEvent>;
offPluginUnloaded?: ReturnType<typeof eventHub.onceEvent>;
currentDiffIndex = -1;
diffView!: HTMLDivElement;
diffNavIndicator!: HTMLSpanElement;
@@ -112,16 +112,19 @@ export class ConflictResolveModal extends Modal {
override onOpen() {
const { contentEl } = this;
if (this.offEvent) {
this.offEvent();
}
this.offConflictCancelled?.();
this.offConflictCancelled = undefined;
this.offPluginUnloaded?.();
this.offPluginUnloaded = eventHub.onceEvent(EVENT_PLUGIN_UNLOADED, () => {
this.sendResponse(CANCELLED);
});
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) => {
this.offConflictCancelled = eventHub.onEvent(EVENT_CONFLICT_CANCELLED, (path) => {
if (path === this.filename) {
this.sendResponse(CANCELLED);
}
@@ -216,9 +219,10 @@ export class ConflictResolveModal extends Modal {
override onClose() {
const { contentEl } = this;
contentEl.empty();
if (this.offEvent) {
this.offEvent();
}
this.offConflictCancelled?.();
this.offConflictCancelled = undefined;
this.offPluginUnloaded?.();
this.offPluginUnloaded = 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);
});
});
@@ -0,0 +1,104 @@
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 and retain both queue processors privately.
* The returned operations do not expose queue state to a host or consumer.
*/
export function createConflictCheckingHandlers(dependencies: ConflictCheckingDependencies): ConflictCheckingHandlers {
const conflictResolveQueue = 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,
}
).replaceEnqueueProcessor((queue, newEntity) => {
const newQueue = [...queue].filter((entry) => entry != newEntity);
return [...newQueue, newEntity];
});
const conflictCheckQueue = new QueueProcessor<FilePathWithPrefix, FilePathWithPrefix>(
(files: FilePathWithPrefix[]) => {
const filename = files[0];
return Promise.resolve([filename]);
},
{
suspended: false,
batchSize: 1,
concurrentLimit: 10,
delay: 0,
keepResultUntilDownstreamConnected: true,
pipeTo: conflictResolveQueue,
totalRemainingReactiveSource: dependencies.conflictProcessQueueCount,
}
);
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 {
conflictCheckQueue.enqueue(file);
}
};
const ensureAllProcessed = (): Promise<boolean> => conflictResolveQueue.waitForAllProcessed();
return {
queueCheckForIfOpen,
queueCheckFor,
ensureAllProcessed,
};
}
@@ -0,0 +1,587 @@
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("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("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("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("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("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
);
});
});
@@ -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,91 @@
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 offConflictCancelled = services.context.events.onEvent(EVENT_CONFLICT_CANCELLED, (filename) => {
operations.invalidateWaitingResolution(filename);
fireAndForget(() => operations.refreshConflictState(filename));
});
let featureDisposed = false;
const dispose = () => {
if (featureDisposed) return;
featureDisposed = true;
// Stop the refresh listener before cancellation so that unloading does
// not start a database read which can race with database disposal.
offConflictCancelled();
operations.dispose();
};
const offPluginUnloaded = services.context.events.onceEvent(EVENT_PLUGIN_UNLOADED, dispose);
services.appLifecycle.onUnload.addHandler(() => {
offPluginUnloaded();
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,683 @@
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_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 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 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: vi.fn(),
},
path: { getPath: vi.fn((entry: { path: FilePathWithPrefix }) => entry.path) },
vault: { getActiveFilePath: vi.fn(() => path) },
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,
constructed,
context,
dialogueResult: {
get value() {
return dialogueResult;
},
set value(value: unknown) {
dialogueResult = value;
},
},
findAllDocs,
getDBEntry,
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 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("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("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("deletes the compared right leaf when concatenating 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);
expect(fixture.storeContent).toHaveBeenCalledWith(path, "");
expect(fixture.resolveByDeletingRevision).toHaveBeenCalledWith(path, "2-right", "UI Concatenated");
});
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("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());
});
});
function createFeatureHarness(
createDialogueForContext?: (
context: ReturnType<typeof createServiceContext>
) => ConflictResolveDialogueFactory
) {
const context = createServiceContext();
const initialLocalDatabase = {
getDBEntry: vi.fn(async () => false),
findAllDocs: vi.fn(() => documents([])),
};
let activeLocalDatabase = initialLocalDatabase;
const getLocalDatabase = vi.fn(() => activeLocalDatabase);
const replaceLocalDatabase = (replacement: typeof initialLocalDatabase) => {
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("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("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;
@@ -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({